// module-refs.test.mjs — the static reference check for the extracted shell // modules (docs/shell-modules.md). A verbatim slice can silently drop an // import: reads work in the old module and throw ReferenceError in the new // one, at runtime only, on paths tests don't walk (the empty-new-chat-sheet // bug, 2026-07-15). This test fails on any identifier a module references // but neither declares, imports, nor gets from the JS/browser globals. // // The scan is deliberately scope-blind (a declaration anywhere in the file // counts) — that trades a known false-negative class (a same-named local // masking a missing import) for zero false positives from nesting. Keep the // GLOBALS list and per-file allowlist tight; every allowlist entry must say // why it's resolved at runtime. import { readFileSync } from "node:fs"; import { test } from "node:test"; import assert from "node:assert"; const MODULES = [ "theme", "lightbox", "media", "shellstate", "shellverbs", "moderation", "searchui", "avatars", "rosterui", "groupactions", "newchat", "adddevice", "onboardingui", "billingui", ]; const GLOBALS = new Set(`if else for while switch case default catch finally return await async new typeof delete void in of do break continue try throw this true false null undefined function const let var class extends super static get set yield export import from as fetch encodeURIComponent decodeURIComponent atob btoa JSON Promise Date Math Object Array String Number Boolean URL URLSearchParams Blob File Uint8Array ArrayBuffer FileReader Set Map WeakMap WeakSet RegExp Error TypeError RangeError console document window location history navigator crypto performance screen localStorage sessionStorage matchMedia requestAnimationFrame cancelAnimationFrame setTimeout setInterval clearTimeout clearInterval alert confirm prompt structuredClone AbortController CustomEvent Event KeyboardEvent MouseEvent WebSocket TextEncoder TextDecoder Intl isNaN isFinite parseInt parseFloat Function Symbol Proxy Reflect queueMicrotask createImageBitmap Audio Image Notification getComputedStyle getSelection NaN Infinity globalThis arguments addEventListener removeEventListener dispatchEvent`.split(/\s+/)); // Verified-at-runtime exceptions, one line of why each: const ALLOW = { // media.js: destructured/defaulted params inside multiline signatures the // scanner's flat param regex doesn't walk; all local. "media.js": ["ch", "dims", "dur", "frame", "poster", "kept", "ignore", "no", "undecodable"], }; function scan(name) { const src = readFileSync(new URL(`../web/static/${name}.js`, import.meta.url), "utf8"); let code = src.replace(/\/\/[^\n]*/g, ""); code = code.replace(/\/\*[\s\S]*?\*\//g, ""); // Template literals: their ${...} interpolations are real code — extract // them (one brace level) before stripping the literal text, or references // hiding in interpolations vanish from the scan (the adddevice.js // buildInviteFragment escape, 2026-07-15). const interp = [...code.matchAll(/\$\{([^{}]*)\}/g)].map((m) => m[1]); code = code.replace(/`(?:[^`\\]|\\[\s\S])*`/g, "``"); code += "\n;" + interp.join(";\n;") + ";"; code = code.replace(/"(?:[^"\\]|\\.)*"/g, '""'); code = code.replace(/'(?:[^'\\]|\\.)*'/g, "''"); code = code.replace(/\/(?:[^/\\\n[]|\\.|\[(?:[^\]\\]|\\.)*\])+\/[gimsuy]*/g, "0"); // regex literals const declared = new Set(); for (const m of code.matchAll(/function\s+([A-Za-z_$][\w$]*)/g)) declared.add(m[1]); for (const m of code.matchAll(/(?:const|let|var)\s+([A-Za-z_$][\w$]*)/g)) declared.add(m[1]); for (const m of code.matchAll(/(?:const|let|var)\s*\{([^}]*)\}/g)) for (const w of m[1].replaceAll(":", ",").split(",")) if (w.trim()) declared.add(w.trim().split("=")[0].trim()); for (const m of code.matchAll(/(?:const|let|var)\s*\[([^\]]*)\]/g)) for (const w of m[1].split(",")) if (w.trim()) declared.add(w.trim().split("=")[0].trim()); for (const m of code.matchAll(/import\s*\{([^}]*)\}/g)) for (const w of m[1].split(",")) if (w.trim()) declared.add(w.trim().split(/\s+as\s+/).pop().trim()); for (const m of code.matchAll(/import\s+([A-Za-z_$][\w$]*)\s+from/g)) declared.add(m[1]); for (const m of code.matchAll(/\(([^()]*)\)\s*(?:=>|\{)/g)) for (const w of m[1].split(",")) { const bare = w.trim().split("=")[0].trim().replace(/^\.{3}/, ""); if (/^[A-Za-z_$][\w$]*$/.test(bare)) declared.add(bare); // one level of destructured params: ({ a, b: c }) / ([x, y]) for (const inner of bare.replace(/^[{[]|[}\]]$/g, "").replaceAll(":", ",").split(",")) if (/^[A-Za-z_$][\w$]*$/.test(inner.trim())) declared.add(inner.trim()); } for (const m of code.matchAll(/catch\s*\(([^)]*)\)/g)) declared.add(m[1].trim()); for (const m of code.matchAll(/([A-Za-z_$][\w$]*)\s*=>/g)) declared.add(m[1]); // single-param arrows // multi-declarator lists: let a, b, c; for (const m of code.matchAll(/(?:const|let|var)\s+([A-Za-z_$][\w$]*(?:\s*,\s*[A-Za-z_$][\w$]*)+)\s*;/g)) for (const w of m[1].split(",")) declared.add(w.trim()); // usable references: strip import statements (declaration sites, incl. alias // source names), then property accesses and object keys code = code.replace(/import\s*\{[^}]*\}\s*from\s*""\s*;?/g, ""); let refs = code.replace(/\.\s*[A-Za-z_$][\w$]*/g, "."); refs = refs.replace(/\b([A-Za-z_$][\w$]*)\s*:/g, ":"); const used = new Set([...refs.matchAll(/\b([A-Za-z_$][\w$]*)\b/g)].map((m) => m[1])); const allow = new Set(ALLOW[`${name}.js`] || []); return [...used].filter((id) => !declared.has(id) && !GLOBALS.has(id) && !allow.has(id)).sort(); } for (const name of MODULES) { test(`web/static/${name}.js has no unresolved references`, () => { const unresolved = scan(name); assert.deepStrictEqual(unresolved, [], `unresolved in ${name}.js: ${unresolved.join(", ")}`); }); }