chocola 2.0.0-next.1 → 2.0.0-next.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,485 @@
1
+ import * as acorn from "acorn";
2
+
3
+ /**
4
+ * Extract binding names from a pattern node (Identifier, ObjectPattern, ArrayPattern, RestElement, AssignmentPattern)
5
+ * Returns flat list of identifier names.
6
+ */
7
+ function extractBindingNames(node) {
8
+ const names = [];
9
+ function walk(n) {
10
+ if (!n) return;
11
+ switch (n.type) {
12
+ case "Identifier":
13
+ names.push(n.name);
14
+ break;
15
+ case "ObjectPattern":
16
+ for (const prop of n.properties) {
17
+ if (prop.type === "RestElement") walk(prop.argument);
18
+ else walk(prop.value);
19
+ }
20
+ break;
21
+ case "ArrayPattern":
22
+ for (const elem of n.elements) {
23
+ if (!elem) continue;
24
+ walk(elem);
25
+ }
26
+ break;
27
+ case "RestElement":
28
+ walk(n.argument);
29
+ break;
30
+ case "AssignmentPattern":
31
+ walk(n.left);
32
+ break;
33
+ default:
34
+ break;
35
+ }
36
+ }
37
+ walk(node);
38
+ return names;
39
+ }
40
+
41
+ /**
42
+ * Parse a <script> block into an ESTree AST and classify top-level declarations.
43
+ *
44
+ * Handles:
45
+ * - export let props (including comma declarators)
46
+ * - let/const with destructuring, comma declarators, array rest
47
+ * - import default/named/namespace/side-effect
48
+ * - function declarations (including async) and $runtime extraction
49
+ *
50
+ * @param {string|null} script - raw script innerHTML or null
51
+ * @returns {{
52
+ * ast: import("acorn").Program|null,
53
+ * props: Array<{name:string, defaultValue:string|undefined, raw:string, initNode: any, declaratorNode:any}>,
54
+ * topVars: Array<{keyword:string, name:string, value:string|undefined, raw:string, names:string[], isDestructuring:boolean, start:number, end:number, initNode:any, declaratorNode:any}>,
55
+ * topFuncs: string[],
56
+ * topFuncNodes: import("acorn").FunctionDeclaration[],
57
+ * imports: Array<{source:string, specifiers:Array<{type:string, local:string, imported?:string}>, raw:string, start:number, end:number}>,
58
+ * runtimeNode: import("acorn").FunctionDeclaration|null,
59
+ * runtime: string|null
60
+ * }}
61
+ */
62
+ export function parseScript(script) {
63
+ if (!script) {
64
+ return {
65
+ ast: null,
66
+ props: [],
67
+ topVars: [],
68
+ topFuncs: [],
69
+ topFuncNodes: [],
70
+ imports: [],
71
+ runtimeNode: null,
72
+ runtime: null,
73
+ };
74
+ }
75
+
76
+ let ast = null;
77
+ try {
78
+ ast = acorn.parse(script, {
79
+ ecmaVersion: 2023,
80
+ sourceType: "module",
81
+ ranges: false,
82
+ });
83
+ } catch (e) {
84
+ return {
85
+ ast: null,
86
+ props: [],
87
+ topVars: [],
88
+ topFuncs: [],
89
+ topFuncNodes: [],
90
+ imports: [],
91
+ runtimeNode: null,
92
+ runtime: null,
93
+ parseError: e.message,
94
+ };
95
+ }
96
+
97
+ const props = [];
98
+ const topVars = [];
99
+ const topFuncs = [];
100
+ const topFuncNodes = [];
101
+ const imports = [];
102
+ let runtimeNode = null;
103
+ let runtime = null;
104
+
105
+ for (const node of ast.body) {
106
+ if (node.type === "ExportNamedDeclaration") {
107
+ const decl = node.declaration;
108
+ if (decl && decl.type === "VariableDeclaration" && decl.kind === "let") {
109
+ for (const d of decl.declarations) {
110
+ const names = extractBindingNames(d.id);
111
+ const defaultValue = d.init ? script.slice(d.init.start, d.init.end).trim() : undefined;
112
+ const raw = script.slice(d.start, d.end);
113
+ for (const name of names) {
114
+ props.push({ name, defaultValue, raw, initNode: d.init || null, declaratorNode: d });
115
+ }
116
+ if (names.length === 0) {
117
+ const patternRaw = script.slice(d.id.start, d.id.end);
118
+ props.push({ name: patternRaw, defaultValue, raw, initNode: d.init || null, declaratorNode: d });
119
+ }
120
+ }
121
+ } else if (!decl) {
122
+ // e.g., export { x } — ignore
123
+ }
124
+ } else if (node.type === "VariableDeclaration") {
125
+ const keyword = node.kind;
126
+ for (const d of node.declarations) {
127
+ const names = extractBindingNames(d.id);
128
+ const value = d.init ? script.slice(d.init.start, d.init.end).trim() : undefined;
129
+ const raw = script.slice(d.start, d.end);
130
+ const idRaw = script.slice(d.id.start, d.id.end);
131
+ const isDestructuring = d.id.type !== "Identifier";
132
+ if (names.length === 0) {
133
+ continue;
134
+ }
135
+ if (isDestructuring) {
136
+ const filtered = names.filter((n) => n !== "self" && n !== "ctx");
137
+ if (filtered.length === 0) continue;
138
+ topVars.push({
139
+ keyword,
140
+ name: idRaw,
141
+ value,
142
+ raw,
143
+ names: filtered,
144
+ isDestructuring: true,
145
+ start: d.start,
146
+ end: d.end,
147
+ initNode: d.init || null,
148
+ declaratorNode: d,
149
+ });
150
+ } else {
151
+ const name = names[0];
152
+ if (name === "self" || name === "ctx") continue;
153
+ topVars.push({
154
+ keyword,
155
+ name,
156
+ value,
157
+ raw,
158
+ names,
159
+ isDestructuring: false,
160
+ start: d.start,
161
+ end: d.end,
162
+ initNode: d.init || null,
163
+ declaratorNode: d,
164
+ });
165
+ }
166
+ }
167
+ } else if (node.type === "FunctionDeclaration") {
168
+ const name = node.id ? node.id.name : null;
169
+ if (name === "$runtime") {
170
+ runtimeNode = node;
171
+ runtime = script.slice(node.start, node.end);
172
+ } else if (name) {
173
+ topFuncs.push(script.slice(node.start, node.end));
174
+ topFuncNodes.push(node);
175
+ }
176
+ } else if (node.type === "ImportDeclaration") {
177
+ const source = node.source.value;
178
+ const specifiers = node.specifiers.map((s) => {
179
+ if (s.type === "ImportDefaultSpecifier") return { type: "default", local: s.local.name };
180
+ if (s.type === "ImportNamespaceSpecifier") return { type: "namespace", local: s.local.name };
181
+ if (s.type === "ImportSpecifier") return { type: "named", imported: s.imported.name, local: s.local.name };
182
+ return { type: "unknown", local: s.local?.name };
183
+ });
184
+ imports.push({
185
+ source,
186
+ specifiers,
187
+ raw: script.slice(node.start, node.end),
188
+ start: node.start,
189
+ end: node.end,
190
+ });
191
+ }
192
+ }
193
+
194
+ return {
195
+ ast,
196
+ props,
197
+ topVars,
198
+ topFuncs,
199
+ topFuncNodes,
200
+ imports,
201
+ runtimeNode,
202
+ runtime,
203
+ };
204
+ }
205
+
206
+ // ---------------------------------------------------------------------------
207
+ // Reachability (Step B)
208
+ // ---------------------------------------------------------------------------
209
+
210
+ const GLOBALS = new Set([
211
+ "self", "ctx",
212
+ "document", "window", "globalThis",
213
+ "console", "Math", "Date", "JSON", "Object", "Array", "String", "Number", "Boolean",
214
+ "Promise", "Set", "Map", "WeakMap", "WeakSet", "RegExp", "Error", "Intl", "URL", "URLSearchParams",
215
+ "parseInt", "parseFloat", "isNaN", "isFinite", "decodeURI", "decodeURIComponent", "encodeURI", "encodeURIComponent",
216
+ "eval", "Function",
217
+ "location", "navigator", "history", "localStorage", "sessionStorage", "fetch",
218
+ "alert", "confirm", "prompt",
219
+ "setTimeout", "setInterval", "clearTimeout", "clearInterval", "requestAnimationFrame", "cancelAnimationFrame",
220
+ "queueMicrotask", "structuredClone",
221
+ "undefined", "NaN", "Infinity",
222
+ ]);
223
+
224
+ function isDynamicRuntime(runtimeNode, script) {
225
+ // Detect eval("..."), new Function(...), with(...), or computed ctx access with non-literal
226
+ let dynamic = false;
227
+ function walk(n) {
228
+ if (!n || dynamic) return;
229
+ if (n.type === "CallExpression") {
230
+ const callee = n.callee;
231
+ if (callee.type === "Identifier" && (callee.name === "eval" || callee.name === "Function")) dynamic = true;
232
+ if (callee.type === "MemberExpression" && callee.object?.name === "ctx" && callee.computed) {
233
+ // will be handled below as MemberExpression case
234
+ }
235
+ }
236
+ if (n.type === "WithStatement") dynamic = true;
237
+ if (n.type === "MemberExpression" && n.object?.type === "Identifier" && n.object.name === "ctx" && n.computed) {
238
+ // ctx["dyn"+x] or ctx[variable] -> dynamic. If property is Literal string, it's static.
239
+ if (n.property.type !== "Literal" || typeof n.property.value !== "string") dynamic = true;
240
+ }
241
+ for (const key in n) {
242
+ if (["type","start","end","loc","range"].includes(key)) continue;
243
+ const child = n[key];
244
+ if (Array.isArray(child)) child.forEach(c => c && typeof c.type === "string" && walk(c));
245
+ else if (child && typeof child.type === "string") walk(child);
246
+ }
247
+ }
248
+ walk(runtimeNode);
249
+ return dynamic;
250
+ }
251
+
252
+ function collectReferencedIdentifiers(node, outSet) {
253
+ // Walk and collect Identifier references, skipping declarations and non-reference positions
254
+ function walk(n, parent) {
255
+ if (!n || typeof n.type !== "string") return;
256
+ if (n.type === "Identifier") {
257
+ // check if this Identifier is a reference (not declaration)
258
+ let isRef = true;
259
+ if (parent) {
260
+ if (parent.type === "VariableDeclarator" && parent.id === n) isRef = false;
261
+ else if ((parent.type === "FunctionDeclaration" || parent.type === "FunctionExpression" || parent.type === "ArrowFunctionExpression") && parent.params.includes(n)) isRef = false;
262
+ else if ((parent.type === "FunctionDeclaration" || parent.type === "FunctionExpression") && parent.id === n) isRef = false;
263
+ else if (parent.type === "MemberExpression" && parent.property === n && !parent.computed) isRef = false;
264
+ else if (parent.type === "Property" && parent.key === n && !parent.computed) isRef = false;
265
+ else if (parent.type === "ImportSpecifier" || parent.type === "ImportDefaultSpecifier" || parent.type === "ImportNamespaceSpecifier") isRef = false;
266
+ else if (parent.type === "ExportSpecifier") isRef = false;
267
+ else if (parent.type === "LabeledStatement" && parent.label === n) isRef = false;
268
+ else if (parent.type === "CatchClause" && parent.param === n) isRef = false;
269
+ else if (parent.type === "AssignmentPattern" && parent.left === n) isRef = false; // param default pattern left is declaration
270
+ }
271
+ if (isRef) outSet.add(n.name);
272
+ return;
273
+ }
274
+ // Skip traversing into declaration patterns where identifiers are not references
275
+ switch (n.type) {
276
+ case "VariableDeclarator":
277
+ // skip id (pattern), only walk init
278
+ if (n.init) walk(n.init, n);
279
+ return;
280
+ case "FunctionDeclaration":
281
+ case "FunctionExpression":
282
+ case "ArrowFunctionExpression":
283
+ // skip params (declarations)
284
+ walk(n.body, n);
285
+ return;
286
+ case "ImportDeclaration":
287
+ return; // skip all
288
+ case "ExportNamedDeclaration":
289
+ if (n.declaration) walk(n.declaration, n);
290
+ return;
291
+ case "MemberExpression":
292
+ walk(n.object, n);
293
+ if (n.computed) walk(n.property, n);
294
+ return;
295
+ case "Property":
296
+ if (n.computed) walk(n.key, n);
297
+ walk(n.value, n);
298
+ return;
299
+ case "MethodDefinition":
300
+ if (n.computed) walk(n.key, n);
301
+ // skip params via FunctionExpression handling already
302
+ walk(n.value, n);
303
+ return;
304
+ default:
305
+ break;
306
+ }
307
+ for (const key in n) {
308
+ if (["type","start","end","loc","range"].includes(key)) continue;
309
+ const child = n[key];
310
+ if (Array.isArray(child)) {
311
+ for (const c of child) if (c && typeof c.type === "string") walk(c, n);
312
+ } else if (child && typeof child.type === "string") {
313
+ walk(child, n);
314
+ }
315
+ }
316
+ }
317
+ walk(node, null);
318
+ }
319
+
320
+ /**
321
+ * Compute client-reachable declarations from $runtime.
322
+ * Transitive closure over vars/funcs/props/imports.
323
+ *
324
+ * @param {ReturnType<typeof parseScript>} parsed
325
+ * @param {{bindings?: string[]}} options
326
+ * @returns {{
327
+ * neededProps: any[],
328
+ * neededVars: any[],
329
+ * neededFuncs: string[],
330
+ * neededFuncNodes: any[],
331
+ * neededImports: any[],
332
+ * neededBindings: string[],
333
+ * fallback: boolean
334
+ * }}
335
+ */
336
+ export function computeReachable(parsed, options = {}) {
337
+ const bindings = options.bindings || [];
338
+ const bindingSet = new Set(bindings);
339
+
340
+ // No runtime -> empty (server-only). Fallback not needed.
341
+ if (!parsed.ast || !parsed.runtimeNode) {
342
+ return {
343
+ neededProps: [],
344
+ neededVars: [],
345
+ neededFuncs: [],
346
+ neededFuncNodes: [],
347
+ neededImports: [],
348
+ neededBindings: [],
349
+ fallback: false,
350
+ };
351
+ }
352
+
353
+ // Parse error fallback -> include all
354
+ if (parsed.parseError) {
355
+ return {
356
+ neededProps: parsed.props.slice(),
357
+ neededVars: parsed.topVars.slice(),
358
+ neededFuncs: parsed.topFuncs.slice(),
359
+ neededFuncNodes: parsed.topFuncNodes.slice(),
360
+ neededImports: parsed.imports.slice(),
361
+ neededBindings: bindings.slice(),
362
+ fallback: true,
363
+ };
364
+ }
365
+
366
+ // Dynamic fallback detection
367
+ if (isDynamicRuntime(parsed.runtimeNode, "")) {
368
+ // Warn and include all
369
+ // console.warn will be done by caller; here just mark fallback
370
+ return {
371
+ neededProps: parsed.props.slice(),
372
+ neededVars: parsed.topVars.slice(),
373
+ neededFuncs: parsed.topFuncs.slice(),
374
+ neededFuncNodes: parsed.topFuncNodes.slice(),
375
+ neededImports: parsed.imports.slice(),
376
+ neededBindings: bindings.slice(),
377
+ fallback: true,
378
+ };
379
+ }
380
+
381
+ // Build maps
382
+ const varMap = new Map(); // name -> var entry (for destructuring, multiple names point to same entry)
383
+ for (const v of parsed.topVars) {
384
+ for (const n of v.names) varMap.set(n, v);
385
+ }
386
+ const funcMap = new Map(); // name -> {node, src, idx}
387
+ for (let i = 0; i < parsed.topFuncNodes.length; i++) {
388
+ const node = parsed.topFuncNodes[i];
389
+ const src = parsed.topFuncs[i];
390
+ funcMap.set(node.id.name, { node, src, idx: i });
391
+ }
392
+ const propMap = new Map();
393
+ for (const p of parsed.props) propMap.set(p.name, p);
394
+ const importMap = new Map();
395
+ for (const imp of parsed.imports) {
396
+ for (const s of imp.specifiers) importMap.set(s.local, imp);
397
+ }
398
+
399
+ const neededVarNames = new Set();
400
+ const neededFuncNames = new Set();
401
+ const neededPropNames = new Set();
402
+ const neededImportLocals = new Set();
403
+ const neededBindingNames = new Set();
404
+ const worklist = [];
405
+
406
+ const runtimeIds = new Set();
407
+ collectReferencedIdentifiers(parsed.runtimeNode.body || parsed.runtimeNode, runtimeIds);
408
+ // Also collect from runtime params? Params are self/ctx globals, ignore
409
+
410
+ for (const id of runtimeIds) {
411
+ if (GLOBALS.has(id)) continue;
412
+ if (bindingSet.has(id)) { neededBindingNames.add(id); }
413
+ else if (varMap.has(id)) { if (!neededVarNames.has(id)) { neededVarNames.add(id); worklist.push(id); } }
414
+ else if (funcMap.has(id)) { if (!neededFuncNames.has(id)) { neededFuncNames.add(id); worklist.push(id); } }
415
+ else if (propMap.has(id)) { if (!neededPropNames.has(id)) { neededPropNames.add(id); worklist.push(id); } }
416
+ else if (importMap.has(id)) { if (!neededImportLocals.has(id)) { neededImportLocals.add(id); worklist.push(id); } }
417
+ }
418
+
419
+ // BFS transitive
420
+ while (worklist.length) {
421
+ const cur = worklist.shift();
422
+ let deps = new Set();
423
+ if (varMap.has(cur)) {
424
+ const entry = varMap.get(cur);
425
+ if (entry.initNode) collectReferencedIdentifiers(entry.initNode, deps);
426
+ } else if (funcMap.has(cur)) {
427
+ const entry = funcMap.get(cur);
428
+ collectReferencedIdentifiers(entry.node, deps);
429
+ // Remove self-reference to avoid trivial loop (func name inside its own body is not a dep)
430
+ deps.delete(cur);
431
+ } else if (propMap.has(cur)) {
432
+ const entry = propMap.get(cur);
433
+ if (entry.initNode) collectReferencedIdentifiers(entry.initNode, deps);
434
+ } else if (importMap.has(cur)) {
435
+ continue;
436
+ }
437
+ for (const dep of deps) {
438
+ if (GLOBALS.has(dep)) continue;
439
+ if (bindingSet.has(dep)) { neededBindingNames.add(dep); continue; }
440
+ if (varMap.has(dep) && !neededVarNames.has(dep)) { neededVarNames.add(dep); worklist.push(dep); }
441
+ else if (funcMap.has(dep) && !neededFuncNames.has(dep)) { neededFuncNames.add(dep); worklist.push(dep); }
442
+ else if (propMap.has(dep) && !neededPropNames.has(dep)) { neededPropNames.add(dep); worklist.push(dep); }
443
+ else if (importMap.has(dep) && !neededImportLocals.has(dep)) { neededImportLocals.add(dep); worklist.push(dep); }
444
+ }
445
+ }
446
+
447
+ // Deduplicate imports/vars that may have multiple names pointing to same entry
448
+ const neededVars = [];
449
+ const seenVarKeys = new Set();
450
+ for (const v of parsed.topVars) {
451
+ const key = v.isDestructuring ? v.raw : v.name;
452
+ if (seenVarKeys.has(key)) continue;
453
+ const isNeeded = v.names.some(n => neededVarNames.has(n));
454
+ if (isNeeded) { neededVars.push(v); seenVarKeys.add(key); }
455
+ }
456
+ const neededFuncs = [];
457
+ const neededFuncNodes = [];
458
+ for (const [name, entry] of funcMap.entries()) {
459
+ if (neededFuncNames.has(name)) {
460
+ neededFuncs.push(entry.src);
461
+ neededFuncNodes.push(entry.node);
462
+ }
463
+ }
464
+ const neededProps = parsed.props.filter(p => neededPropNames.has(p.name));
465
+ const neededImportsSet = new Set();
466
+ for (const local of neededImportLocals) {
467
+ const imp = importMap.get(local);
468
+ if (imp) neededImportsSet.add(imp);
469
+ }
470
+ const neededImports = [...neededImportsSet];
471
+ const neededBindings = bindings.filter(b => neededBindingNames.has(b));
472
+
473
+ return {
474
+ neededProps,
475
+ neededVars,
476
+ neededFuncs,
477
+ neededFuncNodes,
478
+ neededImports,
479
+ neededBindings,
480
+ fallback: false,
481
+ };
482
+ }
483
+
484
+ // Re-export helpers for convenience
485
+ export { extractBindingNames };
@@ -0,0 +1,103 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { parseScript, computeReachable } from "./script.js";
4
+
5
+ test("parseScript: simple let/const and props", () => {
6
+ const { props, topVars } = parseScript(`export let title = "Card"; let a = 1, b = 2; const c = 3;`);
7
+ assert.deepEqual(props.map(p=>p.name), ["title"]);
8
+ assert.equal(props[0].defaultValue, '"Card"');
9
+ assert.deepEqual(topVars.map(v=>v.name), ["a","b","c"]);
10
+ });
11
+
12
+ test("parseScript: destructuring object and array", () => {
13
+ const { topVars } = parseScript(`const {x,y} = obj; let [p, ...rest] = arr; const {a: aa, b: bb} = obj2;`);
14
+ assert.equal(topVars.length, 3);
15
+ assert.deepEqual(topVars[0].names, ["x","y"]);
16
+ assert.equal(topVars[0].isDestructuring, true);
17
+ assert.deepEqual(topVars[1].names, ["p","rest"]);
18
+ assert.deepEqual(topVars[2].names, ["aa","bb"]);
19
+ });
20
+
21
+ test("parseScript: comma declarators", () => {
22
+ const { topVars } = parseScript(`let a = 1, b = 2, c;`);
23
+ assert.equal(topVars.length, 3);
24
+ assert.equal(topVars[0].name, "a");
25
+ assert.equal(topVars[0].value, "1");
26
+ assert.equal(topVars[1].name, "b");
27
+ assert.equal(topVars[2].name, "c");
28
+ assert.equal(topVars[2].value, undefined);
29
+ });
30
+
31
+ test("parseScript: imports default/named/namespace/side-effect", () => {
32
+ const { imports } = parseScript(`
33
+ import Action from "./Action.html";
34
+ import {x} from "./lib.js";
35
+ import * as ns from "./ns.js";
36
+ import "./polyfill.js";
37
+ import {y as z} from "./lib.js";
38
+ `);
39
+ assert.equal(imports.length, 5);
40
+ assert.equal(imports[0].specifiers[0].type, "default");
41
+ assert.equal(imports[0].specifiers[0].local, "Action");
42
+ assert.equal(imports[1].specifiers[0].type, "named");
43
+ assert.equal(imports[2].specifiers[0].type, "namespace");
44
+ assert.equal(imports[3].specifiers.length, 0);
45
+ assert.equal(imports[4].specifiers[0].imported, "y");
46
+ assert.equal(imports[4].specifiers[0].local, "z");
47
+ });
48
+
49
+ test("parseScript: comments containing function $runtime are ignored", () => {
50
+ const { runtime, topVars } = parseScript(`let a = 1; // function $runtime(){ fake }
51
+ /* function $runtime(){ fake } */
52
+ function $runtime(){ a++ }`);
53
+ assert.ok(runtime && runtime.includes("$runtime"));
54
+ assert.equal(topVars.length, 1);
55
+ assert.equal(topVars[0].name, "a");
56
+ });
57
+
58
+ test("parseScript: nested braces in template literals not confused", () => {
59
+ const { topFuncs } = parseScript("function foo(){ return `a {b} c`; } function $runtime(){ foo(); }");
60
+ assert.equal(topFuncs.length, 1);
61
+ assert.ok(topFuncs[0].includes("foo"));
62
+ });
63
+
64
+ test("parseScript: async $runtime", () => {
65
+ const { runtime } = parseScript(`async function $runtime(){ await fetch(); }`);
66
+ assert.ok(runtime && runtime.startsWith("async function $runtime"));
67
+ });
68
+
69
+ test("computeReachable: transitive closure", () => {
70
+ const parsed = parseScript(`let a = b + 1; let b = 2; let unused = 99; function foo(){ return a; } function bar(){ return unused; } function $runtime(){ foo(); }`);
71
+ const reach = computeReachable(parsed, {bindings: []});
72
+ assert.ok(reach.neededVars.some(v=> v.names.includes("a")));
73
+ assert.ok(reach.neededVars.some(v=> v.names.includes("b")));
74
+ assert.equal(reach.neededVars.some(v=> v.names.includes("unused")), false);
75
+ assert.ok(reach.neededFuncs.some(s=> s.includes("foo")));
76
+ assert.equal(reach.neededFuncs.some(s=> s.includes("bar")), false);
77
+ });
78
+
79
+ test("computeReachable: bindings only if referenced", () => {
80
+ const parsed = parseScript(`let count = 0; function inc(){ count++; } function $runtime(){ inc(); btn.addEventListener("click", inc); }`);
81
+ const reach = computeReachable(parsed, {bindings: ["btn","span"]});
82
+ assert.deepEqual(reach.neededBindings, ["btn"]);
83
+ });
84
+
85
+ test("computeReachable: props only if referenced", () => {
86
+ const parsed = parseScript(`export let title = "Card"; export let unused = "hi"; function $runtime(){ console.log(title); }`);
87
+ const reach = computeReachable(parsed, {bindings: []});
88
+ assert.deepEqual(reach.neededProps.map(p=>p.name), ["title"]);
89
+ });
90
+
91
+ test("computeReachable: no runtime -> empty", () => {
92
+ const parsed = parseScript(`let a = 1; function foo(){}`);
93
+ const reach = computeReachable(parsed, {bindings: []});
94
+ assert.equal(reach.neededVars.length, 0);
95
+ assert.equal(reach.neededFuncs.length, 0);
96
+ });
97
+
98
+ test("computeReachable: dynamic fallback", () => {
99
+ const parsed = parseScript(`let a = 1; function $runtime(){ eval("a"); }`);
100
+ const reach = computeReachable(parsed, {bindings: []});
101
+ assert.equal(reach.fallback, true);
102
+ assert.ok(reach.neededVars.length > 0);
103
+ });