// build.js's FORBIDDEN_INPUTS assertion — the mechanical guarantee that the // MV3 background bundle cannot contain src/shared/state.js // (https://git.eeqj.de/sneak/AutistMask/issues/324). // // Why this file exists: `make check` does not run `make build`. CI executes // the assertion (Dockerfile runs `make build`), but executing is not testing — // invert its condition, or make the table lookup always come back undefined, // and every check in this repo stays green while the singleton walks back into // the worker. Five defects, one destroyed wallet, and the whole argument for // the scoped loud-read guard rest on this assertion, so it is pinned here. // // The subject is build.js's exported helpers plus the table's own // well-formedness check, driven against SYNTHETIC metafiles in esbuild's // shape. Nothing here shells out to a build or writes dist/: the assertion's // job is to read a metafile correctly, and a metafile is data. That the real // shapes reach it is the build's own business and is measured in the PR that // introduced it. // // Every vacuous pass this guarantee has been found to have is pinned below, // because each one was a way for `make check`, `make lint` and `make build` to // be green over a background bundle containing the singleton: a stale key, a // stale module, an entry that lists no modules, an entry recorded as checked // before its bundle was in hand, and a background entry point nobody added to // the table. // // Paths are absolute on the way in, because the helpers normalize whatever // esbuild gave them to repo-relative and this file should not depend on the // working directory jest was started from. const path = require("path"); const { importChain, newForbiddenRecord, recordBundledInputs, assertNoForbiddenInputs, assertForbiddenTableCovered, } = require("../build"); const { BACKGROUND_ENTRY_PREFIX, FORBIDDEN_INPUTS, assertTableWellFormed, } = require("../script/lib/forbiddenBundleInputs"); const ROOT = path.resolve(__dirname, ".."); const abs = (p) => path.join(ROOT, p); const ENTRY = "src/background/index.js"; const OUT = "dist/chrome/src/background/index.js"; const STATE = "src/shared/state.js"; const HOP = "src/shared/chainSwitchFields.js"; const SECOND = "src/background/worker2.js"; const SECOND_OUT = "dist/chrome/src/background/worker2.js"; const POPUP = "src/popup/index.js"; const POPUP_OUT = "dist/chrome/src/popup/index.js"; // The real table's shape: entry point -> modules its bundle may not contain. const TABLE = { [ENTRY]: [STATE] }; // A metafile as esbuild emits one: `outputs[out].inputs` is the flat list of // every input that contributed to that output, and `inputs[file].imports` is // the edge list, which is what the chain walk follows. function metafile({ outputs = {}, imports = {} } = {}) { return { outputs: Object.fromEntries( Object.entries(outputs).map(([out, inputs]) => [ abs(out), { inputs: Object.fromEntries( inputs.map((input) => [ abs(input), { bytesInOutput: 1 }, ]), ), }, ]), ), inputs: Object.fromEntries( Object.entries(imports).map(([file, targets]) => [ abs(file), { imports: targets.map((target) => ({ path: abs(target) })) }, ]), ), }; } function check(mf, table = TABLE, record = newForbiddenRecord()) { recordBundledInputs(mf, record); assertNoForbiddenInputs(abs(ENTRY), abs(OUT), mf, record, table); return record; } describe("assertNoForbiddenInputs()", () => { test("a forbidden module in the bundle fails, naming the import chain", () => { const mf = metafile({ outputs: { [OUT]: [ENTRY, HOP, STATE] }, imports: { [ENTRY]: [HOP], [HOP]: [STATE], }, }); expect(() => check(mf)).toThrow( `${OUT} bundles ${STATE}, which ${ENTRY} must not reach: ` + `${ENTRY} -> ${HOP} -> ${STATE}.`, ); }); test("a bundle without the forbidden module passes, and is recorded as checked", () => { const mf = metafile({ outputs: { [OUT]: [ENTRY, HOP, "src/background/state.js"] }, imports: { [ENTRY]: [HOP, "src/background/state.js"] }, }); const record = check(mf); expect([...record.entriesChecked]).toEqual([ENTRY]); expect(record.bundledInputs.has(STATE)).toBe(false); }); test("the failure still names the bundle when no import chain can be shown", () => { // esbuild resolves `import("../shared/" + variable)` as a glob: the // module is an input of the output, but no single edge leads to it. // The message must degrade to no chain rather than crash. const mf = metafile({ outputs: { [OUT]: [ENTRY, STATE] }, imports: { [ENTRY]: [] }, }); expect(() => check(mf)).toThrow( `${OUT} bundles ${STATE}, which ${ENTRY} must not reach.`, ); }); // The lookup this covers is the fragile step in the whole assertion: // repoRelative() resolves against process.cwd() and esbuild's output keys // are cwd-relative, so a change to where the build runs from, or to // outfile vs outdir, makes it miss. It must be loud, and the entry must // NOT already be marked checked when it does — otherwise a later edit // turning this throw into an early return leaves both halves of the // guarantee satisfied by a bundle nothing looked at. test("an output esbuild did not report fails, and records nothing as checked", () => { const mf = metafile({ outputs: { "dist/chrome/src/background/renamed.js": [ENTRY] }, imports: { [ENTRY]: [] }, }); const record = newForbiddenRecord(); recordBundledInputs(mf, record); expect(() => assertNoForbiddenInputs(abs(ENTRY), abs(OUT), mf, record, TABLE), ).toThrow(`esbuild reported no metafile output for ${OUT}`); expect([...record.entriesChecked]).toEqual([]); // So even if that throw became `return`, the coverage half catches it. record.bundledInputs.add(STATE); expect(() => assertForbiddenTableCovered(record, TABLE)).toThrow( `${ENTRY} is listed in FORBIDDEN_INPUTS but was not bundled`, ); }); }); // Finding from the fifth review of this change: adding a second worker entry // point is exactly the accident this guarantee exists for, and the person // adding one has no reason to know a table elsewhere needs a line. So the // default for the background directory is protected, not unprotected. describe("background entry points are protected by default", () => { test("a bundled background entry point with no line in the table fails", () => { const mf = metafile({ outputs: { [SECOND_OUT]: [SECOND, STATE] }, imports: { [SECOND]: [STATE] }, }); const record = newForbiddenRecord(); recordBundledInputs(mf, record); expect(() => assertNoForbiddenInputs( abs(SECOND), abs(SECOND_OUT), mf, record, TABLE, ), ).toThrow( `${SECOND} is a background entry point with no line in ` + `FORBIDDEN_INPUTS`, ); }); test("an entry point outside the background directory needs no line", () => { // The popup legitimately bundles the singleton; that is its model. const mf = metafile({ outputs: { [POPUP_OUT]: [POPUP, STATE] }, imports: { [POPUP]: [STATE] }, }); const record = newForbiddenRecord(); recordBundledInputs(mf, record); expect(() => assertNoForbiddenInputs( abs(POPUP), abs(POPUP_OUT), mf, record, TABLE, ), ).not.toThrow(); expect([...record.entriesChecked]).toEqual([]); }); test("the prefix is the one the lint rule is scoped to", () => { expect(BACKGROUND_ENTRY_PREFIX).toBe("src/background/"); expect(SECOND.startsWith(BACKGROUND_ENTRY_PREFIX)).toBe(true); expect(POPUP.startsWith(BACKGROUND_ENTRY_PREFIX)).toBe(false); }); }); describe("recordBundledInputs()", () => { // The sole data source for the module half of the anti-rot check, and // every other case here hand-seeds what it produces. Driven for real, // across two outputs, and then handed straight to the check that reads it. test("records every input of every output, satisfying the module half", () => { const mf = metafile({ outputs: { [OUT]: [ENTRY, HOP], [POPUP_OUT]: [POPUP, STATE], }, imports: { [ENTRY]: [HOP], [POPUP]: [STATE] }, }); const record = newForbiddenRecord(); recordBundledInputs(mf, record); expect([...record.bundledInputs].sort()).toEqual( [ENTRY, HOP, POPUP, STATE].sort(), ); // Nothing hand-seeded: the covered check passes on what the recorder // actually collected, so a recorder that collects nothing fails here. assertNoForbiddenInputs(abs(ENTRY), abs(OUT), mf, record, TABLE); expect(() => assertForbiddenTableCovered(record, TABLE)).not.toThrow(); }); }); describe("importChain()", () => { test("terminates on a cyclic input graph, and still finds the module", () => { const mf = metafile({ imports: { [ENTRY]: [HOP], [HOP]: ["src/shared/log.js"], // The cycle: log <-> hop, with the target one hop past it. "src/shared/log.js": [HOP, STATE], }, }); expect(importChain(mf, abs(ENTRY), STATE)).toEqual([ ENTRY, HOP, "src/shared/log.js", STATE, ]); }); test("terminates and returns null when a cycle cannot reach the module", () => { const mf = metafile({ imports: { [ENTRY]: [HOP], [HOP]: ["src/shared/log.js"], "src/shared/log.js": [HOP, ENTRY], }, }); expect(importChain(mf, abs(ENTRY), STATE)).toBeNull(); }); }); describe("assertForbiddenTableCovered()", () => { test("the shipped table is satisfied by a build that checked it", () => { const record = newForbiddenRecord(); record.entriesChecked.add(ENTRY); // The popup bundle is what legitimately contains the singleton. record.bundledInputs.add(STATE); expect(() => assertForbiddenTableCovered(record, TABLE)).not.toThrow(); }); // The build this drives bundles the popup and no background entry point, // because a stale key AND a bundled background entry point is now the // stronger failure above — the entry point that is there but unlisted is // reported by name, before the end of the build. This case is the rot that // is left after that one: a key naming something this build never bundled. test("a key no bundled entry point matched fails", () => { const mf = metafile({ outputs: { [POPUP_OUT]: [POPUP] }, imports: { [POPUP]: [] }, }); const stale = { "src/background/renamed.js": [STATE] }; const record = newForbiddenRecord(); recordBundledInputs(mf, record); assertNoForbiddenInputs(abs(POPUP), abs(POPUP_OUT), mf, record, stale); record.bundledInputs.add(STATE); expect(() => assertForbiddenTableCovered(record, stale)).toThrow( "src/background/renamed.js is listed in FORBIDDEN_INPUTS but was" + " not bundled, so nothing checked it", ); }); test("a forbidden module this build bundled nowhere fails", () => { // The other half of the same rot: renaming or moving the singleton // leaves a table that names a path nothing resolves to any more, and // every bundle then passes it vacuously. const mf = metafile({ outputs: { [OUT]: [ENTRY] }, imports: { [ENTRY]: [] }, }); const stale = { [ENTRY]: ["src/shared/stateRenamed.js"] }; const record = check(mf, stale); record.bundledInputs.add(STATE); expect(() => assertForbiddenTableCovered(record, stale)).toThrow( "src/shared/stateRenamed.js is listed in FORBIDDEN_INPUTS for" + ` ${ENTRY}, but this build bundled it nowhere`, ); }); // The third rot, and the worst of the three: an entry with an empty list // is checked, is bundled, names no module that could be missing, and // prohibits nothing — in BOTH layers at once, since the rule's forbidden // set is Object.values(table).flat(). test("an entry that lists no modules fails", () => { const mf = metafile({ outputs: { [OUT]: [ENTRY, STATE] }, imports: { [ENTRY]: [STATE] }, }); const empty = { [ENTRY]: [] }; const record = newForbiddenRecord(); recordBundledInputs(mf, record); assertNoForbiddenInputs(abs(ENTRY), abs(OUT), mf, record, empty); expect(() => assertForbiddenTableCovered(record, empty)).toThrow( `FORBIDDEN_INPUTS["${ENTRY}"] lists no modules`, ); }); }); describe("assertTableWellFormed()", () => { // Runs at require time on the shipped table, in script/lib/ // forbiddenBundleInputs.js, so an empty list fails the lint run as well as // the build — the build's own re-check cannot help a layer that never // reaches the build. test("the shipped table is well formed", () => { expect(() => assertTableWellFormed(FORBIDDEN_INPUTS)).not.toThrow(); expect(Object.entries(FORBIDDEN_INPUTS).length).toBeGreaterThan(0); }); test("an entry that lists no modules fails, naming the entry", () => { expect(() => assertTableWellFormed({ [ENTRY]: [] })).toThrow( `FORBIDDEN_INPUTS["${ENTRY}"] lists no modules`, ); }); test("a table with no entries at all fails", () => { expect(() => assertTableWellFormed({})).toThrow( "FORBIDDEN_INPUTS is empty", ); }); });