// 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. // // THIS RULE IS NOT THE GUARANTEE, and must not be described as one. The // guarantee is in build.js: FORBIDDEN_INPUTS / assertNoForbiddenInputs() fails // the build when esbuild's own metafile reports src/shared/state.js as an input // of a background bundle. That consults the resolution esbuild actually // performed, so no specifier syntax and no resolution rule can slip past it, // and Dockerfile:42 runs `make build` in CI. // // What this rule is: fast local feedback, in the editor and in `make lint`, // before a full bundle. It reads sources from disk and matches import // specifiers TEXTUALLY, so it is a best-effort approximation of module // resolution — a hand-rolled matcher will diverge from a real bundler, and two // earlier revisions of this file proved it by shipping holes (a template // literal, a dynamic `import()`, a comment inside the call, a directory // resolved through `package.json` `main`). Those are all covered now, and the // next divergence is caught by the build rather than by widening this again. // // 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. // // Matching textually over-approximates — a specifier inside a comment or a // string counts — which is the safe direction here: the failure mode is a // spurious error naming an exact file and line, not a silent hole. // // Two shapes this rule does NOT report, both of which the build does fail on // (each measured with `make lint` and `make build` on the branch that added // this note): // // - a computed specifier, `require("../shared/" + "state")` — esbuild // constant-folds it, so it is in the bundle and `make build` is exit 2 // naming src/shared/state.js, while `make lint` is exit 0. Same for // `import("../shared/" + variable)`, which esbuild resolves as a glob. // - a symlink to the module — esbuild reports the real path and fails the // build; this rule resolves the link's own path and sees a different file. // // They are listed as known divergences, not as things that cannot happen. A // matcher will keep diverging from a bundler; that is why the guarantee is the // build's and this rule is not widened again to chase them. const fs = require("fs"); const path = require("path"); const { FORBIDDEN_INPUTS } = require("../forbiddenBundleInputs"); // The modules to keep out, repo-relative, taken from the same table build.js // asserts against so that the two layers cannot name different paths. A second // literal copy here is how a rename disarms one of them while the other still // looks enforced. const FORBIDDEN = [...new Set(Object.values(FORBIDDEN_INPUTS).flat())]; // Whatever may sit between a keyword, a paren and a specifier: whitespace and // comments. `import(/* webpackChunkName: "x" */ "./x")` is a standard bundler // idiom, and an inline `/* eslint-… */` is just as ordinary, so a matcher that // allows only \s there is not strict, it is broken. Each alternative starts // with a distinct character, so this cannot backtrack quadratically. const GAP = "(?:\\s|/\\*[^]*?\\*/|//[^\\n]*)"; const SPECIFIER = "[\"'`]([^\"'`]+)[\"'`]"; // Both alternatives capture the specifier: call form first // (`require(...)`/`import(...)`), then clause form (`from "x"`, and the bare // side-effect `import "x"`). Nothing after the specifier is matched, so a // trailing comment or a trailing comma cannot break the match either. const SPECIFIER_RE = new RegExp( `\\b(?:require|import)${GAP}*\\(${GAP}*${SPECIFIER}` + `|\\b(?:from|import)${GAP}+${SPECIFIER}`, "g", ); // The `main` of a directory's package.json, as a specifier relative to that // directory, or null. esbuild resolves a directory through it, so a walk that // stops at `