// ESLint rule: the background bundle may not reach the shared state singleton. // // src/shared/state.js holds a module-level `state` object, loaded once by // loadState() and mutated in place from then on. That is the popup's model. In // the MV3 service worker there is no "once": the worker is terminated when // idle and revived by the next message, nothing loads state at module scope, // and an unpopulated read used to be served DEFAULT_STATE without complaint — // five defects, one cause // (https://git.eeqj.de/sneak/AutistMask/issues/324). The background has its // own per-call storage layer in src/background/state.js instead. // // A convention nobody can violate beats a convention everyone remembers, so // this is a lint error rather than a review item. It checks REACHABILITY, not // just the direct require: the singleton is one `require()` away from any // shared module the background pulls in, and a re-export would put it back in // the bundle without any background file naming it. So each background file is // the root of a walk over the CommonJS require graph, and the error names the // whole chain that brought the singleton in. // // The walk reads sources from disk and matches import specifiers textually. // That over-approximates — a specifier inside a comment or a string counts — // and over-approximating is the safe direction for a prohibition: the failure // mode is a spurious error naming an exact file and line, not a silent hole. // // It has to match EVERY specifier syntax esbuild resolves statically, because // the hole a narrower match leaves is not "the rule is less tidy", it is a // sixth site the build cannot see. Matching only `require("x")` and `require('x')` // let four shapes through, each of which was confirmed to put the singleton in // the shipped worker bundle: a backtick `require(`x`)`, a dynamic `import("x")`, // a static `import ... from "x"` / `export ... from "x"`, and any of those one // hop away in a shared module the background already pulls in. // // Known and deliberate gap: a computed specifier, `require("../shared/" + // "state")`. It is not matched here, and it is not a hole — esbuild cannot // resolve it statically either, so it never reaches the bundle. Contorting the // rule to chase it would buy nothing. const fs = require("fs"); const path = require("path"); // The module this rule exists to keep out, relative to the repo root. const FORBIDDEN = path.join("src", "shared", "state.js"); // Both alternatives capture the specifier: call form first // (`require(...)`/`import(...)`), then clause form (`from "x"`, and the bare // side-effect `import "x"`). const SPECIFIER_RE = /\b(?:require|import)\(\s*["'`]([^"'`]+)["'`]\s*\)|\b(?:from|import)\s+["'`]([^"'`]+)["'`]/g; // Resolve a relative require to a file path, trying the extensions node would. function resolveRelative(fromFile, spec) { if (!spec.startsWith(".")) return null; // a package, not our tree const base = path.resolve(path.dirname(fromFile), spec); for (const candidate of [ base, base + ".js", base + ".json", path.join(base, "index.js"), ]) { try { if (fs.statSync(candidate).isFile()) return candidate; } catch { // Not this candidate. } } return null; } function requiresOf(file) { let source; try { source = fs.readFileSync(file, "utf8"); } catch { return []; } const out = []; for (const match of source.matchAll(SPECIFIER_RE)) { const resolved = resolveRelative(file, match[1] ?? match[2]); if (resolved) out.push(resolved); } return out; } // Breadth-first from `entry`, returning the shortest chain of files that ends // at the forbidden module, or null when it is not reachable. function chainToForbidden(entry, forbidden) { const seen = new Set([entry]); const queue = [[entry]]; while (queue.length > 0) { const chain = queue.shift(); for (const next of requiresOf(chain[chain.length - 1])) { if (next === forbidden) return chain.concat([next]); if (seen.has(next)) continue; seen.add(next); queue.push(chain.concat([next])); } } return null; } const rule = { meta: { type: "problem", docs: { description: "the background bundle must not be able to reach the" + " module-level state singleton in src/shared/state.js", }, schema: [], messages: { reachable: "The background must not reach the shared state singleton:" + " {{chain}}. The MV3 worker never populates it, so reading it" + " serves DEFAULT_STATE. Use getState()/updateState() from" + " src/background/state.js instead.", }, }, create(context) { return { "Program:exit"(node) { const filename = context.filename; // ESLint lints from the repo root, which is also where the // forbidden path is anchored. const forbidden = path.resolve(context.cwd, FORBIDDEN); const chain = chainToForbidden( path.resolve(filename), forbidden, ); if (!chain) return; context.report({ node, messageId: "reachable", data: { chain: chain .map((file) => path.relative(context.cwd, file)) .join(" -> "), }, }); }, }; }, }; module.exports = { rules: { "no-state-singleton-in-background": rule }, };