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/src/config.js ADDED
@@ -0,0 +1,141 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+
4
+ export const LEVELS = ['safe', 'balanced', 'risky'];
5
+ export const LEVEL_RANK = { safe: 0, balanced: 1, risky: 2 };
6
+
7
+ export const DEFAULT_CONFIG = {
8
+ level: 'balanced',
9
+ transforms: {},
10
+ hotPath: { transitiveDepth: 1 },
11
+ lint: true,
12
+ };
13
+
14
+ export class ConfigError extends Error {}
15
+
16
+ function isPlainObject(v) {
17
+ return v !== null && typeof v === 'object' && !Array.isArray(v);
18
+ }
19
+
20
+ function parseLevel(v) {
21
+ if (typeof v !== 'string' || !LEVELS.includes(v)) {
22
+ throw new ConfigError(`invalid level "${String(v)}" (expected one of ${LEVELS.join(', ')})`);
23
+ }
24
+ return v;
25
+ }
26
+
27
+ /**
28
+ * Validate a raw parsed config object and normalize it onto the defaults.
29
+ * Throws ConfigError on any structural problem.
30
+ */
31
+ export function normalizeConfig(raw) {
32
+ if (raw === undefined || raw === null) return { ...DEFAULT_CONFIG, transforms: {}, hotPath: { transitiveDepth: 1 } };
33
+ if (!isPlainObject(raw)) throw new ConfigError('config root must be a JSON object');
34
+ const cfg = {
35
+ level: DEFAULT_CONFIG.level,
36
+ transforms: {},
37
+ hotPath: { transitiveDepth: 1 },
38
+ lint: true,
39
+ };
40
+ if (raw.level !== undefined) cfg.level = parseLevel(raw.level);
41
+ if (raw.lint !== undefined) {
42
+ if (typeof raw.lint !== 'boolean') throw new ConfigError('lint must be a boolean');
43
+ cfg.lint = raw.lint;
44
+ }
45
+ if (raw.transforms !== undefined) {
46
+ if (!isPlainObject(raw.transforms)) throw new ConfigError('transforms must be an object of booleans');
47
+ for (const [k, v] of Object.entries(raw.transforms)) {
48
+ if (typeof v !== 'boolean') throw new ConfigError(`transforms["${k}"] must be a boolean`);
49
+ cfg.transforms[k] = v;
50
+ }
51
+ }
52
+ if (raw.hotPath !== undefined) {
53
+ if (!isPlainObject(raw.hotPath)) throw new ConfigError('hotPath must be an object');
54
+ if (raw.hotPath.transitiveDepth !== undefined) {
55
+ const d = raw.hotPath.transitiveDepth;
56
+ if (typeof d !== 'number' || !Number.isInteger(d) || d < 0 || d > 10) {
57
+ throw new ConfigError('hotPath.transitiveDepth must be an integer between 0 and 10');
58
+ }
59
+ cfg.hotPath.transitiveDepth = d;
60
+ }
61
+ for (const k of Object.keys(raw.hotPath)) {
62
+ if (k !== 'transitiveDepth') throw new ConfigError(`unknown hotPath option "${k}"`);
63
+ }
64
+ }
65
+ for (const k of Object.keys(raw)) {
66
+ if (!['level', 'transforms', 'hotPath', 'lint'].includes(k)) {
67
+ throw new ConfigError(`unknown config key "${k}"`);
68
+ }
69
+ }
70
+ return cfg;
71
+ }
72
+
73
+ /**
74
+ * Load the config file. `explicitPath` wins; otherwise `./optipack.config.json`
75
+ * is used when present relative to cwd. Returns { config, path } where path is
76
+ * null when no file was found.
77
+ */
78
+ export function loadConfigFile(explicitPath, cwd = process.cwd()) {
79
+ let file = explicitPath;
80
+ if (!file) {
81
+ const def = path.join(cwd, 'optipack.config.json');
82
+ if (fs.existsSync(def)) file = def;
83
+ }
84
+ if (!file) return { config: normalizeConfig(undefined), path: null };
85
+ let text;
86
+ try {
87
+ text = fs.readFileSync(file, 'utf8');
88
+ } catch (e) {
89
+ throw new ConfigError(`cannot read config file ${file}: ${e.message}`);
90
+ }
91
+ if (text.charCodeAt(0) === 0xfeff) text = text.slice(1); // tolerate BOM
92
+ let raw;
93
+ try {
94
+ raw = JSON.parse(text);
95
+ } catch (e) {
96
+ throw new ConfigError(`config file ${file} is not valid JSON: ${e.message}`);
97
+ }
98
+ return { config: normalizeConfig(raw), path: file };
99
+ }
100
+
101
+ /**
102
+ * Merge CLI flags over a loaded config. CLI flags always win.
103
+ * Only recognized transform ids may appear in the transforms map; the caller
104
+ * passes the known id set (from the pass registry).
105
+ */
106
+ export function mergeCliOverrides(config, cli, knownPassIds, knownPassCodes) {
107
+ const out = {
108
+ level: config.level,
109
+ transforms: { ...config.transforms },
110
+ hotPath: { ...config.hotPath },
111
+ lint: config.lint,
112
+ };
113
+ if (cli.level !== undefined) out.level = parseLevel(cli.level);
114
+ if (cli.config && cli.lint !== undefined) out.lint = cli.lint;
115
+ else if (!cli.config && cli.lint !== undefined) out.lint = cli.lint;
116
+
117
+ const normalizeList = (flag, list) => {
118
+ if (!list) return null;
119
+ const ids = list
120
+ .split(',')
121
+ .map((s) => s.trim())
122
+ .filter((s) => s.length > 0);
123
+ for (const id of ids) {
124
+ if (!knownPassIds.has(id) && !knownPassCodes.has(id)) {
125
+ throw new ConfigError(`unknown transform "${id}" in --${flag} (known: ${[...knownPassIds].join(', ')})`);
126
+ }
127
+ }
128
+ return ids;
129
+ };
130
+ const only = normalizeList('only', cli.only);
131
+ const except = normalizeList('except', cli.except);
132
+ if (except) {
133
+ for (const id of except) out.transforms[knownPassIds.has(id) ? id : knownPassCodes.get(id)] = false;
134
+ }
135
+ if (only) {
136
+ // --only replaces the enabled set entirely.
137
+ out.only = new Set(only.map((id) => (knownPassIds.has(id) ? id : knownPassCodes.get(id))));
138
+ }
139
+ if (cli.lint !== undefined) out.lint = cli.lint;
140
+ return out;
141
+ }
package/src/diff.js ADDED
@@ -0,0 +1,108 @@
1
+ /**
2
+ * Minimal dependency-free unified diff (line based, LCS). Deterministic.
3
+ * Used by `optipack --diff`.
4
+ */
5
+
6
+ export function splitLines(text) {
7
+ const lines = text.split('\n');
8
+ // A trailing newline produces a final empty element; drop it for diffing.
9
+ if (lines.length > 0 && lines[lines.length - 1] === '') lines.pop();
10
+ return lines;
11
+ }
12
+
13
+ function lcsTable(a, b) {
14
+ const n = a.length;
15
+ const m = b.length;
16
+ const dp = new Array((n + 1) * (m + 1)).fill(0);
17
+ const at = (i, j) => dp[i * (m + 1) + j];
18
+ for (let i = n - 1; i >= 0; i--) {
19
+ for (let j = m - 1; j >= 0; j--) {
20
+ dp[i * (m + 1) + j] = a[i] === b[j] ? at(i + 1, j + 1) + 1 : Math.max(at(i + 1, j), at(i, j + 1));
21
+ }
22
+ }
23
+ return at;
24
+ }
25
+
26
+ /**
27
+ * Compute ops: [{type: 'eq'|'del'|'add', aLine, bLine, text}]
28
+ */
29
+ export function diffLines(a, b) {
30
+ const A = splitLines(a);
31
+ const B = splitLines(b);
32
+ const at = lcsTable(A, B);
33
+ const ops = [];
34
+ let i = 0;
35
+ let j = 0;
36
+ while (i < A.length && j < B.length) {
37
+ if (A[i] === B[j]) {
38
+ ops.push({ type: 'eq', aLine: i + 1, bLine: j + 1, text: A[i] });
39
+ i++; j++;
40
+ } else if (at(i + 1, j) >= at(i, j + 1)) {
41
+ ops.push({ type: 'del', aLine: i + 1, bLine: j + 1, text: A[i] });
42
+ i++;
43
+ } else {
44
+ ops.push({ type: 'add', aLine: i + 1, bLine: j + 1, text: B[j] });
45
+ j++;
46
+ }
47
+ }
48
+ while (i < A.length) ops.push({ type: 'del', aLine: i + 1, bLine: j + 1, text: A[i++] });
49
+ while (j < B.length) ops.push({ type: 'add', aLine: i + 1, bLine: j + 1, text: B[j++] });
50
+ return ops;
51
+ }
52
+
53
+ /**
54
+ * Render a unified diff for one file. Returns '' when there are no changes.
55
+ */
56
+ export function unifiedDiff(fileA, fileB, aText, bText, contextLines = 3) {
57
+ const ops = diffLines(aText, bText);
58
+ if (ops.every((o) => o.type === 'eq')) return '';
59
+ const out = [];
60
+ out.push(`--- a/${fileA}`);
61
+ out.push(`+++ b/${fileB}`);
62
+ // Group into hunks.
63
+ let idx = 0;
64
+ while (idx < ops.length) {
65
+ if (ops[idx].type === 'eq') { idx++; continue; }
66
+ // Find hunk start: back up `contextLines` eq ops.
67
+ let start = idx;
68
+ let ctxBefore = 0;
69
+ while (start > 0 && ops[start - 1].type === 'eq' && ctxBefore < contextLines) { start--; ctxBefore++; }
70
+ // Extend hunk end while changes or within context of next change.
71
+ let end = idx;
72
+ let pendingEq = 0;
73
+ let k = idx;
74
+ while (k < ops.length) {
75
+ if (ops[k].type !== 'eq') { end = k; pendingEq = 0; k++; }
76
+ else {
77
+ pendingEq++;
78
+ if (pendingEq > contextLines * 2) break;
79
+ end = k;
80
+ k++;
81
+ }
82
+ }
83
+ // Trim trailing eq to contextLines.
84
+ let endTrim = end;
85
+ let ctxAfter = 0;
86
+ while (endTrim > start && ops[endTrim].type === 'eq' && ctxAfter < contextLines) { endTrim--; ctxAfter++; }
87
+ endTrim = Math.min(end, endTrim + (ops[endTrim] && ops[endTrim].type === 'eq' ? 0 : 0));
88
+ // hunk = ops[start..endTrim]
89
+ const hunk = ops.slice(start, endTrim + 1);
90
+ const aStart = hunk[0].aLine;
91
+ const bStart = hunk[0].bLine;
92
+ let aCount = 0;
93
+ let bCount = 0;
94
+ for (const o of hunk) {
95
+ if (o.type !== 'add') aCount++;
96
+ if (o.type !== 'del') bCount++;
97
+ }
98
+ out.push(`@@ -${aStart},${aCount} +${bStart},${bCount} @@`);
99
+ for (const o of hunk) {
100
+ if (o.type === 'eq') out.push(' ' + o.text);
101
+ else if (o.type === 'del') out.push('-' + o.text);
102
+ else out.push('+' + o.text);
103
+ }
104
+ idx = endTrim + 1;
105
+ while (idx < ops.length && ops[idx].type === 'eq') idx++;
106
+ }
107
+ return out.join('\n') + '\n';
108
+ }
package/src/hotpath.js ADDED
@@ -0,0 +1,158 @@
1
+ /**
2
+ * Hot-path classification and the shared pass context.
3
+ */
4
+ import traverse from '@babel/traverse';
5
+ import * as t from '@babel/types';
6
+
7
+ const _traverse = traverse.default || traverse;
8
+
9
+ const HOT_CALLEE_NAMES = new Set(['runInterval', 'runTimeout', 'run', 'runJob', 'subscribe']);
10
+ const TICK_CALLEE_NAMES = new Set(['runInterval', 'run']);
11
+
12
+ function functionLike(node) {
13
+ return t.isFunctionDeclaration(node) || t.isFunctionExpression(node) || t.isArrowFunctionExpression(node);
14
+ }
15
+
16
+ /**
17
+ * Analyze the AST and classify functions as hot.
18
+ * Returns a HotInfo with:
19
+ * isHot(node), isTick(node), hotCount, tickCount,
20
+ * markHot(node), copyHotness(fromNode, toNode)
21
+ */
22
+ export function analyzeHotPaths(ast, pragmas, { transitiveDepth = 1 } = {}) {
23
+ const hot = new WeakSet();
24
+ const tick = new WeakSet();
25
+ let hotCount = 0;
26
+ let tickCount = 0;
27
+
28
+ const addHot = (node, isTick) => {
29
+ if (!functionLike(node)) return;
30
+ if (!hot.has(node)) hotCount++;
31
+ hot.add(node);
32
+ if (isTick && !tick.has(node)) tickCount++;
33
+ if (isTick) tick.add(node);
34
+ };
35
+
36
+ // Resolve an argument that refers to a same-file function (by binding).
37
+ const resolveArg = (path, arg) => {
38
+ if (functionLike(arg)) return { node: arg, viaBinding: null };
39
+ if (t.isIdentifier(arg)) {
40
+ const binding = path.scope.getBinding(arg.name);
41
+ if (!binding) return null;
42
+ if (binding.path.isFunctionDeclaration()) return { node: binding.path.node, viaBinding: binding };
43
+ if (binding.path.isVariableDeclarator()) {
44
+ const init = binding.path.node.init;
45
+ if (functionLike(init)) return { node: init, viaBinding: binding };
46
+ }
47
+ }
48
+ return null;
49
+ };
50
+
51
+ // Pass 1: direct registration sites.
52
+ _traverse(ast, {
53
+ CallExpression(path) {
54
+ const callee = path.node.callee;
55
+ if (!t.isMemberExpression(callee) || callee.computed) return;
56
+ const prop = callee.property;
57
+ if (!t.isIdentifier(prop)) return;
58
+ if (prop.name === 'subscribe' || HOT_CALLEE_NAMES.has(prop.name)) {
59
+ const firstFn = path.node.arguments.find((a) => functionLike(a));
60
+ if (firstFn) {
61
+ addHot(firstFn, TICK_CALLEE_NAMES.has(prop.name));
62
+ return;
63
+ }
64
+ const arg = path.node.arguments[0];
65
+ if (!arg) return;
66
+ const r = resolveArg(path, arg);
67
+ if (r) addHot(r.node, TICK_CALLEE_NAMES.has(prop.name));
68
+ }
69
+ },
70
+ });
71
+
72
+ // Pass 1b: pragma hot marks.
73
+ _traverse(ast, {
74
+ 'FunctionDeclaration|FunctionExpression|ArrowFunctionExpression'(path) {
75
+ const node = path.node;
76
+ if (node.loc && pragmas.isHotLine(node.loc.start.line)) addHot(node, false);
77
+ },
78
+ });
79
+
80
+ // Pass 2: transitive propagation through the same-file call graph.
81
+ // callGraph: targetNode -> Set of caller nodes (null = module top level)
82
+ // Also tracks reference sites for functions passed around as values.
83
+ const callersOf = new Map();
84
+ const recordCaller = (targetNode, callerNode) => {
85
+ if (!targetNode) return;
86
+ if (!callersOf.has(targetNode)) callersOf.set(targetNode, new Set());
87
+ callersOf.get(targetNode).add(callerNode);
88
+ };
89
+
90
+ const resolveCalleeNode = (path, callee) => {
91
+ if (!t.isIdentifier(callee)) return null;
92
+ if (callee.name === 'arguments' || callee.name === 'eval') return null;
93
+ const binding = path.scope.getBinding(callee.name);
94
+ if (!binding) return null;
95
+ if (binding.path.isFunctionDeclaration()) return binding.path.node;
96
+ if (binding.path.isVariableDeclarator()) {
97
+ const init = binding.path.node.init;
98
+ if (functionLike(init)) return init;
99
+ }
100
+ return null;
101
+ };
102
+
103
+ _traverse(ast, {
104
+ CallExpression(path) {
105
+ const target = resolveCalleeNode(path, path.node.callee);
106
+ if (target) recordCaller(target, path.getFunctionParent() ? path.getFunctionParent().node : null);
107
+ },
108
+ ReferencedIdentifier(path) {
109
+ // A function referenced as a value (e.g. passed to runInterval) counts as
110
+ // a use site within the enclosing function.
111
+ const binding = path.scope.getBinding(path.node.name);
112
+ if (!binding) return;
113
+ let target = null;
114
+ if (binding.path.isFunctionDeclaration()) target = binding.path.node;
115
+ else if (binding.path.isVariableDeclarator() && binding.path.node.init && functionLike(binding.path.node.init)) {
116
+ target = binding.path.node.init;
117
+ }
118
+ if (!target) return;
119
+ // Skip the declaration's own identifier.
120
+ if (binding.path.node.id === path.node || (binding.path.node.init && binding.path.node.init === path.node)) return;
121
+ recordCaller(target, path.getFunctionParent() ? path.getFunctionParent().node : null);
122
+ },
123
+ });
124
+
125
+ // BFS from the directly-hot set. A candidate becomes hot when every
126
+ // reference site is inside an already-hot function (i.e. it is only ever
127
+ // reached from hot code) and it has at least one reference site.
128
+ for (let depth = 0; depth < transitiveDepth; depth++) {
129
+ let changed = false;
130
+ for (const [target, callers] of callersOf) {
131
+ if (hot.has(target) || callers.size === 0) continue;
132
+ if (callers.has(null)) continue; // referenced at module top level
133
+ let allHot = true;
134
+ for (const c of callers) {
135
+ if (!hot.has(c)) { allHot = false; break; }
136
+ }
137
+ if (allHot) {
138
+ // Inherit tick-ness: if all callers are tick handlers, so is this.
139
+ let allTick = callers.size > 0;
140
+ for (const c of callers) if (!tick.has(c)) { allTick = false; break; }
141
+ addHot(target, allTick);
142
+ changed = true;
143
+ }
144
+ }
145
+ if (!changed) break;
146
+ }
147
+
148
+ return {
149
+ isHot: (node) => hot.has(node),
150
+ isTick: (node) => tick.has(node),
151
+ markHot: (node) => addHot(node, false),
152
+ copyHotness(fromNode, toNode) {
153
+ if (hot.has(fromNode)) addHot(toNode, tick.has(fromNode));
154
+ },
155
+ get hotCount() { return hotCount; },
156
+ get tickCount() { return tickCount; },
157
+ };
158
+ }
package/src/lint.js ADDED
@@ -0,0 +1,150 @@
1
+ /**
2
+ * Lint pass — reports anti-patterns with line numbers; never rewrites.
3
+ *
4
+ * Rules:
5
+ * - per-tick-getAllPlayers getAllPlayers()/getEntities()/dimension.players
6
+ * unconditionally inside per-tick callbacks
7
+ * - dynprop-in-loop getDynamicProperty inside tick loops beyond what
8
+ * B4 hoisted
9
+ * - residual-allocs closures/arrays/object literals remaining inside
10
+ * hot callbacks (GC pressure metric)
11
+ * - subscribe-in-interval subscribe() called inside runInterval handlers
12
+ * - json-in-hot JSON.parse/JSON.stringify/structuredClone in hot
13
+ * functions
14
+ * - try-catch-wraps-tick try/catch wrapping an entire tick-handler body
15
+ */
16
+ import traverse from '@babel/traverse';
17
+ import * as t from '@babel/types';
18
+ import { walkSameFunction } from './pass-utils.js';
19
+
20
+ const _traverse = traverse.default || traverse;
21
+
22
+ export function runLint(programPath, ctx, hot) {
23
+ const fr = ctx.fileReport;
24
+
25
+ // ---- residual allocations inside hot functions (innermost attribution)
26
+ const counted = new WeakSet();
27
+ programPath.traverse({
28
+ 'FunctionDeclaration|FunctionExpression|ArrowFunctionExpression'(path) {
29
+ const fn = path.node;
30
+ if (!ctx.isHot(fn)) return;
31
+ if (counted.has(fn)) return;
32
+ walkSameFunction(fn.body, (n) => {
33
+ if (n !== fn && (t.isFunctionExpression(n) || t.isArrowFunctionExpression(n))) {
34
+ if (counted.has(n)) return false; // attributed to the inner hot fn
35
+ if (ctx.isHot(n)) return true; // visited separately
36
+ if (n.loc) fr.residualAllocs.closures++;
37
+ counted.add(n);
38
+ return false;
39
+ }
40
+ if (t.isFunctionDeclaration(n) && n !== fn) {
41
+ counted.add(n);
42
+ return false;
43
+ }
44
+ if (n.loc && t.isArrayExpression(n)) fr.residualAllocs.arrays++;
45
+ else if (n.loc && t.isObjectExpression(n)) fr.residualAllocs.objects++;
46
+ return true;
47
+ });
48
+ counted.add(fn);
49
+ },
50
+ });
51
+
52
+ // ---- per-tick scan
53
+ programPath.traverse({
54
+ 'FunctionDeclaration|FunctionExpression|ArrowFunctionExpression'(path) {
55
+ const fn = path.node;
56
+ if (!hot || !hot.isTick(fn)) return;
57
+ const line = fn.loc ? fn.loc.start.line : 0;
58
+
59
+ walkSameFunction(fn.body, (n) => {
60
+ // Rule 1: population sweeps in tick handlers.
61
+ if (t.isCallExpression(n) && t.isMemberExpression(n.callee) && !n.callee.computed &&
62
+ t.isIdentifier(n.callee.property)) {
63
+ const m = n.callee.property.name;
64
+ if (m === 'getAllPlayers' || m === 'getEntities') {
65
+ fr.lint.push({
66
+ line: n.loc ? n.loc.start.line : 0,
67
+ rule: 'per-tick-getAllPlayers',
68
+ message: `${m}() runs unconditionally inside a per-tick callback — prefer event-driven updates or caching between ticks`,
69
+ });
70
+ }
71
+ if (m === 'subscribe') {
72
+ fr.lint.push({
73
+ line: n.loc ? n.loc.start.line : 0,
74
+ rule: 'subscribe-in-interval',
75
+ message: 'subscribe() inside a per-tick handler re-registers on every tick — move registration to setup',
76
+ });
77
+ }
78
+ if (m === 'parse' && t.isMemberExpression(n.callee.object) &&
79
+ t.isIdentifier(n.callee.object.object, { name: 'JSON' })) {
80
+ fr.lint.push({
81
+ line: n.loc ? n.loc.start.line : 0,
82
+ rule: 'json-in-hot',
83
+ message: 'JSON.parse in a hot function — parse once at startup instead',
84
+ });
85
+ }
86
+ }
87
+ if (t.isMemberExpression(n) && !n.computed && t.isIdentifier(n.property) &&
88
+ n.property.name === 'players' && !isCallCallee(n)) {
89
+ // dimension.players — only flag when the object looks like a dimension
90
+ if (t.isIdentifier(n.object) && /dimension/i.test(n.object.name)) {
91
+ fr.lint.push({
92
+ line: n.loc ? n.loc.start.line : 0,
93
+ rule: 'per-tick-getAllPlayers',
94
+ message: 'dimension.players allocates a fresh array every read — cache or go event-driven',
95
+ });
96
+ }
97
+ }
98
+ if (t.isCallExpression(n) && t.isMemberExpression(n.callee) && !n.callee.computed &&
99
+ t.isIdentifier(n.callee.property) && n.callee.property.name === 'stringify' &&
100
+ t.isMemberExpression(n.callee.object) && t.isIdentifier(n.callee.object.object, { name: 'JSON' })) {
101
+ fr.lint.push({
102
+ line: n.loc ? n.loc.start.line : 0,
103
+ rule: 'json-in-hot',
104
+ message: 'JSON.stringify in a hot function — serialize only when data changes',
105
+ });
106
+ }
107
+ if (t.isCallExpression(n) && t.isIdentifier(n.callee) && n.callee.name === 'structuredClone') {
108
+ fr.lint.push({
109
+ line: n.loc ? n.loc.start.line : 0,
110
+ rule: 'json-in-hot',
111
+ message: 'structuredClone in a hot function — clones allocate every tick',
112
+ });
113
+ }
114
+ // Rule 2: remaining getDynamicProperty inside loops.
115
+ if ((t.isForStatement(n) || t.isWhileStatement(n) || t.isDoWhileStatement(n) ||
116
+ t.isForOfStatement(n) || t.isForInStatement(n))) {
117
+ walkSameFunction(n.body, (m) => {
118
+ if (t.isCallExpression(m) && t.isMemberExpression(m.callee) && !m.callee.computed &&
119
+ t.isIdentifier(m.callee.property) && m.callee.property.name === 'getDynamicProperty') {
120
+ fr.lint.push({
121
+ line: m.loc ? m.loc.start.line : 0,
122
+ rule: 'dynprop-in-loop',
123
+ message: 'getDynamicProperty inside a tick loop — reads cross the host boundary every iteration',
124
+ });
125
+ }
126
+ return true;
127
+ });
128
+ }
129
+ return true;
130
+ });
131
+
132
+ // Rule 6: try/catch wrapping the whole handler body.
133
+ if (t.isBlockStatement(fn.body) && fn.body.body.length === 1 &&
134
+ t.isTryStatement(fn.body.body[0])) {
135
+ fr.lint.push({
136
+ line: fn.body.body[0].loc ? fn.body.body[0].loc.start.line : line,
137
+ rule: 'try-catch-wraps-tick',
138
+ message: 'try/catch wraps the entire tick handler body — errors are swallowed every tick',
139
+ });
140
+ }
141
+ },
142
+ });
143
+ }
144
+
145
+ function isCallCallee(memberPathNode) {
146
+ // We walk nodes without paths here; a member expression that is a callee is
147
+ // handled by the CallExpression branch above, so treat all as non-callee.
148
+ void memberPathNode;
149
+ return false;
150
+ }
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Pack walker — walks the input behavior pack, copies non-script files
3
+ * verbatim, and returns the list of script files to transform.
4
+ *
5
+ * Only files under `scripts/` with .js/.mjs extensions are transformed.
6
+ * node_modules/ and .git/ are skipped entirely. Everything else (manifest.json,
7
+ * JSON, functions, texts, textures) is copied byte-for-byte.
8
+ */
9
+ import fs from 'node:fs';
10
+ import path from 'node:path';
11
+
12
+ const SCRIPT_EXT = new Set(['.js', '.mjs']);
13
+ const SKIP_DIRS = new Set(['node_modules', '.git']);
14
+
15
+ export function walkPack(inputDir) {
16
+ const scripts = [];
17
+ const copies = [];
18
+ const visit = (dir, rel) => {
19
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
20
+ entries.sort((a, b) => a.name.localeCompare(b.name));
21
+ for (const ent of entries) {
22
+ const abs = path.join(dir, ent.name);
23
+ const relPath = rel ? `${rel}/${ent.name}` : ent.name;
24
+ if (ent.isDirectory()) {
25
+ if (SKIP_DIRS.has(ent.name)) continue;
26
+ visit(abs, relPath);
27
+ } else if (ent.isFile()) {
28
+ const ext = path.extname(ent.name).toLowerCase();
29
+ const inScripts = relPath === 'scripts' || relPath.startsWith('scripts/');
30
+ if (inScripts && SCRIPT_EXT.has(ext)) {
31
+ scripts.push({ abs, relPath });
32
+ } else {
33
+ copies.push({ abs, relPath });
34
+ }
35
+ }
36
+ }
37
+ };
38
+ visit(inputDir, '');
39
+ return { scripts, copies };
40
+ }
41
+
42
+ export function copyFile(srcAbs, destAbs) {
43
+ fs.mkdirSync(path.dirname(destAbs), { recursive: true });
44
+ fs.copyFileSync(srcAbs, destAbs);
45
+ }
46
+
47
+ export function writeFile(destAbs, content) {
48
+ fs.mkdirSync(path.dirname(destAbs), { recursive: true });
49
+ fs.writeFileSync(destAbs, content);
50
+ }