optipack 0.0.0-stage → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (148) hide show
  1. package/BENCHMARKING.md +127 -0
  2. package/LICENSE +21 -0
  3. package/README.md +136 -2
  4. package/bin/optipack.js +12 -0
  5. package/example/manifest.json +27 -0
  6. package/example/scripts/drops.js +24 -0
  7. package/example/scripts/main.js +67 -0
  8. package/example/scripts/util.js +30 -0
  9. package/package.json +34 -4
  10. package/src/bench.js +231 -0
  11. package/src/cli.js +245 -0
  12. package/src/config.js +141 -0
  13. package/src/diff.js +108 -0
  14. package/src/harness/driver.js +216 -0
  15. package/src/harness/loader-hooks.mjs +63 -0
  16. package/src/harness/mock/@minecraft/common.mjs +17 -0
  17. package/src/harness/mock/@minecraft/debug-utilities.mjs +10 -0
  18. package/src/harness/mock/@minecraft/diagnostics.mjs +14 -0
  19. package/src/harness/mock/@minecraft/server-admin.mjs +14 -0
  20. package/src/harness/mock/@minecraft/server-editor.mjs +39 -0
  21. package/src/harness/mock/@minecraft/server-extras.mjs +538 -0
  22. package/src/harness/mock/@minecraft/server-gametest.mjs +22 -0
  23. package/src/harness/mock/@minecraft/server-graphics.mjs +21 -0
  24. package/src/harness/mock/@minecraft/server-net.mjs +15 -0
  25. package/src/harness/mock/@minecraft/server-ui.mjs +125 -0
  26. package/src/harness/mock/@minecraft/server.mjs +394 -0
  27. package/src/harness/mock/permissive.mjs +71 -0
  28. package/src/hotpath.js +158 -0
  29. package/src/lint.js +150 -0
  30. package/src/pack-walker.js +50 -0
  31. package/src/pass-utils.js +419 -0
  32. package/src/passes/b1-cache-getter-chains.js +406 -0
  33. package/src/passes/b2-closure-lift.js +316 -0
  34. package/src/passes/b3-string-build.js +167 -0
  35. package/src/passes/b4-dynprop-hoist.js +177 -0
  36. package/src/passes/b5-null-chain-flatten.js +137 -0
  37. package/src/passes/index.js +43 -0
  38. package/src/passes/r1-cross-tick-cache.js +192 -0
  39. package/src/passes/r2-json-fold.js +201 -0
  40. package/src/passes/r3-try-catch-strip.js +61 -0
  41. package/src/passes/r4-getter-cache-cross-barrier.js +29 -0
  42. package/src/passes/s1-const-fold.js +130 -0
  43. package/src/passes/s2-dead-code.js +183 -0
  44. package/src/passes/s3-bool-simplify.js +82 -0
  45. package/src/passes/s4-for-of-to-indexed.js +140 -0
  46. package/src/passes/s5-array-methods-to-loops.js +357 -0
  47. package/src/passes/s6-destructure-to-direct.js +186 -0
  48. package/src/passes/s7-hoist-invariant-members.js +327 -0
  49. package/src/pipeline.js +186 -0
  50. package/src/pragma.js +115 -0
  51. package/src/report.js +98 -0
  52. package/test/array-methods-to-loops/expected.js +58 -0
  53. package/test/array-methods-to-loops/expects.json +6 -0
  54. package/test/array-methods-to-loops/input.js +23 -0
  55. package/test/array-methods-to-loops/scenario.json +22 -0
  56. package/test/array-methods-to-loops-bail/expected.js +10 -0
  57. package/test/array-methods-to-loops-bail/input.js +24 -0
  58. package/test/bool-simplify/expected.js +14 -0
  59. package/test/bool-simplify/expects.json +11 -0
  60. package/test/bool-simplify/input.js +18 -0
  61. package/test/cache-getter-chains/expected.js +20 -0
  62. package/test/cache-getter-chains/expects.json +11 -0
  63. package/test/cache-getter-chains/input.js +20 -0
  64. package/test/cache-getter-chains/scenario.json +35 -0
  65. package/test/closure-lift/expected.js +58 -0
  66. package/test/closure-lift/expects.json +1 -0
  67. package/test/closure-lift/input.js +50 -0
  68. package/test/closure-lift/scenario.json +9 -0
  69. package/test/const-fold/expected.js +12 -0
  70. package/test/const-fold/expects.json +1 -0
  71. package/test/const-fold/input.js +12 -0
  72. package/test/const-fold-bail/expected.js +7 -0
  73. package/test/const-fold-bail/expects.json +3 -0
  74. package/test/const-fold-bail/input.js +7 -0
  75. package/test/cross-tick-cache/config.json +1 -0
  76. package/test/cross-tick-cache/expected.js +38 -0
  77. package/test/cross-tick-cache/expects.json +6 -0
  78. package/test/cross-tick-cache/input.js +13 -0
  79. package/test/cross-tick-cache/scenario.json +33 -0
  80. package/test/dead-code/expected.js +14 -0
  81. package/test/dead-code/expects.json +5 -0
  82. package/test/dead-code/input.js +23 -0
  83. package/test/destructure-to-direct/expected.js +37 -0
  84. package/test/destructure-to-direct/expects.json +11 -0
  85. package/test/destructure-to-direct/input.js +28 -0
  86. package/test/destructure-to-direct/scenario.json +22 -0
  87. package/test/destructure-to-direct-bail/expected.js +36 -0
  88. package/test/destructure-to-direct-bail/expects.json +11 -0
  89. package/test/destructure-to-direct-bail/input.js +25 -0
  90. package/test/differential.test.mjs +267 -0
  91. package/test/dynprop-hoist/expected.js +24 -0
  92. package/test/dynprop-hoist/expects.json +11 -0
  93. package/test/dynprop-hoist/input.js +25 -0
  94. package/test/dynprop-hoist/scenario.json +24 -0
  95. package/test/example-report-snapshot.json +107 -0
  96. package/test/example.test.mjs +115 -0
  97. package/test/fetch-corpus.mjs +92 -0
  98. package/test/for-of-to-indexed/expected.js +59 -0
  99. package/test/for-of-to-indexed/expects.json +11 -0
  100. package/test/for-of-to-indexed/input.js +43 -0
  101. package/test/for-of-to-indexed-bail/expected.js +25 -0
  102. package/test/for-of-to-indexed-bail/expects.json +11 -0
  103. package/test/for-of-to-indexed-bail/input.js +29 -0
  104. package/test/gen-expected.mjs +33 -0
  105. package/test/getter-cache-cross-barrier/config.json +1 -0
  106. package/test/getter-cache-cross-barrier/expected.js +10 -0
  107. package/test/getter-cache-cross-barrier/expects.json +6 -0
  108. package/test/getter-cache-cross-barrier/input.js +11 -0
  109. package/test/getter-cache-cross-barrier/scenario.json +28 -0
  110. package/test/harness/fixture.js +117 -0
  111. package/test/hoist-invariant-members/expected.js +37 -0
  112. package/test/hoist-invariant-members/expects.json +11 -0
  113. package/test/hoist-invariant-members/input.js +34 -0
  114. package/test/hoist-invariant-members/scenario.json +35 -0
  115. package/test/hoist-invariant-members-bail/expected.js +19 -0
  116. package/test/hoist-invariant-members-bail/expects.json +5 -0
  117. package/test/hoist-invariant-members-bail/input.js +22 -0
  118. package/test/infra.test.mjs +270 -0
  119. package/test/json-fold/config.json +1 -0
  120. package/test/json-fold/expected.js +14 -0
  121. package/test/json-fold/expects.json +12 -0
  122. package/test/json-fold/input.js +17 -0
  123. package/test/json-fold-bail/config.json +1 -0
  124. package/test/json-fold-bail/expected.js +10 -0
  125. package/test/json-fold-bail/expects.json +5 -0
  126. package/test/json-fold-bail/input.js +14 -0
  127. package/test/null-chain-flatten/expected.js +22 -0
  128. package/test/null-chain-flatten/expects.json +5 -0
  129. package/test/null-chain-flatten/input.js +22 -0
  130. package/test/null-chain-flatten/scenario.json +24 -0
  131. package/test/server-ui-forms/expected.js +15 -0
  132. package/test/server-ui-forms/expects.json +1 -0
  133. package/test/server-ui-forms/input.js +25 -0
  134. package/test/server-ui-forms/scenario.json +1 -0
  135. package/test/string-build/expected.js +29 -0
  136. package/test/string-build/expects.json +5 -0
  137. package/test/string-build/input.js +29 -0
  138. package/test/string-build/scenario.json +27 -0
  139. package/test/string-build-bail/expected.js +10 -0
  140. package/test/string-build-bail/input.js +21 -0
  141. package/test/transforms.test.mjs +43 -0
  142. package/test/try-catch-strip/config.json +1 -0
  143. package/test/try-catch-strip/expected.js +57 -0
  144. package/test/try-catch-strip/expects.json +1 -0
  145. package/test/try-catch-strip/input.js +34 -0
  146. package/test/try-catch-strip/scenario.json +28 -0
  147. package/test/update-example-snapshot.mjs +19 -0
  148. package/test/update-mock-stubs.mjs +91 -0
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
+ }