// The modules a given entry point's bundle may not contain, keyed by the // repo-relative entry point. // // ONE table, read by both layers that act on it: build.js asserts it against // esbuild's own metafile (the guarantee), and // script/lib/eslint/noStateSingletonInBackground.js reports the same // prohibition in the editor (fast feedback). It lives here because a second // literal copy of the path is exactly how a rename disarms one layer while the // other still looks enforced. // // 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: // one page, one load at boot, one lifetime. The MV3 service worker has no // "once" — it is killed when idle and revived by the next message, nothing // loads state at module scope, and an unpopulated read was answered out of // DEFAULT_STATE in silence. Five defects came from that, one of which // destroyed a wallet (https://git.eeqj.de/sneak/AutistMask/issues/324). The // background has its own per-call storage layer in src/background/state.js // instead. // // What build.js's assertion covers, measured rather than assumed: // // - Any import of a listed module, at any hop, in any specifier syntax, // however esbuild resolved it. The check reads the input list esbuild // reported for the emitted bundle, so it is the resolution the shipped // file was built from and not a model of it. Measured on a computed // specifier that esbuild constant-folds (`require("../shared/" + // "state")`), on a computed specifier it resolves as a glob // (`import("../shared/" + variable)`), and on a symlink to the module // (esbuild reports the real path): each is `make build` exit 2. // // - Every background entry point, whether or not anyone remembered to list // it. A bundled entry point under BACKGROUND_ENTRY_PREFIX with no line in // this table fails the build (assertNoForbiddenInputs()), so adding a // second worker entry point is protected by default rather than protected // only if the person adding it knew about this file. Measured: bundling // src/background/worker2.js with no line here is `make build` exit 2. // // - NOT covered: a COPY of a listed module at another path. The table is // keyed by path, so `cp src/shared/state.js src/shared/stateCopy.js` plus // a background require of the copy is `make build` exit 0 and `make lint` // exit 0 (measured). The copy carries the singleton's own guard, so // defects 1-3 of https://git.eeqj.de/sneak/AutistMask/issues/324 — a read // of a field nothing loaded — become a loud StateNotLoadedError instead of // a silent DEFAULT_STATE. Defects 4 and 5 do NOT: a copy also carries // loadState(), and a stale read several awaits after a load, or a load // detaching the objects an in-flight handler is mutating, are silent over // a LOADED singleton whether it is the original or a copy. So the residual // is wider than "it fails loudly". A newly WRITTEN singleton has no // backstop at all. // // - NOT covered: a background-behaving entry point outside // BACKGROUND_ENTRY_PREFIX. The default protection above is keyed on that // directory, which is also what eslint.config.js scopes the rule to, so a // worker entry point placed somewhere else is covered by neither layer and // needs its own line here. // // The ESLint rule's bounds are its own and are narrower: it matches specifiers // textually, so a computed specifier and a symlink to a listed module are // reported by the build and not by the rule. Both are pinned as non-reports in // tests/backgroundStateLintRule.test.js and are `make build` exit 2 (measured). // A second background entry point reached by one of those two shapes is // therefore caught by the build and not by the rule — which is the same // division of labour as everywhere else here, not an extra hole. // // Every way the table itself can rot is a failure rather than a quiet pass: // // - a KEY no bundled entry point matched, and a listed MODULE this build // bundled nowhere: assertForbiddenTableCovered(), at the end of a build; // - an entry that lists NO modules, and a table with no entries at all: // assertTableWellFormed() below, at require time — so it fails the build // and the lint run alike, because the rule reads the same values and an // empty list leaves it with nothing to look for. // // All of it is pinned by tests/buildForbiddenInputs.test.js. // What counts as a background entry point, and therefore must be listed above. // The build has no other notion of one: entry points are the paths handed to // bundle(), and this prefix is the narrowest rule that names the worker's // directory. eslint.config.js scopes the lint rule with the same prefix, from // this constant, so the two layers cannot disagree about what "background" // means. const BACKGROUND_ENTRY_PREFIX = "src/background/"; const FORBIDDEN_INPUTS = { "src/background/index.js": ["src/shared/state.js"], }; // Refuse a table that cannot prohibit anything. An entry whose module list is // empty passes every check in both layers while enforcing nothing: the build // finds no module to look for and records the entry as checked, and the rule's // forbidden set — Object.values(...).flat() — comes back empty, so a plain // `require("../shared/state")` in the worker is green everywhere. That is a // one-character edit, so it fails here, where the table is defined and both // layers must load it, rather than in either layer's own checks. function assertTableWellFormed(table) { const entries = Object.entries(table); if (entries.length === 0) { throw new Error( "FORBIDDEN_INPUTS is empty, so nothing is prohibited anywhere. " + "Removing the last entry disables the guarantee behind " + "https://git.eeqj.de/sneak/AutistMask/issues/324.", ); } for (const [entry, modules] of entries) { if (!Array.isArray(modules) || modules.length === 0) { throw new Error( `FORBIDDEN_INPUTS["${entry}"] lists no modules, so it ` + `prohibits nothing while still looking enforced. Give it ` + `the modules that entry point may not reach, or remove ` + `the entry.`, ); } } } assertTableWellFormed(FORBIDDEN_INPUTS); module.exports = { BACKGROUND_ENTRY_PREFIX, FORBIDDEN_INPUTS, assertTableWellFormed, };