Root-cause change, not a sixth point fix. The background no longer holds an
in-memory copy of the profile at all, src/shared/state.js is unreachable from
its bundle, and the build fails if it ever becomes reachable again.
Evidence attribution
Every measurement below is marked [this head] (c8d758f) or [carried forward] with the commit it was taken at. The carried-forward rows
are the eight specifier shapes from round 2, measured at cf8cb24, which is no
longer fetchable; the matching logic they exercise is unchanged since, and
shape 1 was re-measured on this head. Everything else in this body was measured
on c8d758f.
What changed
src/background/state.js (new) — the background's whole access to the
profile. getState() is a detached, normalized per-call read; updateState(fn)
is a queued read-modify-write whose read is one storage round trip ahead of its
write. No module-level copy, because the MV3 worker has no lifetime to hold one
over. The write is the WHOLE record, and the header now names what that costs:
a popup write landing inside that one-round-trip window is reverted.
Every handler answers from ONE snapshot, including which address it names: activeAddressOf(s) replaced a second, later getState() that could disagree
with the first.
wallet_switchEthereumChain applies applyChainSwitchFields() (split out of chainSwitch.js, which keeps the singleton path for the popup) inside updateState(), instead of onChainSwitch() on the singleton.
A remembered site decision is a read-modify-write, not a load-mutate-save
wrapped around a prompt the user takes seconds to answer.
Item 5:backgroundRefresh() refreshes a private copy of the wallets and
applies the balances that came back BY ADDRESS. It never publishes an object
other in-flight work holds, and a wallet added or deleted during the round
trip survives its write.
Item 4: the transaction attempt takes its chain id and its endpoint from
the same snapshot.
getProvider(rpcUrl, networkId) now REQUIRES the network id, validated
against networks.js. refreshBalances(), lookupTokenInfo(), scanForAddresses() and resolveEnsName() carry it through; balances.js no
longer requires state.js at all.
Loud failure: reading a persisted field of the singleton before any load
throws StateNotLoadedError instead of serving DEFAULT_STATE.
Mechanical enforcement: the guarantee is the bundler's, and it is tested
Two review rounds found four evasions of a hand-rolled ESLint matcher. Each was
the same mistake — the rule reimplemented a JS parser and a module resolver, and
it will keep diverging from esbuild. So the guarantee moved to the build.
The table lives in script/lib/forbiddenBundleInputs.js: one map from a
repo-relative entry point to the modules its bundle may not contain, read by
BOTH layers. It was two literal copies of the same path (build.js and the
rule), which is precisely how a rename disarms one layer while the other still
looks enforced — that is now one copy.
build.js's assertNoForbiddenInputs() is the guarantee. It fails the
build when esbuild's own metafile reports a forbidden module as an input of that
entry point's output, and names the import chain by walking the metafile's own
input graph. It consults the resolution esbuild actually performed, so specifier
syntax and resolution rules are not modelled at all. Dockerfile:42 runs make build, so it is enforced in CI.
Background entry points are protected by default
A bundled entry point under src/background/ with no line in the table now
fails the build. Adding a second worker entry point is exactly the kind of
accident this exists for, and the person adding one has no reason to know a
table elsewhere needs a line. src/background/ is the build's only notion of
"the background" — entry points are the paths handed to bundle() — and eslint.config.js scopes the lint rule from the same exported constant, so the
two layers cannot disagree about which files that is.
second entry point src/background/worker2.js
make lint
make build
requires the singleton, NOT in the table — before this round
exit 2
exit 0, grep -c StateNotLoadedError = 1 in the new bundle in both browsers [this head, on 18ad93b]
requires the singleton, NOT in the table — now
not run
exit 2, src/background/worker2.js is a background entry point with no line in FORBIDDEN_INPUTS [this head]
requires the singleton, listed in the table
not run
exit 2, dist/chrome/src/background/worker2.js bundles src/shared/state.js, which src/background/worker2.js must not reach: src/background/worker2.js -> src/shared/state.js [this head]
reaches the singleton by a computed specifier, listed in the table
exit 0
exit 2, same chain [this head]
requires only src/background/state.js, listed in the table
not run
exit 0 — the mechanism is a line in a table, not a ban on second workers [this head]
Anti-rot: every way the table can rot now fails
Each half of an entry rots independently, and each one turns the prohibition
into a pass that checks nothing.
rot
result
stale KEY — key alone changed to src/background/renamed.js
make buildexit 2, src/background/renamed.js is listed in FORBIDDEN_INPUTS but was not bundled, so nothing checked it [carried forward, cf8cb24; pinned by a unit case re-run on this head]
stale MODULE — value alone changed to src/shared/stateRenamed.js
make buildexit 2, ... but this build bundled it nowhere, so the prohibition names a module that is not in this tree at that path and nothing enforces it [carried forward, cf8cb24; pinned by a unit case re-run on this head]
EMPTY LIST — "src/background/index.js": [], plus a plain require("../shared/state") in the worker — before this round
make lint exit 0, make build exit 0, grep -c StateNotLoadedError = 1 in BOTH background bundles [this head, on 18ad93b]
EMPTY LIST — same edit, now
make lintexit 2, make buildexit 2, make testexit 2, all with FORBIDDEN_INPUTS["src/background/index.js"] lists no modules, so it prohibits nothing while still looking enforced [this head]
The empty list is refused at require time, where the table is defined, because
that one also empties the lint rule's forbidden set (Object.values(...).flat())
— the build's own check cannot help a layer that never reaches the build. assertForbiddenTableCovered() re-checks it so the build's half does not depend
on where the table was loaded from.
The check can no longer record itself as done before it runs
record.entriesChecked.add(entry) ran BEFORE the metafile output lookup that
produces the inputs. Any early return past that point left BOTH halves of the
guarantee satisfied by an entry point whose bundle was never examined. Measured
on 18ad93b: replacing the esbuild reported no metafile output throw with if (!entryOutput) return; was make test919/919, exit 0 [this head, on 18ad93b].
The add now happens after the inputs are in hand, so that same edit is caught
twice over: the entry is not recorded, and assertForbiddenTableCovered() fails
with is listed in FORBIDDEN_INPUTS but was not bundled. The throw itself is
pinned. With the disarming edit applied to the fixed tree, make test is 1 failed, 929 passed — an output esbuild did not report fails, and records nothing as checked [this head].
The lookup is genuinely the fragile step, which is why it is not left as the
only line of defence: repoRelative() resolves against process.cwd() while
esbuild's metafile output keys are cwd-relative.
The ESLint rule stays as fast local feedback, reading the same table, and is
described that way everywhere.
Per-shape evidence
The eight shapes below were each applied alone and reverted, with make lint in
the pinned container and a full make build. Shapes 2-8 are [carried
forward] from cf8cb24; assertNoForbiddenInputs()'s matching logic is
unchanged since. Shape 1 is [this head].
On the unmodified branch make build is exit 0, the receipt records 15 emitted
files with 4 containing constants.js, and grep -c StateNotLoadedError is 0 on both dist/chrome/src/background/index.js and dist/firefox/src/background/index.js, 1 on both popup bundles [this head].
dist/ was absent after each failure (make build wraps every step in script/discard-dist-on-failure), which is why there is no grep column: the
build never emitted a bundle to grep.
On shape 5: under this repo's commonjs languageOptions an ESM import is a
parse error, so what make lint reports there is the parser, not the rule. The
rule does cover the shape — tests/backgroundStateLintRule.test.js pins it with
a sourceType: "module" fixture — but the repository lint run cannot be cited
as evidence of it. It is the build that blocks it here.
Shapes the rule does not report, and the build does
All re-measured on this head, applied alone and reverted:
computed specifier, import("../shared/" + variable) in an async fn
not run
exit 2, no chain named (esbuild resolves it as a glob, so importChain() returns null and the message degrades rather than crashing) [this head]
symlink, src/shared/stateLink.js to state.js, required by the background
exit 0 [this head]
exit 2, src/background/index.js -> src/shared/state.js (esbuild reports the real path) [this head]
Both are now pinned in tests/backgroundStateLintRule.test.js as asserted
NON-reports, so the rule's stated bounds are measured rather than claimed. That
is what TODO.md says now; the previous round's TODO.md said they were
"pinned by" that file when they existed only in its header comment.
The guarantee's own coverage
make check does not run make build, so assertNoForbiddenInputs() had ZERO
coverage in it. build.js runs build() only under require.main === module
and exports the checks; tests/buildForbiddenInputs.test.js drives them
against SYNTHETIC metafiles in esbuild's shape — no dist/, no shelled-out
build. Seventeen cases; the nine added this round are marked NEW:
a forbidden module in the bundle throws, and the message names the chain
(index.js -> chainSwitchFields.js -> state.js);
a bundle without it does not throw, and is recorded as checked;
the failure still names the bundle when no chain can be shown (the glob case
above);
NEW: an output esbuild did not report fails, AND records nothing as checked —
the ordering fix, asserted through assertForbiddenTableCovered() as well as
through the throw;
importChain() terminates on a CYCLIC input graph and still finds the module;
importChain() terminates and returns null when a cycle cannot reach it;
NEW: a bundled background entry point with no line in the table fails;
NEW: an entry point outside src/background/ needs no line;
NEW: the prefix the build uses is the one the lint rule is scoped to;
NEW: recordBundledInputs() records every input of every output, and the
covered check then passes on what it collected with nothing hand-seeded;
the shipped table is satisfied by a build that checked it;
a key no bundled entry point matched fails (rewritten this round to drive a
popup-only build, because a stale key over a bundled background entry point is
now the stronger failure above and fires first);
a forbidden module this build bundled nowhere fails;
NEW: an entry that lists no modules fails the covered check;
NEW ×3: assertTableWellFormed() accepts the shipped table, rejects an entry
with no modules, and rejects a table with no entries.
Fail-first, this round [all this head]:
disarming edit
result
if (!entryOutput) return; in place of the throw
1 failed, 929 passed — an output esbuild did not report fails, and records nothing as checked (was 919/919 green on 18ad93b)
recordBundledInputs() body replaced with a no-op
1 failed, 929 passed — records every input of every output, satisfying the module half (was 919/919 green on 18ad93b)
"src/background/index.js": []
make test2 suites fail to load, make lint exit 2, make build exit 2
The rule's own coverage is pinned
tests/backgroundStateLintRule.test.js runs the rule through eslint's Linter
over real fixture trees in a temp dir. Thirteen cases: quoted, backtick, dynamic import(), static from, bare side-effect import, comment before the
specifier, comment after it, the two-hop re-export, the package.jsonmain
hop, a clean background that reaches only src/background/state.js, the
repository's own src/background/index.js (which must report nothing), and NEW
this round the two divergences as asserted non-reports.
Neither non-report case is vacuous [both this head]:
probe
result
resolveRelative() widened with fs.realpathSync() — a plausible one-line widening
1 failed, 929 passed — a symlink to the module is not reported
the computed-specifier fixture replaced with a plain quoted require
1 failed, 929 passed — a computed specifier is not reported
Source comments now credit the build
src/background/index.js:11, src/shared/state.js:12 and src/shared/state.js:60 named the ESLint rule as the enforcement. The third is
the written justification for the scoped loud-read guard, so it pointed at the
layer this PR demoted. All three now name build.js's metafile assertion; eslint.config.js's own comment says the rule is the early report, not the
guarantee.
Fail-first evidence (behaviour)
[carried forward, reproduced by three reviews] The three behavioural tests were
run against src/ reverted to head (git stash push -- src/, tests kept):
Item 4 — tests/backgroundStateIsolation.test.js "the artifact is
broadcast to the endpoint of the chain it was verified against": Expected: {"txHash": "0xfeed"} / Received: {"error": "The signed transaction is for a different network than the one that was approved.", "retryable": false, "stage": "verify"}.
Item 5 — same file, "a chain read arriving mid-refresh does not discard
the refresh": Expected: "1.5" / Received: "0.0".
#320 — tests/coldWorkerSendTransaction.test.js "a cold send on Sepolia
reaches the approval screen and goes out": Expected: 11155111n / Received: 1n.
Two of the five new cases pass on head, correctly and by design: "a cold send on
mainnet is prepared for mainnet" is the swap guard, and "a wallet added
mid-refresh survives the refresh's write" is already covered by the per-field
merge from #304.
Background-reachable, all three previously built with the mainnet fallback:
src/background/index.jshandleSendTransaction() — the #320 defect; now getProvider(s.rpcUrl, s.networkId) from the handler's snapshot.
src/background/index.jsAUTISTMASK_TX_RESPONSE broadcast — was getProvider(state.rpcUrl): missing hint AND an endpoint read later than the
chain id. Now both from one snapshot.
src/shared/balances.jsrefreshBalances() — reached from backgroundRefresh(). Same missing hint; now takes networkId.
Popup-only, no background path, all now pass state.networkId explicitly: balances.jslookupTokenInfo() (addToken.js, settingsAddToken.js), balances.jsscanForAddresses() (addWallet.js, two sites), ens.js resolveEnsName() (addressDetail.js, addressToken.js), send.js, confirmTx.js (three sites), txStatus.js.
Test stub audit
chrome.storage.local is a serialization boundary. Eight files stubbed it with
an aliasing get, so the object a module held and the object "storage" held
were one object. All eight now use tests/support/storageStub.js, which
structured-clones in both directions.
Assertions whose meaning changed:
tests/chainSwitchGate.test.js — mocked src/shared/state wholesale and had
a no-op set, so "the switch happened" was read off the mock's own
in-memory object and no persistence was exercised at all. Rewritten against
real storage: bg.walletState() now reads the written record.
tests/backgroundApproval.test.js — same shape, plus a mocked state module
that supplied the chain. setNetwork() now moves the stored networkId and
endpoint together; setActiveAddress() writes to storage. The two tests that
injected a failing loadState now install a hook on the state read, armed
AFTER the approval is raised so it is the attempt's read that fails.
tests/settingsUtcTimestamps.test.js — expect(second.state.utcTimestamps) .toBe(false) before loadState() asserted the default of an unloaded module.
That is now an error by design, so it asserts the throw instead.
tests/networkEndpoints.test.js — written() fed the next module load the
previous one's live object as its "persisted bytes"; the restart it simulates
now crosses a real serialization boundary.
tests/alarms.test.js — the "open popup just refreshed" case poked store.autistmask.lastBalanceRefresh on an object the worker shared; it now
writes to storage. The latency simulation is preserved through onOp.
tests/txStatus.test.js, tests/state.test.js, tests/coldWorkerChainSwitch.test.js — stub replaced; no assertion changed
meaning.
tests/deleteWalletLostPassword.test.js and tests/stateMerge.test.js kept
private makeStorage() helpers. Both cloned correctly, so nothing was wrong —
but tests/support/storageStub.js's header says "nothing rebuilds a storage
stub by hand". Both now call makeStorageStub(); _raw() became read().
tests/coldWorkerChainId.test.js also already cloned correctly and is left
alone. The remaining stubs are stateless — get returns a fresh {} and set
discards.
Disclosures
Known bound: a COPIED module, and it is wider than "it fails loudly". The
assertion is keyed by path, so a duplicate of the singleton's code at another
path is outside it. cp src/shared/state.js src/shared/stateCopy.js plus a
background require of the copy is make buildexit 0, make lint exit 0, and grep -c StateNotLoadedError = 1 in BOTH background
bundles [this head]. Deliberately not fixed. A copy carries the singleton's
own guard, so defects 1-3 of #324 — a read of a field nothing
loaded — become a loud StateNotLoadedError rather than 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. A newly WRITTEN singleton has no backstop at
all. All of that is recorded in script/lib/forbiddenBundleInputs.js.
A symlink to the singleton and a computed specifier are caught by the
build and missed by the rule [this head]. Fine under the two-layer framing,
and now pinned as asserted non-reports rather than only described.
A background-behaving entry point outside src/background/ is covered by
neither layer's default and needs its own line in the table. That is the bound
of the prefix rule, and it is the narrowest defensible definition: the build
has no other notion of a background entry point, and the lint rule's glob is
scoped from the same constant. Recorded where the table lives.
updateState() is not reentrant, and says so. A mutate that calls updateState() itself deadlocks the background: the queue is strictly serial.
No call site does this today; the queue is deliberately NOT redesigned here
and the trap is named in the header comment above updateState().
updateState() writes the whole record. A popup write that lands inside
the one-round-trip window between its read and its write is reverted, in every
field. Accepted — the window is one storage round trip and the popup is not
writing while the worker is — and now stated in the header rather than left
for the next reader to derive.
The loud-read guard is scoped, deliberately. The singleton throws on a
read of a persisted field before loadState() — unless this context has
ALREADY ASSIGNED into it. The cost: a context that writes one field and then
reads a different, untouched one is still served that field's default.
Nothing in state.js closes that; what closes it for the background is that
the background cannot reach the module at all, which is the build's assertion.
The alternative (per-field tracking) was implemented and measured first — it
failed ~94-103 tests across popup suites unrelated to this issue, which two
reviews independently reproduced and adjudicated as fixture gaps.
applyChainSwitchFields() does not call clearPrices(); onChainSwitch()
(popup) still does. The price cache is in-memory and per bundle, and the
background never fills it, so the background's old call cleared nothing.
tests/deleteAddress.test.js and tests/transactions.test.js gained a
network id / a loadState() in setup: both read state that nothing had
loaded.
normalizePersisted() now structured-clones the nested collections it
returns, so a caller may mutate the result freely.
The item-5 test uses wallet_switchEthereumChain as its trigger rather than
the eth_chainId named in the issue: that path was already moved off loadState() by #317.
Verification
[all this head] make check green on c8d758f, which is one commit on top of next at 36bc6be: exit 0, 51 suites, 930 tests, test-verify-build 46 cases, check-censored175 tracked files, lint in the pinned
container ([lint 1/1] RUN make lint executed uncached, DONE 5.1s, with its
output), prettier clean. (Earlier revisions of this body quoted 45/861/162,
49/906/172, 50/911/173 and 51/919/175 — the figures before each rebase onto a
moving next and before each round's new tests.)
make build exit 0 on the unmodified branch, exit 2 for every mutation above. dist/ removed afterwards; the working tree and the pushed branch are identical
and carry no probe fixture, symlink or scratch file; docker ps -a lists
nothing, and no image tag was removed.
closes #324
closes #320
Root-cause change, not a sixth point fix. The background no longer holds an
in-memory copy of the profile at all, `src/shared/state.js` is unreachable from
its bundle, and the **build** fails if it ever becomes reachable again.
## Evidence attribution
Every measurement below is marked **[this head]** (`c8d758f`) or
**[carried forward]** with the commit it was taken at. The carried-forward rows
are the eight specifier shapes from round 2, measured at `cf8cb24`, which is no
longer fetchable; the matching logic they exercise is unchanged since, and
shape 1 was re-measured on this head. Everything else in this body was measured
on `c8d758f`.
## What changed
**`src/background/state.js` (new)** — the background's whole access to the
profile. `getState()` is a detached, normalized per-call read; `updateState(fn)`
is a queued read-modify-write whose read is one storage round trip ahead of its
write. No module-level copy, because the MV3 worker has no lifetime to hold one
over. The write is the WHOLE record, and the header now names what that costs:
a popup write landing inside that one-round-trip window is reverted.
- Every handler answers from ONE snapshot, including which address it names:
`activeAddressOf(s)` replaced a second, later `getState()` that could disagree
with the first.
- `wallet_switchEthereumChain` applies `applyChainSwitchFields()` (split out of
`chainSwitch.js`, which keeps the singleton path for the popup) inside
`updateState()`, instead of `onChainSwitch()` on the singleton.
- A remembered site decision is a read-modify-write, not a load-mutate-save
wrapped around a prompt the user takes seconds to answer.
- **Item 5:** `backgroundRefresh()` refreshes a private copy of the wallets and
applies the balances that came back BY ADDRESS. It never publishes an object
other in-flight work holds, and a wallet added or deleted during the round
trip survives its write.
- **Item 4:** the transaction attempt takes its chain id and its endpoint from
the same snapshot.
**`getProvider(rpcUrl, networkId)` now REQUIRES the network id**, validated
against `networks.js`. `refreshBalances()`, `lookupTokenInfo()`,
`scanForAddresses()` and `resolveEnsName()` carry it through; `balances.js` no
longer requires `state.js` at all.
**Loud failure:** reading a persisted field of the singleton before any load
throws `StateNotLoadedError` instead of serving `DEFAULT_STATE`.
## Mechanical enforcement: the guarantee is the bundler's, and it is tested
Two review rounds found four evasions of a hand-rolled ESLint matcher. Each was
the same mistake — the rule reimplemented a JS parser and a module resolver, and
it will keep diverging from esbuild. So the guarantee moved to the build.
**The table lives in `script/lib/forbiddenBundleInputs.js`**: one map from a
repo-relative entry point to the modules its bundle may not contain, read by
BOTH layers. It was two literal copies of the same path (`build.js` and the
rule), which is precisely how a rename disarms one layer while the other still
looks enforced — that is now one copy.
**`build.js`'s `assertNoForbiddenInputs()` is the guarantee.** It fails the
build when esbuild's own metafile reports a forbidden module as an input of that
entry point's output, and names the import chain by walking the metafile's own
input graph. It consults the resolution esbuild actually performed, so specifier
syntax and resolution rules are not modelled at all. `Dockerfile:42` runs
`make build`, so it is enforced in CI.
### Background entry points are protected by default
A bundled entry point under `src/background/` with no line in the table now
fails the build. Adding a second worker entry point is exactly the kind of
accident this exists for, and the person adding one has no reason to know a
table elsewhere needs a line. `src/background/` is the build's only notion of
"the background" — entry points are the paths handed to `bundle()` — and
`eslint.config.js` scopes the lint rule from the same exported constant, so the
two layers cannot disagree about which files that is.
| second entry point `src/background/worker2.js` | `make lint` | `make build` |
|---|---|---|
| requires the singleton, NOT in the table — before this round | exit 2 | **exit 0**, `grep -c StateNotLoadedError` = **1** in the new bundle in both browsers [this head, on `18ad93b`] |
| requires the singleton, NOT in the table — now | not run | **exit 2**, `src/background/worker2.js is a background entry point with no line in FORBIDDEN_INPUTS` [this head] |
| requires the singleton, listed in the table | not run | **exit 2**, `dist/chrome/src/background/worker2.js bundles src/shared/state.js, which src/background/worker2.js must not reach: src/background/worker2.js -> src/shared/state.js` [this head] |
| reaches the singleton by a computed specifier, listed in the table | **exit 0** | **exit 2**, same chain [this head] |
| requires only `src/background/state.js`, listed in the table | not run | **exit 0** — the mechanism is a line in a table, not a ban on second workers [this head] |
### Anti-rot: every way the table can rot now fails
Each half of an entry rots independently, and each one turns the prohibition
into a pass that checks nothing.
| rot | result |
|---|---|
| stale KEY — key alone changed to `src/background/renamed.js` | `make build` **exit 2**, `src/background/renamed.js is listed in FORBIDDEN_INPUTS but was not bundled, so nothing checked it` [carried forward, `cf8cb24`; pinned by a unit case re-run on this head] |
| stale MODULE — value alone changed to `src/shared/stateRenamed.js` | `make build` **exit 2**, `... but this build bundled it nowhere, so the prohibition names a module that is not in this tree at that path and nothing enforces it` [carried forward, `cf8cb24`; pinned by a unit case re-run on this head] |
| EMPTY LIST — `"src/background/index.js": []`, plus a plain `require("../shared/state")` in the worker — before this round | `make lint` exit 0, `make build` exit 0, `grep -c StateNotLoadedError` = **1** in BOTH background bundles [this head, on `18ad93b`] |
| EMPTY LIST — same edit, now | `make lint` **exit 2**, `make build` **exit 2**, `make test` **exit 2**, all with `FORBIDDEN_INPUTS["src/background/index.js"] lists no modules, so it prohibits nothing while still looking enforced` [this head] |
The empty list is refused at require time, where the table is defined, because
that one also empties the lint rule's forbidden set (`Object.values(...).flat()`)
— the build's own check cannot help a layer that never reaches the build.
`assertForbiddenTableCovered()` re-checks it so the build's half does not depend
on where the table was loaded from.
### The check can no longer record itself as done before it runs
`record.entriesChecked.add(entry)` ran BEFORE the metafile output lookup that
produces the inputs. Any early return past that point left BOTH halves of the
guarantee satisfied by an entry point whose bundle was never examined. Measured
on `18ad93b`: replacing the `esbuild reported no metafile output` throw with
`if (!entryOutput) return;` was `make test` **919/919, exit 0** [this head, on
`18ad93b`].
The `add` now happens after the inputs are in hand, so that same edit is caught
twice over: the entry is not recorded, and `assertForbiddenTableCovered()` fails
with `is listed in FORBIDDEN_INPUTS but was not bundled`. The throw itself is
pinned. With the disarming edit applied to the fixed tree, `make test` is
**1 failed, 929 passed** — `an output esbuild did not report fails, and records
nothing as checked` [this head].
The lookup is genuinely the fragile step, which is why it is not left as the
only line of defence: `repoRelative()` resolves against `process.cwd()` while
esbuild's metafile output keys are cwd-relative.
**The ESLint rule stays as fast local feedback**, reading the same table, and is
described that way everywhere.
### Per-shape evidence
The eight shapes below were each applied alone and reverted, with `make lint` in
the pinned container and a full `make build`. Shapes 2-8 are **[carried
forward]** from `cf8cb24`; `assertNoForbiddenInputs()`'s matching logic is
unchanged since. Shape 1 is **[this head]**.
On the unmodified branch `make build` is exit 0, the receipt records 15 emitted
files with 4 containing `constants.js`, and `grep -c StateNotLoadedError` is
**0** on both `dist/chrome/src/background/index.js` and
`dist/firefox/src/background/index.js`, **1** on both popup bundles [this head].
| # | shape | mutation | `make lint` | `make build` |
|---|---|---|---|---|
| 1 | quoted `require` | `globalThis.__probe1 = require("../shared/state").state;` | exit 2, rule error naming `src/background/index.js -> src/shared/state.js` [this head] | **exit 2**, same chain [this head] |
| 2 | backtick `require` | same with `` require(`../shared/state`) `` | exit 2, rule error | **exit 2**, same chain |
| 3 | dynamic `import()` | `const m = await import("../shared/state");` in an async fn | exit 2, rule error | **exit 2**, same chain |
| 4 | two-hop re-export | `` module.exports.state = require(`./state`).state; `` appended to `src/shared/chainSwitchFields.js` | exit 2, rule error | **exit 2**, `index.js -> chainSwitchFields.js -> state.js` |
| 5 | static `from` re-export | `import { state as __probe5 } from "../shared/state";` | exit 2, **parse error**, not the rule (see below) | **exit 2**, `index.js -> state.js` |
| 6 | comment before the specifier | `require(/* probe */ "../shared/state").state` | exit 2, rule error | **exit 2**, same chain |
| 7 | comment after the specifier | `require("../shared/state" /* probe */).state` | exit 2, rule error | **exit 2**, same chain |
| 8 | `package.json` `main` hop | `src/shared/probepkg/package.json` = `{"main": "./bridge.js"}`, `bridge.js` requires `../state`, background requires `../shared/probepkg` | exit 2, rule error | **exit 2**, `index.js -> probepkg/bridge.js -> state.js` |
`dist/` was absent after each failure (`make build` wraps every step in
`script/discard-dist-on-failure`), which is why there is no grep column: the
build never emitted a bundle to grep.
On shape 5: under this repo's commonjs `languageOptions` an ESM `import` is a
parse error, so what `make lint` reports there is the parser, not the rule. The
rule does cover the shape — `tests/backgroundStateLintRule.test.js` pins it with
a `sourceType: "module"` fixture — but the repository lint run cannot be cited
as evidence of it. It is the build that blocks it here.
### Shapes the rule does not report, and the build does
All re-measured on this head, applied alone and reverted:
| shape | `make lint` | `make build` |
|---|---|---|
| computed specifier, `require("../shared/" + "state")` | **exit 0** [this head] | **exit 2**, `src/background/index.js -> src/shared/state.js` [this head] |
| computed specifier, `import("../shared/" + variable)` in an async fn | not run | **exit 2**, no chain named (esbuild resolves it as a glob, so `importChain()` returns null and the message degrades rather than crashing) [this head] |
| symlink, `src/shared/stateLink.js` to `state.js`, required by the background | **exit 0** [this head] | **exit 2**, `src/background/index.js -> src/shared/state.js` (esbuild reports the real path) [this head] |
Both are now pinned in `tests/backgroundStateLintRule.test.js` as asserted
NON-reports, so the rule's stated bounds are measured rather than claimed. That
is what `TODO.md` says now; the previous round's `TODO.md` said they were
"pinned by" that file when they existed only in its header comment.
### The guarantee's own coverage
`make check` does not run `make build`, so `assertNoForbiddenInputs()` had ZERO
coverage in it. `build.js` runs `build()` only under `require.main === module`
and exports the checks; **`tests/buildForbiddenInputs.test.js`** drives them
against SYNTHETIC metafiles in esbuild's shape — no `dist/`, no shelled-out
build. Seventeen cases; the nine added this round are marked NEW:
- a forbidden module in the bundle throws, and the message names the chain
(`index.js -> chainSwitchFields.js -> state.js`);
- a bundle without it does not throw, and is recorded as checked;
- the failure still names the bundle when no chain can be shown (the glob case
above);
- NEW: an output esbuild did not report fails, AND records nothing as checked —
the ordering fix, asserted through `assertForbiddenTableCovered()` as well as
through the throw;
- `importChain()` terminates on a CYCLIC input graph and still finds the module;
- `importChain()` terminates and returns null when a cycle cannot reach it;
- NEW: a bundled background entry point with no line in the table fails;
- NEW: an entry point outside `src/background/` needs no line;
- NEW: the prefix the build uses is the one the lint rule is scoped to;
- NEW: `recordBundledInputs()` records every input of every output, and the
covered check then passes on what it collected with nothing hand-seeded;
- the shipped table is satisfied by a build that checked it;
- a key no bundled entry point matched fails (rewritten this round to drive a
popup-only build, because a stale key over a bundled background entry point is
now the stronger failure above and fires first);
- a forbidden module this build bundled nowhere fails;
- NEW: an entry that lists no modules fails the covered check;
- NEW ×3: `assertTableWellFormed()` accepts the shipped table, rejects an entry
with no modules, and rejects a table with no entries.
**Fail-first, this round** [all this head]:
| disarming edit | result |
|---|---|
| `if (!entryOutput) return;` in place of the throw | **1 failed, 929 passed** — `an output esbuild did not report fails, and records nothing as checked` (was 919/919 green on `18ad93b`) |
| `recordBundledInputs()` body replaced with a no-op | **1 failed, 929 passed** — `records every input of every output, satisfying the module half` (was 919/919 green on `18ad93b`) |
| `"src/background/index.js": []` | `make test` **2 suites fail to load**, `make lint` exit 2, `make build` exit 2 |
### The rule's own coverage is pinned
`tests/backgroundStateLintRule.test.js` runs the rule through eslint's `Linter`
over real fixture trees in a temp dir. Thirteen cases: quoted, backtick, dynamic
`import()`, static `from`, bare side-effect `import`, comment before the
specifier, comment after it, the two-hop re-export, the `package.json` `main`
hop, a clean background that reaches only `src/background/state.js`, the
repository's own `src/background/index.js` (which must report nothing), and NEW
this round the two divergences as asserted non-reports.
Neither non-report case is vacuous [both this head]:
| probe | result |
|---|---|
| `resolveRelative()` widened with `fs.realpathSync()` — a plausible one-line widening | **1 failed, 929 passed** — `a symlink to the module is not reported` |
| the computed-specifier fixture replaced with a plain quoted `require` | **1 failed, 929 passed** — `a computed specifier is not reported` |
### Source comments now credit the build
`src/background/index.js:11`, `src/shared/state.js:12` and
`src/shared/state.js:60` named the ESLint rule as the enforcement. The third is
the written justification for the scoped loud-read guard, so it pointed at the
layer this PR demoted. All three now name `build.js`'s metafile assertion;
`eslint.config.js`'s own comment says the rule is the early report, not the
guarantee.
## Fail-first evidence (behaviour)
[carried forward, reproduced by three reviews] The three behavioural tests were
run against `src/` reverted to head (`git stash push -- src/`, tests kept):
- **Item 4** — `tests/backgroundStateIsolation.test.js` "the artifact is
broadcast to the endpoint of the chain it was verified against":
`Expected: {"txHash": "0xfeed"}` / `Received: {"error": "The signed transaction
is for a different network than the one that was approved.", "retryable":
false, "stage": "verify"}`.
- **Item 5** — same file, "a chain read arriving mid-refresh does not discard
the refresh": `Expected: "1.5"` / `Received: "0.0"`.
- **https://git.eeqj.de/sneak/AutistMask/issues/320** —
`tests/coldWorkerSendTransaction.test.js` "a cold send on Sepolia
reaches the approval screen and goes out": `Expected: 11155111n` /
`Received: 1n`.
Two of the five new cases pass on head, correctly and by design: "a cold send on
mainnet is prepared for mainnet" is the swap guard, and "a wallet added
mid-refresh survives the refresh's write" is already covered by the per-field
merge from https://git.eeqj.de/sneak/AutistMask/issues/304.
## `getProvider()` call-site audit (every site, background-reachable marked)
Background-reachable, all three previously built with the mainnet fallback:
- `src/background/index.js` `handleSendTransaction()` — the
https://git.eeqj.de/sneak/AutistMask/issues/320 defect; now
`getProvider(s.rpcUrl, s.networkId)` from the handler's snapshot.
- `src/background/index.js` `AUTISTMASK_TX_RESPONSE` broadcast — was
`getProvider(state.rpcUrl)`: missing hint AND an endpoint read later than the
chain id. Now both from one snapshot.
- `src/shared/balances.js` `refreshBalances()` — reached from
`backgroundRefresh()`. Same missing hint; now takes `networkId`.
Popup-only, no background path, all now pass `state.networkId` explicitly:
`balances.js` `lookupTokenInfo()` (`addToken.js`, `settingsAddToken.js`),
`balances.js` `scanForAddresses()` (`addWallet.js`, two sites), `ens.js`
`resolveEnsName()` (`addressDetail.js`, `addressToken.js`), `send.js`,
`confirmTx.js` (three sites), `txStatus.js`.
## Test stub audit
`chrome.storage.local` is a serialization boundary. Eight files stubbed it with
an aliasing `get`, so the object a module held and the object "storage" held
were one object. All eight now use `tests/support/storageStub.js`, which
structured-clones in both directions.
Assertions whose meaning changed:
- `tests/chainSwitchGate.test.js` — mocked `src/shared/state` wholesale and had
a **no-op `set`**, so "the switch happened" was read off the mock's own
in-memory object and no persistence was exercised at all. Rewritten against
real storage: `bg.walletState()` now reads the written record.
- `tests/backgroundApproval.test.js` — same shape, plus a mocked state module
that supplied the chain. `setNetwork()` now moves the stored `networkId` and
endpoint together; `setActiveAddress()` writes to storage. The two tests that
injected a failing `loadState` now install a hook on the state read, armed
AFTER the approval is raised so it is the attempt's read that fails.
- `tests/settingsUtcTimestamps.test.js` — `expect(second.state.utcTimestamps)
.toBe(false)` before `loadState()` asserted the default of an unloaded module.
That is now an error by design, so it asserts the throw instead.
- `tests/networkEndpoints.test.js` — `written()` fed the next module load the
previous one's live object as its "persisted bytes"; the restart it simulates
now crosses a real serialization boundary.
- `tests/alarms.test.js` — the "open popup just refreshed" case poked
`store.autistmask.lastBalanceRefresh` on an object the worker shared; it now
writes to storage. The latency simulation is preserved through `onOp`.
- `tests/txStatus.test.js`, `tests/state.test.js`,
`tests/coldWorkerChainSwitch.test.js` — stub replaced; no assertion changed
meaning.
`tests/deleteWalletLostPassword.test.js` and `tests/stateMerge.test.js` kept
private `makeStorage()` helpers. Both cloned correctly, so nothing was wrong —
but `tests/support/storageStub.js`'s header says "nothing rebuilds a storage
stub by hand". Both now call `makeStorageStub()`; `_raw()` became `read()`.
`tests/coldWorkerChainId.test.js` also already cloned correctly and is left
alone. The remaining stubs are stateless — `get` returns a fresh `{}` and `set`
discards.
## Disclosures
- **Known bound: a COPIED module, and it is wider than "it fails loudly".** The
assertion is keyed by path, so a duplicate of the singleton's code at another
path is outside it. `cp src/shared/state.js src/shared/stateCopy.js` plus a
background `require` of the copy is `make build` **exit 0**, `make lint`
**exit 0**, and `grep -c StateNotLoadedError` = **1** in BOTH background
bundles [this head]. Deliberately not fixed. A 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` rather than 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. A newly WRITTEN singleton has no backstop at
all. All of that is recorded in `script/lib/forbiddenBundleInputs.js`.
- **A symlink to the singleton and a computed specifier** are caught by the
build and missed by the rule [this head]. Fine under the two-layer framing,
and now pinned as asserted non-reports rather than only described.
- **A background-behaving entry point outside `src/background/`** is covered by
neither layer's default and needs its own line in the table. That is the bound
of the prefix rule, and it is the narrowest defensible definition: the build
has no other notion of a background entry point, and the lint rule's glob is
scoped from the same constant. Recorded where the table lives.
- **`updateState()` is not reentrant, and says so.** A `mutate` that calls
`updateState()` itself deadlocks the background: the queue is strictly serial.
No call site does this today; the queue is deliberately NOT redesigned here
and the trap is named in the header comment above `updateState()`.
- **`updateState()` writes the whole record.** A popup write that lands inside
the one-round-trip window between its read and its write is reverted, in every
field. Accepted — the window is one storage round trip and the popup is not
writing while the worker is — and now stated in the header rather than left
for the next reader to derive.
- **The loud-read guard is scoped, deliberately.** The singleton throws on a
read of a persisted field before `loadState()` — unless this context has
ALREADY ASSIGNED into it. The cost: a context that writes one field and then
reads a different, untouched one is still served that field's default.
Nothing in `state.js` closes that; what closes it for the background is that
the background cannot reach the module at all, which is the build's assertion.
The alternative (per-field tracking) was implemented and measured first — it
failed ~94-103 tests across popup suites unrelated to this issue, which two
reviews independently reproduced and adjudicated as fixture gaps.
- `applyChainSwitchFields()` does not call `clearPrices()`; `onChainSwitch()`
(popup) still does. The price cache is in-memory and per bundle, and the
background never fills it, so the background's old call cleared nothing.
- `tests/deleteAddress.test.js` and `tests/transactions.test.js` gained a
network id / a `loadState()` in setup: both read state that nothing had
loaded.
- `normalizePersisted()` now structured-clones the nested collections it
returns, so a caller may mutate the result freely.
- The item-5 test uses `wallet_switchEthereumChain` as its trigger rather than
the `eth_chainId` named in the issue: that path was already moved off
`loadState()` by https://git.eeqj.de/sneak/AutistMask/issues/317.
## Verification
[all this head] `make check` green on `c8d758f`, which is one commit on top of
`next` at `36bc6be`: exit 0, **51 suites, 930 tests**, `test-verify-build`
**46 cases**, `check-censored` **175 tracked files**, lint in the pinned
container (`[lint 1/1] RUN make lint` executed uncached, `DONE 5.1s`, with its
output), prettier clean. (Earlier revisions of this body quoted 45/861/162,
49/906/172, 50/911/173 and 51/919/175 — the figures before each rebase onto a
moving `next` and before each round's new tests.)
`make build` exit 0 on the unmodified branch, exit 2 for every mutation above.
`dist/` removed afterwards; the working tree and the pushed branch are identical
and carry no probe fixture, symlink or scratch file; `docker ps -a` lists
nothing, and no image tag was removed.
Five defects traced to one fact: src/background/index.js read and wrote the
module-level `state` singleton in src/shared/state.js, which the MV3 service
worker never populates and which answered an unpopulated read out of
DEFAULT_STATE in silence. Every previous fix added a loadState() before the
access, and that is what produced the fifth: a load detaches the objects an
in-flight handler is holding.
So the reachability goes rather than a sixth call site.
The background now has its own storage layer, src/background/state.js:
getState() is a detached, normalized per-call read, and updateState() is a
queued read-modify-write whose read is one storage round trip ahead of its
write. Nothing in the background holds an in-memory copy of the profile.
- Every handler takes one snapshot and answers from it, including the address
it names: activeAddressOf(s) replaced a second, later storage read that
could disagree with the first.
- wallet_switchEthereumChain applies applyChainSwitchFields() (split out of
chainSwitch.js, which keeps the singleton path for the popup) inside
updateState() instead of calling onChainSwitch() on the singleton.
- The remembered site decision is a read-modify-write, not a load-mutate-save
around a prompt the user takes seconds to answer.
- backgroundRefresh() refreshes a private copy of the wallets and applies the
balances that came back by address, so it never publishes an object other
in-flight work holds, and a wallet added or deleted during the round trip
survives its write.
- The transaction attempt takes its chain id and its endpoint from the same
snapshot. They used to come from different moments, so a chain switch
committed in between moved the endpoint under an artifact already verified
against the old chain.
getProvider(rpcUrl, networkId) now REQUIRES the network id and validates it
against networks.js. That closes the cold-worker wrong-chain send at its shape
rather than at one call site: the hint used to default to currentNetwork() off
the unpopulated singleton, so the endpoint was the user's chain and ethers
fixed chainId at 0x1, and the wallet's own verifySignedTx then refused every
non-mainnet dApp send. refreshBalances(), lookupTokenInfo(), scanForAddresses()
and resolveEnsName() carry the id through; balances.js no longer requires
state.js at all.
The prohibition is enforced mechanically, not by review: a custom ESLint rule
walks the CommonJS require graph from every src/background/ file and fails the
lint when src/shared/state.js is reachable, naming the chain. A re-export from
any shared module cannot put the singleton back in the bundle unnoticed.
Reading a persisted field of the singleton before any load now throws
StateNotLoadedError instead of serving DEFAULT_STATE.
Test stubs: chrome.storage.local is a serialization boundary, and eight files
stubbed it with an aliasing get, so the object a module held and the object
"storage" held were one object — an assertion could pass on a build that never
wrote anything. They all go through tests/support/storageStub.js now, which
structured-clones in both directions.
closes#320
clawbot
self-assigned this 2026-08-23 15:46:15 +02:00
The reachability walk matches only require() with a double- or single-quoted specifier. Two specifier shapes are invisible to it, and esbuild statically resolves both, so src/shared/state.js lands in the emitted background bundle with no check failing.
Measured in a clean clone at 4343d8f, appending to src/background/index.js:
make check → exit 0. 853/853 tests pass, [lint 1/1] RUN make lint executes and reports no problems, prettier reports "All matched files use Prettier code style!".
make build → grep -c StateNotLoadedError dist/chrome/src/background/index.js = 1 (0 on unmodified head). Same for "was read before loadState" and "identity collision merging". The singleton is in the worker bundle.
Dynamic import expression — await import("../shared/state.js") inside an async function
make lint clean; grep -c StateNotLoadedError on the built background bundle = 1.
For contrast, both shapes the PR body demonstrates do fire, and I reproduced them: the direct quoted require, and the two-hop re-export appended to src/shared/chainSwitchFields.js (src/background/index.js -> src/shared/chainSwitchFields.js -> src/shared/state.js). The hole is the specifier syntax, not the graph walk — the same two shapes evade it at any hop, so a shared module the background already pulls in can re-export the singleton through a backtick require and stay green.
Why it matters here specifically: the DoD of #324 is "enforced mechanically, not by review", and the whole argument for the weakened StateNotLoadedError guard (Disclosure 1) is that its hole "is closed for the background by unreachability". An enforcement that a plain require with the wrong quote character walks through is not the unreachability that argument rests on.
Acceptable: widen the pattern to cover backticks and import(, e.g. /\b(?:require|import)\(\s*["'`]([^"'`]+)["'`]\s*\)/g, and add a rule test (or a lint-fixture check) that pins each of the four shapes — quoted require, backtick require, import(), and a two-hop re-export. Deriving the graph from esbuild's metafile instead of a regex would also close it and would match what build.js already does for AUDITED_MODULE.
Non-blocking
src/background/state.js:66 — updateState()deadlocks on reentrancy: a mutate that itself calls updateState() queues behind the turn that is awaiting it. Confirmed empirically (inner update never runs; the outer turn only completes because the probe raced it against a timeout). No current call site does this, and the header warns mutate "must not do anything slow" but says nothing about calling back in. Worth one line in that comment.
tests/deleteWalletLostPassword.test.js:102 and tests/stateMerge.test.js:18 each still carry a private hand-rolled cloning makeStorage(), while tests/support/storageStub.js states "nothing rebuilds a storage stub by hand". Both clone correctly, so nothing is hidden — it is a consistency point, not a defect.
The PR body's "rest of tests/ audited" enumeration omits tests/approvalDisplayFloor.test.js. It is stateless (get: async () => ({})), so the conclusion is unaffected; the list is just short by one.
Verified and holding
Sweep of all of tests/ found no storage stub the eight-file audit missed — the eight go through tests/support/storageStub.js (which structuredClones on get, set, read and write), coldWorkerChainId/stateMerge/deleteWalletLostPassword already cloned, and every remaining stub is stateless. All three fail-first claims reproduce exactly against src/ reverted to next (11155111n vs 1n; {"txHash":"0xfeed"} vs the three-key "different network" refusal; "1.5" vs "0.0"). Every getProvider() call site passes a loaded id; no default and no guess remains. updateState() serializes two concurrent updates, survives a throwing mutate without stalling the queue, and awaits an async mutate before writing. Disclosure 3 holds: prices is written only by refreshPrices(), called only from src/popup/index.js, and clearPrices no longer appears in the background bundle at all. Disclosure 4 is two genuine fixture gaps, not a masked regression. Disclosure 2 holds — eth_chainId is already off loadState() on next, and wallet_switchEthereumChain exercises the same detach. make check green in my own clone with [lint 1/1] RUN make lint executed rather than CACHED; CI green on 4343d8f; fast-forwardable onto next at 669c443; one commit, title ends (closes #324), body closes #320; base is next; no Claude/Anthropic references or attribution trailers anywhere.
On Disclosure 1: the stated hole is real, and the 103 failures are what the author says they are. I rebuilt the strict per-field guard and measured 94 failures across 8 suites on this tree — every one a popup unit test that hand-builds the singleton and then has a view read state.networkId the fixture never set (e.g. tests/txStatus.test.js via currentNetwork() → explorerUrl() → etherscanAddressUrl()). Fixture churn, not 94 production sites reading unloaded state; the popup calls loadState() at boot. The weakened form is acceptable on the merits — and note it is in fact stronger for the background than claimed, since nothing in the background ever assigns into the singleton, so adopted stays false and a stray read would throw. That remains a second line of defence rather than the first, which is why the lint hole above is the blocking item.
FAIL — `needs-rework`. One blocking finding; everything else verified and holds.
## BLOCKING: the singleton is still reachable from the background, and `make check` is green over it
`script/lib/eslint/noStateSingletonInBackground.js:31` —
`const REQUIRE_RE = /\brequire\(\s*["']([^"']+)["']\s*\)/g;`
The reachability walk matches only `require()` with a **double- or single-quoted** specifier. Two specifier shapes are invisible to it, and esbuild statically resolves both, so `src/shared/state.js` lands in the emitted background bundle with no check failing.
Measured in a clean clone at `4343d8f`, appending to `src/background/index.js`:
1. Template-literal specifier — ``const probeI = require(`../shared/state`);``
- `make check` → **exit 0**. 853/853 tests pass, `[lint 1/1] RUN make lint` executes and reports no problems, prettier reports "All matched files use Prettier code style!".
- `make build` → `grep -c StateNotLoadedError dist/chrome/src/background/index.js` = **1** (0 on unmodified head). Same for `"was read before loadState"` and `"identity collision merging"`. The singleton is in the worker bundle.
2. Dynamic import expression — `await import("../shared/state.js")` inside an `async function`
- `make lint` clean; `grep -c StateNotLoadedError` on the built background bundle = **1**.
For contrast, both shapes the PR body demonstrates do fire, and I reproduced them: the direct quoted require, and the two-hop re-export appended to `src/shared/chainSwitchFields.js` (`src/background/index.js -> src/shared/chainSwitchFields.js -> src/shared/state.js`). The hole is the specifier syntax, not the graph walk — the same two shapes evade it at any hop, so a shared module the background already pulls in can re-export the singleton through a backtick require and stay green.
Why it matters here specifically: the DoD of https://git.eeqj.de/sneak/AutistMask/issues/324 is "enforced mechanically, not by review", and the whole argument for the weakened `StateNotLoadedError` guard (Disclosure 1) is that its hole "is closed for the background by unreachability". An enforcement that a plain `require` with the wrong quote character walks through is not the unreachability that argument rests on.
Acceptable: widen the pattern to cover backticks and `import(`, e.g. ``/\b(?:require|import)\(\s*["'`]([^"'`]+)["'`]\s*\)/g``, and add a rule test (or a lint-fixture check) that pins each of the four shapes — quoted require, backtick require, `import()`, and a two-hop re-export. Deriving the graph from esbuild's metafile instead of a regex would also close it and would match what `build.js` already does for `AUDITED_MODULE`.
## Non-blocking
- `src/background/state.js:66` — `updateState()` **deadlocks on reentrancy**: a `mutate` that itself calls `updateState()` queues behind the turn that is awaiting it. Confirmed empirically (inner update never runs; the outer turn only completes because the probe raced it against a timeout). No current call site does this, and the header warns `mutate` "must not do anything slow" but says nothing about calling back in. Worth one line in that comment.
- `tests/deleteWalletLostPassword.test.js:102` and `tests/stateMerge.test.js:18` each still carry a private hand-rolled cloning `makeStorage()`, while `tests/support/storageStub.js` states "nothing rebuilds a storage stub by hand". Both clone correctly, so nothing is hidden — it is a consistency point, not a defect.
- The PR body's "rest of `tests/` audited" enumeration omits `tests/approvalDisplayFloor.test.js`. It is stateless (`get: async () => ({})`), so the conclusion is unaffected; the list is just short by one.
## Verified and holding
Sweep of all of `tests/` found **no storage stub the eight-file audit missed** — the eight go through `tests/support/storageStub.js` (which `structuredClone`s on `get`, `set`, `read` and `write`), `coldWorkerChainId`/`stateMerge`/`deleteWalletLostPassword` already cloned, and every remaining stub is stateless. All three fail-first claims reproduce exactly against `src/` reverted to `next` (`11155111n` vs `1n`; `{"txHash":"0xfeed"}` vs the three-key "different network" refusal; `"1.5"` vs `"0.0"`). Every `getProvider()` call site passes a loaded id; no default and no guess remains. `updateState()` serializes two concurrent updates, survives a throwing mutate without stalling the queue, and awaits an async mutate before writing. Disclosure 3 holds: `prices` is written only by `refreshPrices()`, called only from `src/popup/index.js`, and `clearPrices` no longer appears in the background bundle at all. Disclosure 4 is two genuine fixture gaps, not a masked regression. Disclosure 2 holds — `eth_chainId` is already off `loadState()` on `next`, and `wallet_switchEthereumChain` exercises the same detach. `make check` green in my own clone with `[lint 1/1] RUN make lint` executed rather than `CACHED`; CI green on `4343d8f`; fast-forwardable onto `next` at `669c443`; one commit, title ends ` (closes #324)`, body closes https://git.eeqj.de/sneak/AutistMask/issues/320; base is `next`; no Claude/Anthropic references or attribution trailers anywhere.
On Disclosure 1: the stated hole is real, and the 103 failures are what the author says they are. I rebuilt the strict per-field guard and measured 94 failures across 8 suites on this tree — every one a popup unit test that hand-builds the singleton and then has a view read `state.networkId` the fixture never set (e.g. `tests/txStatus.test.js` via `currentNetwork()` → `explorerUrl()` → `etherscanAddressUrl()`). Fixture churn, not 94 production sites reading unloaded state; the popup calls `loadState()` at boot. The weakened form is acceptable on the merits — and note it is in fact stronger for the background than claimed, since nothing in the background ever assigns into the singleton, so `adopted` stays false and a stray read would throw. That remains a second line of defence rather than the first, which is why the lint hole above is the blocking item.
FAIL — needs-rework. One blocking finding; everything else re-derived independently and holds.
BLOCKING: a sixth site can still be created — two shapes evade the rule and land in the shipped worker bundle
Both measured in a clean clone at 277ec8c: make lint in the pinned container, then make build and grep for StateNotLoadedError, was read before loadState and identity collision merging in dist/chrome/src/background/index.js and dist/firefox/src/background/index.js (all three are 0 in both bundles on unmodified head).
1. A comment inside the require() call.script/lib/eslint/noStateSingletonInBackground.js:47 — SPECIFIER_RE allows only \s* between ( and the opening quote (and between the closing quote and )), so any comment token breaks the match while esbuild resolves the call normally.
Appended to src/background/index.js: globalThis.__probe = require(/* probe */ "../shared/state").state;
make lint → exit 0. Full make check → exit 0 (45 suites, 861 tests, "All matched files use Prettier code style!"). The comment is prettier-stable — make fmt leaves it untouched — so nothing in the repo objects.
make build → all three markers = 1 in BOTH the Chrome and Firefox background bundles.
The trailing form require("../shared/state" /* p */) behaves identically: eslint reports nothing, prettier leaves it alone, singleton in the bundle.
require("../shared/state",) (trailing comma) and require ("../shared/state") (space before the paren) also produce no eslint error; those two are normalized away by prettier, so they fail make fmt-check rather than shipping — but they show the match is fragile in three independent places, not one.
Not exotic: import(/* webpackChunkName: "…" */ "…") is the standard bundler-annotation idiom, and any inline /* eslint-… */ inside the call has the same effect.
2. The file resolution disagrees with esbuild, independently of the matcher.resolveRelative() (lines 51-67) tries only base, base + ".js", base + ".json" and base/index.js. esbuild resolves a directory through its package.jsonmain, so the walk stops at a specifier it matched perfectly well.
Added src/shared/probepkg/package.json = {"main": "./bridge.js"} and src/shared/probepkg/bridge.js = const { state } = require("../state");, then in src/background/index.js: globalThis.__probe6 = require("../shared/probepkg").state;
make lint → exit 0, prettier clean. make build → StateNotLoadedError = 1 in both background bundles.
Why this blocks: #324's DoD is "enforced mechanically, not by review", and the scoped loud-read guard is justified in the PR body by the background being unable to reach the module at all. Neither holds while an ordinary, prettier-stable construct walks through. Widening the regex again does not close it — the two failures are in different halves of the rule (textual matcher vs hand-written resolver), and each rework so far has closed one shape and left the next.
Acceptable: make the check authoritative rather than approximate — assert from esbuild's metafile that src/shared/state.js is not an input of the background bundles. build.js already does exactly this for AUDITED_MODULE (outputsContainingAuditedModule()), and Dockerfile:42 runs make build in CI, so the assertion would be enforced on every push. Keeping the lint rule as fast feedback is fine; the guarantee should not rest on the regex. Whatever is chosen, add both shapes above to tests/backgroundStateLintRule.test.js, and correct the "covers every specifier syntax esbuild resolves statically" claim, which currently appears in the rule header, the test-file header, the commit message, the PR body and TODO.md and is false as written.
Re-derived, and holding
All four claimed shapes fire (exit 2, one error, chain named): quoted require, backtick require, dynamic import(), and the two-hop backtick re-export through src/shared/chainSwitchFields.js. With the old regex restored, exactly 5 of 8 rule tests fail — backtick, dynamic import, static from, bare import, two-hop — as claimed. The negative case is not vacuous: pointing FORBIDDEN at src/shared/networks.js makes "the repository's own background entrypoint" fail, and so does reverting src/ to next. All three fail-first claims reproduce exactly (11155111n vs 1n; the "different network" refusal; "1.5" vs "0.0"). make check green in my own clone with [lint 1/1] RUN make lint executed uncached (DONE 4.9s, with output); CI green on 277ec8c; fast-forwardable onto next at 669c443; one commit, title ends (closes #324), body closes #320; base is next; no AI-vendor references or attribution trailers anywhere. Working tree and pushed branch are identical and carry no probe mutation, stray fixture or scratch file. The tests/deleteWalletLostPassword.test.js / tests/stateMerge.test.js reroute is mechanical (_raw() → the stub's read(), both clone on the way out); no assertion changed meaning; tests/coldWorkerChainId.test.js clones both ways and does not drive state.js, so leaving it is justified. updateState()'s queue is unchanged and the reentrancy deadlock is named in its header. The scoped guard reasoning still holds: nothing in the background assigns into the singleton, so adopted stays false there.
FAIL — `needs-rework`. One blocking finding; everything else re-derived independently and holds.
## BLOCKING: a sixth site can still be created — two shapes evade the rule and land in the shipped worker bundle
Both measured in a clean clone at `277ec8c`: `make lint` in the pinned container, then `make build` and grep for `StateNotLoadedError`, `was read before loadState` and `identity collision merging` in `dist/chrome/src/background/index.js` and `dist/firefox/src/background/index.js` (all three are **0** in both bundles on unmodified head).
**1. A comment inside the `require()` call.** `script/lib/eslint/noStateSingletonInBackground.js:47` — `SPECIFIER_RE` allows only `\s*` between `(` and the opening quote (and between the closing quote and `)`), so any comment token breaks the match while esbuild resolves the call normally.
- Appended to `src/background/index.js`: `globalThis.__probe = require(/* probe */ "../shared/state").state;`
- `make lint` → exit 0. Full `make check` → **exit 0** (45 suites, 861 tests, "All matched files use Prettier code style!"). The comment is prettier-stable — `make fmt` leaves it untouched — so nothing in the repo objects.
- `make build` → all three markers = **1** in BOTH the Chrome and Firefox background bundles.
- The trailing form `require("../shared/state" /* p */)` behaves identically: eslint reports nothing, prettier leaves it alone, singleton in the bundle.
- `require("../shared/state",)` (trailing comma) and `require ("../shared/state")` (space before the paren) also produce **no eslint error**; those two are normalized away by prettier, so they fail `make fmt-check` rather than shipping — but they show the match is fragile in three independent places, not one.
- Not exotic: `import(/* webpackChunkName: "…" */ "…")` is the standard bundler-annotation idiom, and any inline `/* eslint-… */` inside the call has the same effect.
**2. The file resolution disagrees with esbuild, independently of the matcher.** `resolveRelative()` (lines 51-67) tries only `base`, `base + ".js"`, `base + ".json"` and `base/index.js`. esbuild resolves a directory through its `package.json` `main`, so the walk stops at a specifier it matched perfectly well.
- Added `src/shared/probepkg/package.json` = `{"main": "./bridge.js"}` and `src/shared/probepkg/bridge.js` = `const { state } = require("../state");`, then in `src/background/index.js`: `globalThis.__probe6 = require("../shared/probepkg").state;`
- `make lint` → exit 0, prettier clean. `make build` → `StateNotLoadedError` = **1** in both background bundles.
Why this blocks: https://git.eeqj.de/sneak/AutistMask/issues/324's DoD is "enforced mechanically, not by review", and the scoped loud-read guard is justified in the PR body by the background being unable to reach the module at all. Neither holds while an ordinary, prettier-stable construct walks through. Widening the regex again does not close it — the two failures are in different halves of the rule (textual matcher vs hand-written resolver), and each rework so far has closed one shape and left the next.
Acceptable: make the check authoritative rather than approximate — assert from esbuild's metafile that `src/shared/state.js` is not an input of the background bundles. `build.js` already does exactly this for `AUDITED_MODULE` (`outputsContainingAuditedModule()`), and `Dockerfile:42` runs `make build` in CI, so the assertion would be enforced on every push. Keeping the lint rule as fast feedback is fine; the guarantee should not rest on the regex. Whatever is chosen, add both shapes above to `tests/backgroundStateLintRule.test.js`, and correct the "covers every specifier syntax esbuild resolves statically" claim, which currently appears in the rule header, the test-file header, the commit message, the PR body and `TODO.md` and is false as written.
## Re-derived, and holding
All four claimed shapes fire (exit 2, one error, chain named): quoted require, backtick require, dynamic `import()`, and the two-hop backtick re-export through `src/shared/chainSwitchFields.js`. With the old regex restored, exactly **5 of 8** rule tests fail — backtick, dynamic import, static `from`, bare import, two-hop — as claimed. The negative case is not vacuous: pointing `FORBIDDEN` at `src/shared/networks.js` makes "the repository's own background entrypoint" fail, and so does reverting `src/` to `next`. All three fail-first claims reproduce exactly (`11155111n` vs `1n`; the "different network" refusal; `"1.5"` vs `"0.0"`). `make check` green in my own clone with `[lint 1/1] RUN make lint` executed uncached (`DONE 4.9s`, with output); CI green on `277ec8c`; fast-forwardable onto `next` at `669c443`; one commit, title ends ` (closes #324)`, body closes https://git.eeqj.de/sneak/AutistMask/issues/320; base is `next`; no AI-vendor references or attribution trailers anywhere. Working tree and pushed branch are identical and carry no probe mutation, stray fixture or scratch file. The `tests/deleteWalletLostPassword.test.js` / `tests/stateMerge.test.js` reroute is mechanical (`_raw()` → the stub's `read()`, both clone on the way out); no assertion changed meaning; `tests/coldWorkerChainId.test.js` clones both ways and does not drive `state.js`, so leaving it is justified. `updateState()`'s queue is unchanged and the reentrancy deadlock is named in its header. The scoped guard reasoning still holds: nothing in the background assigns into the singleton, so `adopted` stays false there.
FAIL — needs-rework. Three defects plus one required test. The new mechanism itself held against every evasion I could construct; the findings are in its edges, its wording, and its own lack of coverage.
1. build.js:35-37 — the anti-rot check covers only half the table
A FORBIDDEN_INPUTSkey that no entry point matches fails the build, as claimed: src/background/renamed.js gives exit 2, is listed in FORBIDDEN_INPUTS but was not bundled, so nothing checked it. A forbidden module that no longer exists does not. I changed the value alone to src/shared/stateRenamed.js: make buildexit 0, receipt written, verify-build and check-censored --require-dist pass, nothing said.
So renaming or moving src/shared/state.js silently disarms the guarantee — and disarms the lint rule in the same moment, since script/lib/eslint/noStateSingletonInBackground.js:46 is a second literal copy of the same path. No test pins either: the rule test's only repo-facing case is a negative one, which passes vacuously after a rename. #311 is sequenced next and rewrites this exact persistence layer, so this is not a hypothetical rot. The commit message's "A FORBIDDEN_INPUTS key that matches no bundled entry point also fails, so the table cannot rot into a vacuous pass" overstates: half the table can.
Acceptable: assert that every module named in FORBIDDEN_INPUTS was bundled into at least one output of this build. The popup bundles src/shared/state.js, so that holds today and is stronger than an existsSync — it fails both on a rename and on a module that stops being built at all.
2. The "computed specifier" claim is false, in three places
script/lib/eslint/noStateSingletonInBackground.js:38-40, the header of tests/backgroundStateLintRule.test.js, and the commit message all say require("../shared/" + "state") is deliberately unmatched because "esbuild cannot resolve that statically either, so it never reaches the bundle" / "there is nothing to block".
Appended verbatim to src/background/index.js, make build is exit 2: dist/chrome/src/background/index.js bundles src/shared/state.js, which src/background/index.js must not reach: src/background/index.js -> src/shared/state.js. esbuild folds the concatenation. The variable form, await import("../shared/" + part), is resolved too — as a glob import — and is also caught, with importChain() returning null so the message correctly degrades to no chain rather than crashing.
The build catches both, which is the redesign working. But the stated reason for not matching them is wrong, and it is exactly the sentence a future reader would cite for not widening anything. Acceptable: "not matched by this rule; the build's metafile assertion catches it".
3. Three source comments still name the lint rule as the enforcement
The correction landed in the five places listed, but not in:
src/background/index.js:11-14 — "is deliberately NOT imported here and must never be: see the header of src/background/state.js, and the lint rule that enforces it in eslint.config.js."
src/shared/state.js:12-17 — "this module is unreachable from the background bundle (enforced by the ESLint rule in eslint.config.js, and by the background having its own per-call storage layer ...)".
src/shared/state.js:60-61 — "what closes it for the background is that the background cannot reach this module at all (eslint.config.js)."
The third is load-bearing: it is the written justification for the scoped loud-read guard, and it points at the layer this PR just demoted to best-effort. Same class as #331 and #309. All three should name build.js's assertion.
4. The guarantee is unpinned, and must not stay that way
make check does not run make build, so assertNoForbiddenInputs() has zero coverage in it. CI executes it (Dockerfile:42, via script/cibuild), but executing is not testing: invert the condition, or make the FORBIDDEN_INPUTS[entry] lookup always come back undefined, and every check in this repo stays green while the singleton walks back into the worker. That is precisely the failure mode script/test-verify-build's own header records four consecutive reviews of, and the reason that 46-case harness exists.
The stated obstacles do not bind. build.js runs build() at load — guarding that one call with require.main === module and exporting the helpers makes them testable with no dist/ written and no build shelled out, against synthetic metafiles. Required assertions:
a forbidden module present in an output's inputs throws, and the message names the import chain;
an output not containing it does not throw;
importChain() terminates and returns a chain over a cyclicinputs[].imports graph;
a FORBIDDEN_INPUTS key that no bundled entry point matched fails;
finding 1's inverse: a forbidden module that this build bundled nowhere fails.
Non-blocking, for disclosure
An equivalent singleton that is copied rather than imported is invisible to both layers. cp src/shared/state.js src/shared/stateCopy.js plus a background require: make build exit 0, make lint exit 0, and grep -c StateNotLoadedError = 1 in both dist/chrome/src/background/index.js and dist/firefox/src/background/index.js. This is inherent to a path-keyed assertion and no code change is asked for — for a literal copy the proxy guard still throws in the background (nothing there assigns, so adopted stays false), but a newly written singleton carries no such backstop. It belongs in the PR body's disclosures.
A symlink to the singleton is caught by the build (esbuild reports the real path) and missed by the rule (make lint exit 0) — a ninth known matcher divergence, and fine under the new two-layer framing.
FORBIDDEN_INPUTS protects the one entry point it names; a second background-side entry point added later would be covered by the rule (glob src/background/**) but not by the build. Low priority, noted.
Verified and holding
All eight shapes re-derived, each applied alone and reverted: every one is make build exit 2 from assertNoForbiddenInputs(), with the chain named through both the two-hop (index.js -> chainSwitchFields.js -> state.js) and the package.jsonmain hop (index.js -> probepkg/bridge.js -> state.js). Shape 5's honesty holds exactly — make lint reports Parsing error: 'import' and 'export' may appear only with 'sourceType: module', not the rule, and the build blocks it regardless. No false positive: unmodified branch make build exit 0, StateNotLoadedError = 0 in both background bundles and 1 in both popup bundles. 277ec8c is no longer fetchable, so I reconstructed the round-2 matcher by hand (reverting the comment gap, the package.jsonmain candidates and the trailing \s*\)); with it, exactly the 3 new rule cases fail, 908/911 — restored, 911/911. All three fail-first claims reproduce (11155111n vs 1n; the "different network" refusal; "1.5" vs "0.0"). getProvider() refuses an unknown network id loudly. tests/support/storageStub.js clones both ways. make check green in my own clone: 50 suites, 911 tests, test-verify-build 46 cases, check-censored 173 tracked files, [lint 1/1] RUN make lint executed uncached (DONE 5.4s, with its output), prettier clean. CI green on cf8cb24 including both e2e jobs; fast-forwardable onto next at 36bc6be; one commit, title ends (closes #324), body closes #320; base is next; no AI-vendor reference or attribution trailer anywhere; working tree and pushed branch identical, carrying no probe, fixture or scratch file.
FAIL — `needs-rework`. Three defects plus one required test. The new mechanism itself held against every evasion I could construct; the findings are in its edges, its wording, and its own lack of coverage.
## 1. `build.js:35-37` — the anti-rot check covers only half the table
A `FORBIDDEN_INPUTS` **key** that no entry point matches fails the build, as claimed: `src/background/renamed.js` gives exit 2, `is listed in FORBIDDEN_INPUTS but was not bundled, so nothing checked it`. A forbidden **module** that no longer exists does not. I changed the value alone to `src/shared/stateRenamed.js`: `make build` **exit 0**, receipt written, `verify-build` and `check-censored --require-dist` pass, nothing said.
So renaming or moving `src/shared/state.js` silently disarms the guarantee — and disarms the lint rule in the same moment, since `script/lib/eslint/noStateSingletonInBackground.js:46` is a second literal copy of the same path. No test pins either: the rule test's only repo-facing case is a negative one, which passes vacuously after a rename. https://git.eeqj.de/sneak/AutistMask/issues/311 is sequenced next and rewrites this exact persistence layer, so this is not a hypothetical rot. The commit message's "A FORBIDDEN_INPUTS key that matches no bundled entry point also fails, so the table cannot rot into a vacuous pass" overstates: half the table can.
Acceptable: assert that every module named in `FORBIDDEN_INPUTS` was bundled into at least one output of this build. The popup bundles `src/shared/state.js`, so that holds today and is stronger than an `existsSync` — it fails both on a rename and on a module that stops being built at all.
## 2. The "computed specifier" claim is false, in three places
`script/lib/eslint/noStateSingletonInBackground.js:38-40`, the header of `tests/backgroundStateLintRule.test.js`, and the commit message all say `require("../shared/" + "state")` is deliberately unmatched because "esbuild cannot resolve that statically either, so it never reaches the bundle" / "there is nothing to block".
Appended verbatim to `src/background/index.js`, `make build` is **exit 2**: `dist/chrome/src/background/index.js bundles src/shared/state.js, which src/background/index.js must not reach: src/background/index.js -> src/shared/state.js`. esbuild folds the concatenation. The variable form, `await import("../shared/" + part)`, is resolved too — as a glob import — and is also caught, with `importChain()` returning null so the message correctly degrades to no chain rather than crashing.
The build catches both, which is the redesign working. But the stated reason for not matching them is wrong, and it is exactly the sentence a future reader would cite for not widening anything. Acceptable: "not matched by this rule; the build's metafile assertion catches it".
## 3. Three source comments still name the lint rule as the enforcement
The correction landed in the five places listed, but not in:
- `src/background/index.js:11-14` — "is deliberately NOT imported here and must never be: see the header of `src/background/state.js`, and the lint rule that enforces it in `eslint.config.js`."
- `src/shared/state.js:12-17` — "this module is unreachable from the background bundle (enforced by the ESLint rule in `eslint.config.js`, and by the background having its own per-call storage layer ...)".
- `src/shared/state.js:60-61` — "what closes it for the background is that the background cannot reach this module at all (`eslint.config.js`)."
The third is load-bearing: it is the written justification for the scoped loud-read guard, and it points at the layer this PR just demoted to best-effort. Same class as https://git.eeqj.de/sneak/AutistMask/issues/331 and https://git.eeqj.de/sneak/AutistMask/issues/309. All three should name `build.js`'s assertion.
## 4. The guarantee is unpinned, and must not stay that way
`make check` does not run `make build`, so `assertNoForbiddenInputs()` has **zero** coverage in it. CI executes it (`Dockerfile:42`, via `script/cibuild`), but executing is not testing: invert the condition, or make the `FORBIDDEN_INPUTS[entry]` lookup always come back undefined, and every check in this repo stays green while the singleton walks back into the worker. That is precisely the failure mode `script/test-verify-build`'s own header records four consecutive reviews of, and the reason that 46-case harness exists.
The stated obstacles do not bind. `build.js` runs `build()` at load — guarding that one call with `require.main === module` and exporting the helpers makes them testable with no `dist/` written and no build shelled out, against synthetic metafiles. Required assertions:
- a forbidden module present in an output's `inputs` throws, and the message names the import chain;
- an output not containing it does not throw;
- `importChain()` terminates and returns a chain over a **cyclic** `inputs[].imports` graph;
- a `FORBIDDEN_INPUTS` key that no bundled entry point matched fails;
- finding 1's inverse: a forbidden module that this build bundled nowhere fails.
## Non-blocking, for disclosure
An equivalent singleton that is **copied** rather than imported is invisible to both layers. `cp src/shared/state.js src/shared/stateCopy.js` plus a background `require`: `make build` exit 0, `make lint` exit 0, and `grep -c StateNotLoadedError` = **1** in both `dist/chrome/src/background/index.js` and `dist/firefox/src/background/index.js`. This is inherent to a path-keyed assertion and no code change is asked for — for a literal copy the proxy guard still throws in the background (nothing there assigns, so `adopted` stays false), but a newly written singleton carries no such backstop. It belongs in the PR body's disclosures.
A symlink to the singleton is **caught** by the build (esbuild reports the real path) and **missed** by the rule (`make lint` exit 0) — a ninth known matcher divergence, and fine under the new two-layer framing.
`FORBIDDEN_INPUTS` protects the one entry point it names; a second background-side entry point added later would be covered by the rule (glob `src/background/**`) but not by the build. Low priority, noted.
## Verified and holding
All eight shapes re-derived, each applied alone and reverted: every one is `make build` exit 2 from `assertNoForbiddenInputs()`, with the chain named through both the two-hop (`index.js -> chainSwitchFields.js -> state.js`) and the `package.json` `main` hop (`index.js -> probepkg/bridge.js -> state.js`). Shape 5's honesty holds exactly — `make lint` reports `Parsing error: 'import' and 'export' may appear only with 'sourceType: module'`, not the rule, and the build blocks it regardless. No false positive: unmodified branch `make build` exit 0, `StateNotLoadedError` = 0 in both background bundles and 1 in both popup bundles. `277ec8c` is no longer fetchable, so I reconstructed the round-2 matcher by hand (reverting the comment gap, the `package.json` `main` candidates and the trailing `\s*\)`); with it, exactly the 3 new rule cases fail, 908/911 — restored, 911/911. All three fail-first claims reproduce (`11155111n` vs `1n`; the "different network" refusal; `"1.5"` vs `"0.0"`). `getProvider()` refuses an unknown network id loudly. `tests/support/storageStub.js` clones both ways. `make check` green in my own clone: 50 suites, 911 tests, `test-verify-build` 46 cases, `check-censored` 173 tracked files, `[lint 1/1] RUN make lint` executed uncached (`DONE 5.4s`, with its output), prettier clean. CI green on `cf8cb24` including both e2e jobs; fast-forwardable onto `next` at `36bc6be`; one commit, title ends ` (closes #324)`, body closes https://git.eeqj.de/sneak/AutistMask/issues/320; base is `next`; no AI-vendor reference or attribution trailer anywhere; working tree and pushed branch identical, carrying no probe, fixture or scratch file.
FAIL — needs-rework. Four findings, all in the guarantee's own guard rather
than in shipped behaviour: the assertion held against every evasion I could
build, and all three documented bounds are true as measured on this head.
1. build.js:139 — an entry is marked "checked" before it can be checked, and the throw that saves that is untested
record.entriesChecked.add(entry) runs immediately after the table lookup, four
lines before inputs is resolved. If assertNoForbiddenInputs() leaves early
after that point, the entry is still recorded as checked and assertForbiddenTableCovered() passes — so the coverage half cannot detect a
check that bailed out. The only thing standing in that gap today is build.js:145-147, esbuild reported no metafile output for ${out}, and that
branch has ZERO test coverage.
Measured on this head: change those three lines to if (!entryOutput) return;
and nothing else — make test is 919/919 passed, exit 0. In that state both
halves of the guarantee are satisfied by an entry point whose bundle was never
examined, and make build exits 0.
The lookup is not hypothetically fragile: repoRelative() resolves a RELATIVE
path against process.cwd(), and esbuild's metafile output keys are
cwd-relative, so any change to where the build runs from, to outfile
vs outdir, or to output naming makes it miss. Today that throws loudly, which
is correct — but nothing pins it, and the ordering means it is the last line of
defence rather than the second.
Acceptable: move record.entriesChecked.add(entry) to after inputs is
successfully built, and add a case to tests/buildForbiddenInputs.test.js
asserting the no-matching-output throw.
2. script/lib/forbiddenBundleInputs.js:51 — an empty module list is a third vacuous pass, and it disarms both layers at once
assertForbiddenTableCovered() fails a stale KEY and a stale MODULE, both
confirmed. It does not fail an entry whose list is empty, and neither does the
rule: FORBIDDEN there is Object.values(FORBIDDEN_INPUTS).flat(), so an empty
value leaves it with nothing to look for.
Measured, "src/background/index.js": [] as the only edit, plus globalThis.__probeEmpty = require("../shared/state").state; appended to src/background/index.js:
make lintexit 0
make buildexit 0
grep -c StateNotLoadedError = 1 in BOTH dist/chrome/src/background/index.js and dist/firefox/src/background/index.js
Nothing is reported anywhere. The singleton is back in the shipped worker with
every check in the repo green — the precise outcome the header at script/lib/forbiddenBundleInputs.js:46-49 says cannot happen ("Both halves are
checked for rot ... each fail the build rather than passing vacuously"). Fixing
it is one line: fail when a listed entry names no modules.
3. TODO.md — "the two it is known to miss ... pinned by tests/backgroundStateLintRule.test.js" is false
That file has eleven cases and none of them is the computed specifier or the
symlink; both appear only in its header comment, lines 23-26. Nothing asserts
that the rule does not report them, so nothing would notice if that changed. The
PR body's own wording for the same fact ("record ... as measured known
divergences") is accurate; TODO.md says pinned by and is not. Third round in
a row that a claim about what pins or enforces what has been wrong, which is why
it is itemized rather than waived.
4. tests/buildForbiddenInputs.test.js — recordBundledInputs() is imported but never actually pinned
Every assertForbiddenTableCovered() case hand-seeds record.bundledInputs.add(STATE), and the one case that reads the function's
output asserts bundledInputs.has(STATE) is false — which is trivially true
if it records nothing. Measured: replace the body of recordBundledInputs()
with a no-op and make test is 919/919, exit 0. That function is the sole
data source for the module half of the anti-rot check added this round, so that
half rests on an unpinned helper. Its failure direction is fail-safe
(under-recording throws), so this is the smallest of the four — but one case
driving the real function into the covered check closes it.
Disclosure correction, no code change asked for
The COPY residual's stated reason is that "a copy carries the singleton's own
guard, so a background read of an unloaded field throws StateNotLoadedError
... loud". True, and I reproduced the whole bound (make build exit 0, make lint exit 0, marker = 1 in both background bundles). But it only covers
defects 1-3 of #324. A copy also
carries loadState(), and defects 4 and 5 — a state.rpcUrl read several
awaits after a load, and a load detaching the objects backgroundRefresh() is
mutating — are silent over a LOADED singleton, copy or not. The accepted
residual is wider than the reason given for accepting it, and the sentence
should say so.
Related, and worth one line where the two residuals are recorded: they compound.
A second background entry point is invisible to the build (confirmed: src/background/worker2.js requiring the singleton, named nowhere in the table,
is make buildexit 0 with the marker = 1 in the new bundle, caught only by make lintexit 2). A second entry point that reaches the singleton by a
shape the rule also misses — computed specifier, symlink — is green everywhere.
Verified on this head, holding
Guarantee not evaded by anything I tried: quoted require, computed require("../shared/" + "state") (lint 0 / build 2), glob import("../shared/" + variable) (build 2, chain correctly degraded),
symlink (lint 0 / build 2), stale MODULE (exit 2, named message), stale
KEY, a module path spelled ./src/shared/state.js (exit 2 via the module
half). require.main === module did not change the build: make build exit 0,
receipt written (15 files, 4 audited), verify-build and check-censored --require-dist pass, StateNotLoadedError 0 in both background
bundles and 1 in both popup bundles. The new test drives the SHIPPED helpers
(require("../build")); the fail-first claim reproduces exactly — the key-only
form of assertForbiddenTableCovered() gives 1 failed, 918 passed, that
case, that message — and inverting the input condition, the table lookup, or the
key check each fail 4, 4 and 1 cases respectively. The three behavioural
fail-first claims reproduce against src/ at next (11155111n vs 1n; "1.5" vs "0.0"; the "different network" refusal), and the rule test's
repository case fails there too, so it is not vacuous. make check green in my
own clone: 51 suites, 919 tests, check-censored 175 files, [lint 1/1] RUN make lint executed uncached (DONE 5.3s, with output),
prettier clean. CI green on 18ad93b (check, e2e-chrome, e2e-firefox);
fast-forwardable onto next; one commit, title ends (closes #324), body
closes #320; base is next; no
AI-vendor reference or attribution trailer anywhere; working tree and pushed
branch identical, no probe, symlink, fixture or scratch file left, no container
started that survives.
Note on evidence attribution: cf8cb24 is no longer fetchable, so the eight
carried-forward rows cannot be re-derived at that commit. Everything the body
marks "re-measured on this head" I reproduced, and four of the carried rows I
re-measured independently on this head also match, so the attribution reads as
honest.
FAIL — `needs-rework`. Four findings, all in the guarantee's own guard rather
than in shipped behaviour: the assertion held against every evasion I could
build, and all three documented bounds are true as measured on this head.
## 1. `build.js:139` — an entry is marked "checked" before it can be checked, and the throw that saves that is untested
`record.entriesChecked.add(entry)` runs immediately after the table lookup, four
lines before `inputs` is resolved. If `assertNoForbiddenInputs()` leaves early
after that point, the entry is still recorded as checked and
`assertForbiddenTableCovered()` passes — so the coverage half cannot detect a
check that bailed out. The only thing standing in that gap today is
`build.js:145-147`, `esbuild reported no metafile output for ${out}`, and that
branch has ZERO test coverage.
Measured on this head: change those three lines to `if (!entryOutput) return;`
and nothing else — `make test` is **919/919 passed, exit 0**. In that state both
halves of the guarantee are satisfied by an entry point whose bundle was never
examined, and `make build` exits 0.
The lookup is not hypothetically fragile: `repoRelative()` resolves a RELATIVE
path against `process.cwd()`, and esbuild's metafile output keys are
cwd-relative, so any change to where the build runs from, to `outfile`
vs `outdir`, or to output naming makes it miss. Today that throws loudly, which
is correct — but nothing pins it, and the ordering means it is the last line of
defence rather than the second.
Acceptable: move `record.entriesChecked.add(entry)` to after `inputs` is
successfully built, and add a case to `tests/buildForbiddenInputs.test.js`
asserting the no-matching-output throw.
## 2. `script/lib/forbiddenBundleInputs.js:51` — an empty module list is a third vacuous pass, and it disarms both layers at once
`assertForbiddenTableCovered()` fails a stale KEY and a stale MODULE, both
confirmed. It does not fail an entry whose list is empty, and neither does the
rule: `FORBIDDEN` there is `Object.values(FORBIDDEN_INPUTS).flat()`, so an empty
value leaves it with nothing to look for.
Measured, `"src/background/index.js": []` as the only edit, plus
`globalThis.__probeEmpty = require("../shared/state").state;` appended to
`src/background/index.js`:
- `make lint` **exit 0**
- `make build` **exit 0**
- `grep -c StateNotLoadedError` = **1** in BOTH
`dist/chrome/src/background/index.js` and
`dist/firefox/src/background/index.js`
Nothing is reported anywhere. The singleton is back in the shipped worker with
every check in the repo green — the precise outcome the header at
`script/lib/forbiddenBundleInputs.js:46-49` says cannot happen ("Both halves are
checked for rot ... each fail the build rather than passing vacuously"). Fixing
it is one line: fail when a listed entry names no modules.
## 3. `TODO.md` — "the two it is known to miss ... pinned by `tests/backgroundStateLintRule.test.js`" is false
That file has eleven cases and none of them is the computed specifier or the
symlink; both appear only in its header comment, lines 23-26. Nothing asserts
that the rule does not report them, so nothing would notice if that changed. The
PR body's own wording for the same fact ("record ... as measured known
divergences") is accurate; `TODO.md` says `pinned by` and is not. Third round in
a row that a claim about what pins or enforces what has been wrong, which is why
it is itemized rather than waived.
## 4. `tests/buildForbiddenInputs.test.js` — `recordBundledInputs()` is imported but never actually pinned
Every `assertForbiddenTableCovered()` case hand-seeds
`record.bundledInputs.add(STATE)`, and the one case that reads the function's
output asserts `bundledInputs.has(STATE)` is **false** — which is trivially true
if it records nothing. Measured: replace the body of `recordBundledInputs()`
with a no-op and `make test` is **919/919, exit 0**. That function is the sole
data source for the module half of the anti-rot check added this round, so that
half rests on an unpinned helper. Its failure direction is fail-safe
(under-recording throws), so this is the smallest of the four — but one case
driving the real function into the covered check closes it.
## Disclosure correction, no code change asked for
The COPY residual's stated reason is that "a copy carries the singleton's own
guard, so a background read of an unloaded field throws `StateNotLoadedError`
... loud". True, and I reproduced the whole bound (`make build` exit 0,
`make lint` exit 0, marker = 1 in both background bundles). But it only covers
defects 1-3 of https://git.eeqj.de/sneak/AutistMask/issues/324. A copy also
carries `loadState()`, and defects 4 and 5 — a `state.rpcUrl` read several
awaits after a load, and a load detaching the objects `backgroundRefresh()` is
mutating — are silent over a LOADED singleton, copy or not. The accepted
residual is wider than the reason given for accepting it, and the sentence
should say so.
Related, and worth one line where the two residuals are recorded: they compound.
A second background entry point is invisible to the build (confirmed:
`src/background/worker2.js` requiring the singleton, named nowhere in the table,
is `make build` **exit 0** with the marker = 1 in the new bundle, caught only by
`make lint` **exit 2**). A second entry point that reaches the singleton by a
shape the rule also misses — computed specifier, symlink — is green everywhere.
## Verified on this head, holding
Guarantee not evaded by anything I tried: quoted require, computed
`require("../shared/" + "state")` (**lint 0 / build 2**), glob
`import("../shared/" + variable)` (**build 2**, chain correctly degraded),
symlink (**lint 0 / build 2**), stale MODULE (**exit 2**, named message), stale
KEY, a module path spelled `./src/shared/state.js` (**exit 2** via the module
half). `require.main === module` did not change the build: `make build` exit 0,
receipt written (15 files, 4 audited), `verify-build` and
`check-censored --require-dist` pass, `StateNotLoadedError` 0 in both background
bundles and 1 in both popup bundles. The new test drives the SHIPPED helpers
(`require("../build")`); the fail-first claim reproduces exactly — the key-only
form of `assertForbiddenTableCovered()` gives **1 failed, 918 passed**, that
case, that message — and inverting the input condition, the table lookup, or the
key check each fail 4, 4 and 1 cases respectively. The three behavioural
fail-first claims reproduce against `src/` at `next` (`11155111n` vs `1n`;
`"1.5"` vs `"0.0"`; the "different network" refusal), and the rule test's
repository case fails there too, so it is not vacuous. `make check` green in my
own clone: 51 suites, 919 tests, `check-censored` 175 files,
`[lint 1/1] RUN make lint` executed uncached (`DONE 5.3s`, with output),
prettier clean. CI green on `18ad93b` (check, e2e-chrome, e2e-firefox);
fast-forwardable onto `next`; one commit, title ends ` (closes #324)`, body
closes https://git.eeqj.de/sneak/AutistMask/issues/320; base is `next`; no
AI-vendor reference or attribution trailer anywhere; working tree and pushed
branch identical, no probe, symlink, fixture or scratch file left, no container
started that survives.
Note on evidence attribution: `cf8cb24` is no longer fetchable, so the eight
carried-forward rows cannot be re-derived at that commit. Everything the body
marks "re-measured on this head" I reproduced, and four of the carried rows I
re-measured independently on this head also match, so the attribution reads as
honest.
PASS. All five round-5 claims and all five documented bounds re-measured independently on c8d758f in a fresh clone and correct; make check green (51 suites, 930 tests, test-verify-build 46 cases, check-censored 175 tracked files, [lint 1/1] RUN make lint executed uncached DONE 5.2s with its output, prettier clean); CI green on the head; next at 36bc6be is an ancestor; one commit, no AI-vendor reference or trailer; build.js read in full is coherent after the disclosed git checkout mishap, and no probe artifact, symlink or scratch file survives anywhere.
Two items, neither blocking.
One bound the list does not name. A background entry point bundled by a direct esbuild.build() call rather than through build.js's bundle() helper never reaches assertNoForbiddenInputs(). Measured on this head (src/background/worker2.js requiring the singleton, bundled directly): make buildexit 2 — but from script/verify-build (carries a debug marker but the build did not record it as containing src/shared/constants.js), not from the forbidden-input assertion. Not a silent hole, and reliably not one: src/shared/state.js reaches src/shared/constants.js through both persistedState.js and log.js, so any bundle containing the singleton carries the marker. Recorded as a bound, not a defect.
tests/buildForbiddenInputs.test.js:210, "the prefix is the one the lint rule is scoped to", reads nothing from eslint.config.js; it asserts BACKGROUND_ENTRY_PREFIX === "src/background/" against a literal. The underlying claim is true — eslint.config.js:13-14,106 builds its glob from the imported constant, and a top-level src/background/worker2.js is reported by make lint — and the assertion is loud in the direction that matters, since changing the constant fails the case. The case name promises more than it asserts.
Reviewer disclosure: the fail-first reproduction ("1.5" vs "0.0", 11155111n vs 1n, and the "different network" refusal — all three reproduce against src/ at next) was run with yarn jest on two files rather than through a make/script entrypoint. Everything else, and every lint run, went through them.
PASS. All five round-5 claims and all five documented bounds re-measured independently on `c8d758f` in a fresh clone and correct; `make check` green (51 suites, 930 tests, `test-verify-build` 46 cases, `check-censored` 175 tracked files, `[lint 1/1] RUN make lint` executed uncached `DONE 5.2s` with its output, prettier clean); CI green on the head; `next` at `36bc6be` is an ancestor; one commit, no AI-vendor reference or trailer; `build.js` read in full is coherent after the disclosed `git checkout` mishap, and no probe artifact, symlink or scratch file survives anywhere.
Two items, neither blocking.
- **One bound the list does not name.** A background entry point bundled by a direct `esbuild.build()` call rather than through `build.js`'s `bundle()` helper never reaches `assertNoForbiddenInputs()`. Measured on this head (`src/background/worker2.js` requiring the singleton, bundled directly): `make build` **exit 2** — but from `script/verify-build` (`carries a debug marker but the build did not record it as containing src/shared/constants.js`), not from the forbidden-input assertion. Not a silent hole, and reliably not one: `src/shared/state.js` reaches `src/shared/constants.js` through both `persistedState.js` and `log.js`, so any bundle containing the singleton carries the marker. Recorded as a bound, not a defect.
- **`tests/buildForbiddenInputs.test.js:210`**, "the prefix is the one the lint rule is scoped to", reads nothing from `eslint.config.js`; it asserts `BACKGROUND_ENTRY_PREFIX === "src/background/"` against a literal. The underlying claim is true — `eslint.config.js:13-14,106` builds its glob from the imported constant, and a top-level `src/background/worker2.js` is reported by `make lint` — and the assertion is loud in the direction that matters, since changing the constant fails the case. The case name promises more than it asserts.
Reviewer disclosure: the fail-first reproduction (`"1.5"` vs `"0.0"`, `11155111n` vs `1n`, and the "different network" refusal — all three reproduce against `src/` at `next`) was run with `yarn jest` on two files rather than through a `make`/`script` entrypoint. Everything else, and every lint run, went through them.
clawbot
merged commit bd0a626e7b into next2026-08-23 17:57:31 +02:00
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
closes #324
closes #320
Root-cause change, not a sixth point fix. The background no longer holds an
in-memory copy of the profile at all,
src/shared/state.jsis unreachable fromits bundle, and the build fails if it ever becomes reachable again.
Evidence attribution
Every measurement below is marked [this head] (
c8d758f) or[carried forward] with the commit it was taken at. The carried-forward rows
are the eight specifier shapes from round 2, measured at
cf8cb24, which is nolonger fetchable; the matching logic they exercise is unchanged since, and
shape 1 was re-measured on this head. Everything else in this body was measured
on
c8d758f.What changed
src/background/state.js(new) — the background's whole access to theprofile.
getState()is a detached, normalized per-call read;updateState(fn)is a queued read-modify-write whose read is one storage round trip ahead of its
write. No module-level copy, because the MV3 worker has no lifetime to hold one
over. The write is the WHOLE record, and the header now names what that costs:
a popup write landing inside that one-round-trip window is reverted.
activeAddressOf(s)replaced a second, latergetState()that could disagreewith the first.
wallet_switchEthereumChainappliesapplyChainSwitchFields()(split out ofchainSwitch.js, which keeps the singleton path for the popup) insideupdateState(), instead ofonChainSwitch()on the singleton.wrapped around a prompt the user takes seconds to answer.
backgroundRefresh()refreshes a private copy of the wallets andapplies the balances that came back BY ADDRESS. It never publishes an object
other in-flight work holds, and a wallet added or deleted during the round
trip survives its write.
the same snapshot.
getProvider(rpcUrl, networkId)now REQUIRES the network id, validatedagainst
networks.js.refreshBalances(),lookupTokenInfo(),scanForAddresses()andresolveEnsName()carry it through;balances.jsnolonger requires
state.jsat all.Loud failure: reading a persisted field of the singleton before any load
throws
StateNotLoadedErrorinstead of servingDEFAULT_STATE.Mechanical enforcement: the guarantee is the bundler's, and it is tested
Two review rounds found four evasions of a hand-rolled ESLint matcher. Each was
the same mistake — the rule reimplemented a JS parser and a module resolver, and
it will keep diverging from esbuild. So the guarantee moved to the build.
The table lives in
script/lib/forbiddenBundleInputs.js: one map from arepo-relative entry point to the modules its bundle may not contain, read by
BOTH layers. It was two literal copies of the same path (
build.jsand therule), which is precisely how a rename disarms one layer while the other still
looks enforced — that is now one copy.
build.js'sassertNoForbiddenInputs()is the guarantee. It fails thebuild when esbuild's own metafile reports a forbidden module as an input of that
entry point's output, and names the import chain by walking the metafile's own
input graph. It consults the resolution esbuild actually performed, so specifier
syntax and resolution rules are not modelled at all.
Dockerfile:42runsmake build, so it is enforced in CI.Background entry points are protected by default
A bundled entry point under
src/background/with no line in the table nowfails the build. Adding a second worker entry point is exactly the kind of
accident this exists for, and the person adding one has no reason to know a
table elsewhere needs a line.
src/background/is the build's only notion of"the background" — entry points are the paths handed to
bundle()— andeslint.config.jsscopes the lint rule from the same exported constant, so thetwo layers cannot disagree about which files that is.
src/background/worker2.jsmake lintmake buildgrep -c StateNotLoadedError= 1 in the new bundle in both browsers [this head, on18ad93b]src/background/worker2.js is a background entry point with no line in FORBIDDEN_INPUTS[this head]dist/chrome/src/background/worker2.js bundles src/shared/state.js, which src/background/worker2.js must not reach: src/background/worker2.js -> src/shared/state.js[this head]src/background/state.js, listed in the tableAnti-rot: every way the table can rot now fails
Each half of an entry rots independently, and each one turns the prohibition
into a pass that checks nothing.
src/background/renamed.jsmake buildexit 2,src/background/renamed.js is listed in FORBIDDEN_INPUTS but was not bundled, so nothing checked it[carried forward,cf8cb24; pinned by a unit case re-run on this head]src/shared/stateRenamed.jsmake buildexit 2,... but this build bundled it nowhere, so the prohibition names a module that is not in this tree at that path and nothing enforces it[carried forward,cf8cb24; pinned by a unit case re-run on this head]"src/background/index.js": [], plus a plainrequire("../shared/state")in the worker — before this roundmake lintexit 0,make buildexit 0,grep -c StateNotLoadedError= 1 in BOTH background bundles [this head, on18ad93b]make lintexit 2,make buildexit 2,make testexit 2, all withFORBIDDEN_INPUTS["src/background/index.js"] lists no modules, so it prohibits nothing while still looking enforced[this head]The empty list is refused at require time, where the table is defined, because
that one also empties the lint rule's forbidden set (
Object.values(...).flat())— the build's own check cannot help a layer that never reaches the build.
assertForbiddenTableCovered()re-checks it so the build's half does not dependon where the table was loaded from.
The check can no longer record itself as done before it runs
record.entriesChecked.add(entry)ran BEFORE the metafile output lookup thatproduces the inputs. Any early return past that point left BOTH halves of the
guarantee satisfied by an entry point whose bundle was never examined. Measured
on
18ad93b: replacing theesbuild reported no metafile outputthrow withif (!entryOutput) return;wasmake test919/919, exit 0 [this head, on18ad93b].The
addnow happens after the inputs are in hand, so that same edit is caughttwice over: the entry is not recorded, and
assertForbiddenTableCovered()failswith
is listed in FORBIDDEN_INPUTS but was not bundled. The throw itself ispinned. With the disarming edit applied to the fixed tree,
make testis1 failed, 929 passed —
an output esbuild did not report fails, and records nothing as checked[this head].The lookup is genuinely the fragile step, which is why it is not left as the
only line of defence:
repoRelative()resolves againstprocess.cwd()whileesbuild's metafile output keys are cwd-relative.
The ESLint rule stays as fast local feedback, reading the same table, and is
described that way everywhere.
Per-shape evidence
The eight shapes below were each applied alone and reverted, with
make lintinthe pinned container and a full
make build. Shapes 2-8 are [carriedforward] from
cf8cb24;assertNoForbiddenInputs()'s matching logic isunchanged since. Shape 1 is [this head].
On the unmodified branch
make buildis exit 0, the receipt records 15 emittedfiles with 4 containing
constants.js, andgrep -c StateNotLoadedErroris0 on both
dist/chrome/src/background/index.jsanddist/firefox/src/background/index.js, 1 on both popup bundles [this head].make lintmake buildrequireglobalThis.__probe1 = require("../shared/state").state;src/background/index.js -> src/shared/state.js[this head]requirerequire(`../shared/state`)import()const m = await import("../shared/state");in an async fnmodule.exports.state = require(`./state`).state;appended tosrc/shared/chainSwitchFields.jsindex.js -> chainSwitchFields.js -> state.jsfromre-exportimport { state as __probe5 } from "../shared/state";index.js -> state.jsrequire(/* probe */ "../shared/state").staterequire("../shared/state" /* probe */).statepackage.jsonmainhopsrc/shared/probepkg/package.json={"main": "./bridge.js"},bridge.jsrequires../state, background requires../shared/probepkgindex.js -> probepkg/bridge.js -> state.jsdist/was absent after each failure (make buildwraps every step inscript/discard-dist-on-failure), which is why there is no grep column: thebuild never emitted a bundle to grep.
On shape 5: under this repo's commonjs
languageOptionsan ESMimportis aparse error, so what
make lintreports there is the parser, not the rule. Therule does cover the shape —
tests/backgroundStateLintRule.test.jspins it witha
sourceType: "module"fixture — but the repository lint run cannot be citedas evidence of it. It is the build that blocks it here.
Shapes the rule does not report, and the build does
All re-measured on this head, applied alone and reverted:
make lintmake buildrequire("../shared/" + "state")src/background/index.js -> src/shared/state.js[this head]import("../shared/" + variable)in an async fnimportChain()returns null and the message degrades rather than crashing) [this head]src/shared/stateLink.jstostate.js, required by the backgroundsrc/background/index.js -> src/shared/state.js(esbuild reports the real path) [this head]Both are now pinned in
tests/backgroundStateLintRule.test.jsas assertedNON-reports, so the rule's stated bounds are measured rather than claimed. That
is what
TODO.mdsays now; the previous round'sTODO.mdsaid they were"pinned by" that file when they existed only in its header comment.
The guarantee's own coverage
make checkdoes not runmake build, soassertNoForbiddenInputs()had ZEROcoverage in it.
build.jsrunsbuild()only underrequire.main === moduleand exports the checks;
tests/buildForbiddenInputs.test.jsdrives themagainst SYNTHETIC metafiles in esbuild's shape — no
dist/, no shelled-outbuild. Seventeen cases; the nine added this round are marked NEW:
(
index.js -> chainSwitchFields.js -> state.js);above);
the ordering fix, asserted through
assertForbiddenTableCovered()as well asthrough the throw;
importChain()terminates on a CYCLIC input graph and still finds the module;importChain()terminates and returns null when a cycle cannot reach it;src/background/needs no line;recordBundledInputs()records every input of every output, and thecovered check then passes on what it collected with nothing hand-seeded;
popup-only build, because a stale key over a bundled background entry point is
now the stronger failure above and fires first);
assertTableWellFormed()accepts the shipped table, rejects an entrywith no modules, and rejects a table with no entries.
Fail-first, this round [all this head]:
if (!entryOutput) return;in place of the throwan output esbuild did not report fails, and records nothing as checked(was 919/919 green on18ad93b)recordBundledInputs()body replaced with a no-oprecords every input of every output, satisfying the module half(was 919/919 green on18ad93b)"src/background/index.js": []make test2 suites fail to load,make lintexit 2,make buildexit 2The rule's own coverage is pinned
tests/backgroundStateLintRule.test.jsruns the rule through eslint'sLinterover real fixture trees in a temp dir. Thirteen cases: quoted, backtick, dynamic
import(), staticfrom, bare side-effectimport, comment before thespecifier, comment after it, the two-hop re-export, the
package.jsonmainhop, a clean background that reaches only
src/background/state.js, therepository's own
src/background/index.js(which must report nothing), and NEWthis round the two divergences as asserted non-reports.
Neither non-report case is vacuous [both this head]:
resolveRelative()widened withfs.realpathSync()— a plausible one-line wideninga symlink to the module is not reportedrequirea computed specifier is not reportedSource comments now credit the build
src/background/index.js:11,src/shared/state.js:12andsrc/shared/state.js:60named the ESLint rule as the enforcement. The third isthe written justification for the scoped loud-read guard, so it pointed at the
layer this PR demoted. All three now name
build.js's metafile assertion;eslint.config.js's own comment says the rule is the early report, not theguarantee.
Fail-first evidence (behaviour)
[carried forward, reproduced by three reviews] The three behavioural tests were
run against
src/reverted to head (git stash push -- src/, tests kept):tests/backgroundStateIsolation.test.js"the artifact isbroadcast to the endpoint of the chain it was verified against":
Expected: {"txHash": "0xfeed"}/Received: {"error": "The signed transaction is for a different network than the one that was approved.", "retryable": false, "stage": "verify"}.the refresh":
Expected: "1.5"/Received: "0.0".tests/coldWorkerSendTransaction.test.js"a cold send on Sepoliareaches the approval screen and goes out":
Expected: 11155111n/Received: 1n.Two of the five new cases pass on head, correctly and by design: "a cold send on
mainnet is prepared for mainnet" is the swap guard, and "a wallet added
mid-refresh survives the refresh's write" is already covered by the per-field
merge from #304.
getProvider()call-site audit (every site, background-reachable marked)Background-reachable, all three previously built with the mainnet fallback:
src/background/index.jshandleSendTransaction()— the#320 defect; now
getProvider(s.rpcUrl, s.networkId)from the handler's snapshot.src/background/index.jsAUTISTMASK_TX_RESPONSEbroadcast — wasgetProvider(state.rpcUrl): missing hint AND an endpoint read later than thechain id. Now both from one snapshot.
src/shared/balances.jsrefreshBalances()— reached frombackgroundRefresh(). Same missing hint; now takesnetworkId.Popup-only, no background path, all now pass
state.networkIdexplicitly:balances.jslookupTokenInfo()(addToken.js,settingsAddToken.js),balances.jsscanForAddresses()(addWallet.js, two sites),ens.jsresolveEnsName()(addressDetail.js,addressToken.js),send.js,confirmTx.js(three sites),txStatus.js.Test stub audit
chrome.storage.localis a serialization boundary. Eight files stubbed it withan aliasing
get, so the object a module held and the object "storage" heldwere one object. All eight now use
tests/support/storageStub.js, whichstructured-clones in both directions.
Assertions whose meaning changed:
tests/chainSwitchGate.test.js— mockedsrc/shared/statewholesale and hada no-op
set, so "the switch happened" was read off the mock's ownin-memory object and no persistence was exercised at all. Rewritten against
real storage:
bg.walletState()now reads the written record.tests/backgroundApproval.test.js— same shape, plus a mocked state modulethat supplied the chain.
setNetwork()now moves the storednetworkIdandendpoint together;
setActiveAddress()writes to storage. The two tests thatinjected a failing
loadStatenow install a hook on the state read, armedAFTER the approval is raised so it is the attempt's read that fails.
tests/settingsUtcTimestamps.test.js—expect(second.state.utcTimestamps) .toBe(false)beforeloadState()asserted the default of an unloaded module.That is now an error by design, so it asserts the throw instead.
tests/networkEndpoints.test.js—written()fed the next module load theprevious one's live object as its "persisted bytes"; the restart it simulates
now crosses a real serialization boundary.
tests/alarms.test.js— the "open popup just refreshed" case pokedstore.autistmask.lastBalanceRefreshon an object the worker shared; it nowwrites to storage. The latency simulation is preserved through
onOp.tests/txStatus.test.js,tests/state.test.js,tests/coldWorkerChainSwitch.test.js— stub replaced; no assertion changedmeaning.
tests/deleteWalletLostPassword.test.jsandtests/stateMerge.test.jskeptprivate
makeStorage()helpers. Both cloned correctly, so nothing was wrong —but
tests/support/storageStub.js's header says "nothing rebuilds a storagestub by hand". Both now call
makeStorageStub();_raw()becameread().tests/coldWorkerChainId.test.jsalso already cloned correctly and is leftalone. The remaining stubs are stateless —
getreturns a fresh{}andsetdiscards.
Disclosures
assertion is keyed by path, so a duplicate of the singleton's code at another
path is outside it.
cp src/shared/state.js src/shared/stateCopy.jsplus abackground
requireof the copy ismake buildexit 0,make lintexit 0, and
grep -c StateNotLoadedError= 1 in BOTH backgroundbundles [this head]. Deliberately not fixed. A copy carries the singleton's
own guard, so defects 1-3 of
#324 — a read of a field nothing
loaded — become a loud
StateNotLoadedErrorrather than a silentDEFAULT_STATE. Defects 4 and 5 do NOT: a copy also carriesloadState(),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. A newly WRITTEN singleton has no backstop at
all. All of that is recorded in
script/lib/forbiddenBundleInputs.js.build and missed by the rule [this head]. Fine under the two-layer framing,
and now pinned as asserted non-reports rather than only described.
src/background/is covered byneither layer's default and needs its own line in the table. That is the bound
of the prefix rule, and it is the narrowest defensible definition: the build
has no other notion of a background entry point, and the lint rule's glob is
scoped from the same constant. Recorded where the table lives.
updateState()is not reentrant, and says so. Amutatethat callsupdateState()itself deadlocks the background: the queue is strictly serial.No call site does this today; the queue is deliberately NOT redesigned here
and the trap is named in the header comment above
updateState().updateState()writes the whole record. A popup write that lands insidethe one-round-trip window between its read and its write is reverted, in every
field. Accepted — the window is one storage round trip and the popup is not
writing while the worker is — and now stated in the header rather than left
for the next reader to derive.
read of a persisted field before
loadState()— unless this context hasALREADY ASSIGNED into it. The cost: a context that writes one field and then
reads a different, untouched one is still served that field's default.
Nothing in
state.jscloses that; what closes it for the background is thatthe background cannot reach the module at all, which is the build's assertion.
The alternative (per-field tracking) was implemented and measured first — it
failed ~94-103 tests across popup suites unrelated to this issue, which two
reviews independently reproduced and adjudicated as fixture gaps.
applyChainSwitchFields()does not callclearPrices();onChainSwitch()(popup) still does. The price cache is in-memory and per bundle, and the
background never fills it, so the background's old call cleared nothing.
tests/deleteAddress.test.jsandtests/transactions.test.jsgained anetwork id / a
loadState()in setup: both read state that nothing hadloaded.
normalizePersisted()now structured-clones the nested collections itreturns, so a caller may mutate the result freely.
wallet_switchEthereumChainas its trigger rather thanthe
eth_chainIdnamed in the issue: that path was already moved offloadState()by #317.Verification
[all this head]
make checkgreen onc8d758f, which is one commit on top ofnextat36bc6be: exit 0, 51 suites, 930 tests,test-verify-build46 cases,
check-censored175 tracked files, lint in the pinnedcontainer (
[lint 1/1] RUN make lintexecuted uncached,DONE 5.1s, with itsoutput), prettier clean. (Earlier revisions of this body quoted 45/861/162,
49/906/172, 50/911/173 and 51/919/175 — the figures before each rebase onto a
moving
nextand before each round's new tests.)make buildexit 0 on the unmodified branch, exit 2 for every mutation above.dist/removed afterwards; the working tree and the pushed branch are identicaland carry no probe fixture, symlink or scratch file;
docker ps -alistsnothing, and no image tag was removed.
FAIL —
needs-rework. One blocking finding; everything else verified and holds.BLOCKING: the singleton is still reachable from the background, and
make checkis green over itscript/lib/eslint/noStateSingletonInBackground.js:31—const REQUIRE_RE = /\brequire\(\s*["']([^"']+)["']\s*\)/g;The reachability walk matches only
require()with a double- or single-quoted specifier. Two specifier shapes are invisible to it, and esbuild statically resolves both, sosrc/shared/state.jslands in the emitted background bundle with no check failing.Measured in a clean clone at
4343d8f, appending tosrc/background/index.js:const probeI = require(`../shared/state`);make check→ exit 0. 853/853 tests pass,[lint 1/1] RUN make lintexecutes and reports no problems, prettier reports "All matched files use Prettier code style!".make build→grep -c StateNotLoadedError dist/chrome/src/background/index.js= 1 (0 on unmodified head). Same for"was read before loadState"and"identity collision merging". The singleton is in the worker bundle.await import("../shared/state.js")inside anasync functionmake lintclean;grep -c StateNotLoadedErroron the built background bundle = 1.For contrast, both shapes the PR body demonstrates do fire, and I reproduced them: the direct quoted require, and the two-hop re-export appended to
src/shared/chainSwitchFields.js(src/background/index.js -> src/shared/chainSwitchFields.js -> src/shared/state.js). The hole is the specifier syntax, not the graph walk — the same two shapes evade it at any hop, so a shared module the background already pulls in can re-export the singleton through a backtick require and stay green.Why it matters here specifically: the DoD of #324 is "enforced mechanically, not by review", and the whole argument for the weakened
StateNotLoadedErrorguard (Disclosure 1) is that its hole "is closed for the background by unreachability". An enforcement that a plainrequirewith the wrong quote character walks through is not the unreachability that argument rests on.Acceptable: widen the pattern to cover backticks and
import(, e.g./\b(?:require|import)\(\s*["'`]([^"'`]+)["'`]\s*\)/g, and add a rule test (or a lint-fixture check) that pins each of the four shapes — quoted require, backtick require,import(), and a two-hop re-export. Deriving the graph from esbuild's metafile instead of a regex would also close it and would match whatbuild.jsalready does forAUDITED_MODULE.Non-blocking
src/background/state.js:66—updateState()deadlocks on reentrancy: amutatethat itself callsupdateState()queues behind the turn that is awaiting it. Confirmed empirically (inner update never runs; the outer turn only completes because the probe raced it against a timeout). No current call site does this, and the header warnsmutate"must not do anything slow" but says nothing about calling back in. Worth one line in that comment.tests/deleteWalletLostPassword.test.js:102andtests/stateMerge.test.js:18each still carry a private hand-rolled cloningmakeStorage(), whiletests/support/storageStub.jsstates "nothing rebuilds a storage stub by hand". Both clone correctly, so nothing is hidden — it is a consistency point, not a defect.tests/audited" enumeration omitstests/approvalDisplayFloor.test.js. It is stateless (get: async () => ({})), so the conclusion is unaffected; the list is just short by one.Verified and holding
Sweep of all of
tests/found no storage stub the eight-file audit missed — the eight go throughtests/support/storageStub.js(whichstructuredClones onget,set,readandwrite),coldWorkerChainId/stateMerge/deleteWalletLostPasswordalready cloned, and every remaining stub is stateless. All three fail-first claims reproduce exactly againstsrc/reverted tonext(11155111nvs1n;{"txHash":"0xfeed"}vs the three-key "different network" refusal;"1.5"vs"0.0"). EverygetProvider()call site passes a loaded id; no default and no guess remains.updateState()serializes two concurrent updates, survives a throwing mutate without stalling the queue, and awaits an async mutate before writing. Disclosure 3 holds:pricesis written only byrefreshPrices(), called only fromsrc/popup/index.js, andclearPricesno longer appears in the background bundle at all. Disclosure 4 is two genuine fixture gaps, not a masked regression. Disclosure 2 holds —eth_chainIdis already offloadState()onnext, andwallet_switchEthereumChainexercises the same detach.make checkgreen in my own clone with[lint 1/1] RUN make lintexecuted rather thanCACHED; CI green on4343d8f; fast-forwardable ontonextat669c443; one commit, title ends(closes #324), body closes #320; base isnext; no Claude/Anthropic references or attribution trailers anywhere.On Disclosure 1: the stated hole is real, and the 103 failures are what the author says they are. I rebuilt the strict per-field guard and measured 94 failures across 8 suites on this tree — every one a popup unit test that hand-builds the singleton and then has a view read
state.networkIdthe fixture never set (e.g.tests/txStatus.test.jsviacurrentNetwork()→explorerUrl()→etherscanAddressUrl()). Fixture churn, not 94 production sites reading unloaded state; the popup callsloadState()at boot. The weakened form is acceptable on the merits — and note it is in fact stronger for the background than claimed, since nothing in the background ever assigns into the singleton, soadoptedstays false and a stray read would throw. That remains a second line of defence rather than the first, which is why the lint hole above is the blocking item.4343d8fc77to277ec8c8f8FAIL —
needs-rework. One blocking finding; everything else re-derived independently and holds.BLOCKING: a sixth site can still be created — two shapes evade the rule and land in the shipped worker bundle
Both measured in a clean clone at
277ec8c:make lintin the pinned container, thenmake buildand grep forStateNotLoadedError,was read before loadStateandidentity collision mergingindist/chrome/src/background/index.jsanddist/firefox/src/background/index.js(all three are 0 in both bundles on unmodified head).1. A comment inside the
require()call.script/lib/eslint/noStateSingletonInBackground.js:47—SPECIFIER_REallows only\s*between(and the opening quote (and between the closing quote and)), so any comment token breaks the match while esbuild resolves the call normally.src/background/index.js:globalThis.__probe = require(/* probe */ "../shared/state").state;make lint→ exit 0. Fullmake check→ exit 0 (45 suites, 861 tests, "All matched files use Prettier code style!"). The comment is prettier-stable —make fmtleaves it untouched — so nothing in the repo objects.make build→ all three markers = 1 in BOTH the Chrome and Firefox background bundles.require("../shared/state" /* p */)behaves identically: eslint reports nothing, prettier leaves it alone, singleton in the bundle.require("../shared/state",)(trailing comma) andrequire ("../shared/state")(space before the paren) also produce no eslint error; those two are normalized away by prettier, so they failmake fmt-checkrather than shipping — but they show the match is fragile in three independent places, not one.import(/* webpackChunkName: "…" */ "…")is the standard bundler-annotation idiom, and any inline/* eslint-… */inside the call has the same effect.2. The file resolution disagrees with esbuild, independently of the matcher.
resolveRelative()(lines 51-67) tries onlybase,base + ".js",base + ".json"andbase/index.js. esbuild resolves a directory through itspackage.jsonmain, so the walk stops at a specifier it matched perfectly well.src/shared/probepkg/package.json={"main": "./bridge.js"}andsrc/shared/probepkg/bridge.js=const { state } = require("../state");, then insrc/background/index.js:globalThis.__probe6 = require("../shared/probepkg").state;make lint→ exit 0, prettier clean.make build→StateNotLoadedError= 1 in both background bundles.Why this blocks: #324's DoD is "enforced mechanically, not by review", and the scoped loud-read guard is justified in the PR body by the background being unable to reach the module at all. Neither holds while an ordinary, prettier-stable construct walks through. Widening the regex again does not close it — the two failures are in different halves of the rule (textual matcher vs hand-written resolver), and each rework so far has closed one shape and left the next.
Acceptable: make the check authoritative rather than approximate — assert from esbuild's metafile that
src/shared/state.jsis not an input of the background bundles.build.jsalready does exactly this forAUDITED_MODULE(outputsContainingAuditedModule()), andDockerfile:42runsmake buildin CI, so the assertion would be enforced on every push. Keeping the lint rule as fast feedback is fine; the guarantee should not rest on the regex. Whatever is chosen, add both shapes above totests/backgroundStateLintRule.test.js, and correct the "covers every specifier syntax esbuild resolves statically" claim, which currently appears in the rule header, the test-file header, the commit message, the PR body andTODO.mdand is false as written.Re-derived, and holding
All four claimed shapes fire (exit 2, one error, chain named): quoted require, backtick require, dynamic
import(), and the two-hop backtick re-export throughsrc/shared/chainSwitchFields.js. With the old regex restored, exactly 5 of 8 rule tests fail — backtick, dynamic import, staticfrom, bare import, two-hop — as claimed. The negative case is not vacuous: pointingFORBIDDENatsrc/shared/networks.jsmakes "the repository's own background entrypoint" fail, and so does revertingsrc/tonext. All three fail-first claims reproduce exactly (11155111nvs1n; the "different network" refusal;"1.5"vs"0.0").make checkgreen in my own clone with[lint 1/1] RUN make lintexecuted uncached (DONE 4.9s, with output); CI green on277ec8c; fast-forwardable ontonextat669c443; one commit, title ends(closes #324), body closes #320; base isnext; no AI-vendor references or attribution trailers anywhere. Working tree and pushed branch are identical and carry no probe mutation, stray fixture or scratch file. Thetests/deleteWalletLostPassword.test.js/tests/stateMerge.test.jsreroute is mechanical (_raw()→ the stub'sread(), both clone on the way out); no assertion changed meaning;tests/coldWorkerChainId.test.jsclones both ways and does not drivestate.js, so leaving it is justified.updateState()'s queue is unchanged and the reentrancy deadlock is named in its header. The scoped guard reasoning still holds: nothing in the background assigns into the singleton, soadoptedstays false there.277ec8c8f8to480563cd16480563cd16tocf8cb248abFAIL —
needs-rework. Three defects plus one required test. The new mechanism itself held against every evasion I could construct; the findings are in its edges, its wording, and its own lack of coverage.1.
build.js:35-37— the anti-rot check covers only half the tableA
FORBIDDEN_INPUTSkey that no entry point matches fails the build, as claimed:src/background/renamed.jsgives exit 2,is listed in FORBIDDEN_INPUTS but was not bundled, so nothing checked it. A forbidden module that no longer exists does not. I changed the value alone tosrc/shared/stateRenamed.js:make buildexit 0, receipt written,verify-buildandcheck-censored --require-distpass, nothing said.So renaming or moving
src/shared/state.jssilently disarms the guarantee — and disarms the lint rule in the same moment, sincescript/lib/eslint/noStateSingletonInBackground.js:46is a second literal copy of the same path. No test pins either: the rule test's only repo-facing case is a negative one, which passes vacuously after a rename. #311 is sequenced next and rewrites this exact persistence layer, so this is not a hypothetical rot. The commit message's "A FORBIDDEN_INPUTS key that matches no bundled entry point also fails, so the table cannot rot into a vacuous pass" overstates: half the table can.Acceptable: assert that every module named in
FORBIDDEN_INPUTSwas bundled into at least one output of this build. The popup bundlessrc/shared/state.js, so that holds today and is stronger than anexistsSync— it fails both on a rename and on a module that stops being built at all.2. The "computed specifier" claim is false, in three places
script/lib/eslint/noStateSingletonInBackground.js:38-40, the header oftests/backgroundStateLintRule.test.js, and the commit message all sayrequire("../shared/" + "state")is deliberately unmatched because "esbuild cannot resolve that statically either, so it never reaches the bundle" / "there is nothing to block".Appended verbatim to
src/background/index.js,make buildis exit 2:dist/chrome/src/background/index.js bundles src/shared/state.js, which src/background/index.js must not reach: src/background/index.js -> src/shared/state.js. esbuild folds the concatenation. The variable form,await import("../shared/" + part), is resolved too — as a glob import — and is also caught, withimportChain()returning null so the message correctly degrades to no chain rather than crashing.The build catches both, which is the redesign working. But the stated reason for not matching them is wrong, and it is exactly the sentence a future reader would cite for not widening anything. Acceptable: "not matched by this rule; the build's metafile assertion catches it".
3. Three source comments still name the lint rule as the enforcement
The correction landed in the five places listed, but not in:
src/background/index.js:11-14— "is deliberately NOT imported here and must never be: see the header ofsrc/background/state.js, and the lint rule that enforces it ineslint.config.js."src/shared/state.js:12-17— "this module is unreachable from the background bundle (enforced by the ESLint rule ineslint.config.js, and by the background having its own per-call storage layer ...)".src/shared/state.js:60-61— "what closes it for the background is that the background cannot reach this module at all (eslint.config.js)."The third is load-bearing: it is the written justification for the scoped loud-read guard, and it points at the layer this PR just demoted to best-effort. Same class as #331 and #309. All three should name
build.js's assertion.4. The guarantee is unpinned, and must not stay that way
make checkdoes not runmake build, soassertNoForbiddenInputs()has zero coverage in it. CI executes it (Dockerfile:42, viascript/cibuild), but executing is not testing: invert the condition, or make theFORBIDDEN_INPUTS[entry]lookup always come back undefined, and every check in this repo stays green while the singleton walks back into the worker. That is precisely the failure modescript/test-verify-build's own header records four consecutive reviews of, and the reason that 46-case harness exists.The stated obstacles do not bind.
build.jsrunsbuild()at load — guarding that one call withrequire.main === moduleand exporting the helpers makes them testable with nodist/written and no build shelled out, against synthetic metafiles. Required assertions:inputsthrows, and the message names the import chain;importChain()terminates and returns a chain over a cyclicinputs[].importsgraph;FORBIDDEN_INPUTSkey that no bundled entry point matched fails;Non-blocking, for disclosure
An equivalent singleton that is copied rather than imported is invisible to both layers.
cp src/shared/state.js src/shared/stateCopy.jsplus a backgroundrequire:make buildexit 0,make lintexit 0, andgrep -c StateNotLoadedError= 1 in bothdist/chrome/src/background/index.jsanddist/firefox/src/background/index.js. This is inherent to a path-keyed assertion and no code change is asked for — for a literal copy the proxy guard still throws in the background (nothing there assigns, soadoptedstays false), but a newly written singleton carries no such backstop. It belongs in the PR body's disclosures.A symlink to the singleton is caught by the build (esbuild reports the real path) and missed by the rule (
make lintexit 0) — a ninth known matcher divergence, and fine under the new two-layer framing.FORBIDDEN_INPUTSprotects the one entry point it names; a second background-side entry point added later would be covered by the rule (globsrc/background/**) but not by the build. Low priority, noted.Verified and holding
All eight shapes re-derived, each applied alone and reverted: every one is
make buildexit 2 fromassertNoForbiddenInputs(), with the chain named through both the two-hop (index.js -> chainSwitchFields.js -> state.js) and thepackage.jsonmainhop (index.js -> probepkg/bridge.js -> state.js). Shape 5's honesty holds exactly —make lintreportsParsing error: 'import' and 'export' may appear only with 'sourceType: module', not the rule, and the build blocks it regardless. No false positive: unmodified branchmake buildexit 0,StateNotLoadedError= 0 in both background bundles and 1 in both popup bundles.277ec8cis no longer fetchable, so I reconstructed the round-2 matcher by hand (reverting the comment gap, thepackage.jsonmaincandidates and the trailing\s*\)); with it, exactly the 3 new rule cases fail, 908/911 — restored, 911/911. All three fail-first claims reproduce (11155111nvs1n; the "different network" refusal;"1.5"vs"0.0").getProvider()refuses an unknown network id loudly.tests/support/storageStub.jsclones both ways.make checkgreen in my own clone: 50 suites, 911 tests,test-verify-build46 cases,check-censored173 tracked files,[lint 1/1] RUN make lintexecuted uncached (DONE 5.4s, with its output), prettier clean. CI green oncf8cb24including both e2e jobs; fast-forwardable ontonextat36bc6be; one commit, title ends(closes #324), body closes #320; base isnext; no AI-vendor reference or attribution trailer anywhere; working tree and pushed branch identical, carrying no probe, fixture or scratch file.cf8cb248abto18ad93be45FAIL —
needs-rework. Four findings, all in the guarantee's own guard ratherthan in shipped behaviour: the assertion held against every evasion I could
build, and all three documented bounds are true as measured on this head.
1.
build.js:139— an entry is marked "checked" before it can be checked, and the throw that saves that is untestedrecord.entriesChecked.add(entry)runs immediately after the table lookup, fourlines before
inputsis resolved. IfassertNoForbiddenInputs()leaves earlyafter that point, the entry is still recorded as checked and
assertForbiddenTableCovered()passes — so the coverage half cannot detect acheck that bailed out. The only thing standing in that gap today is
build.js:145-147,esbuild reported no metafile output for ${out}, and thatbranch has ZERO test coverage.
Measured on this head: change those three lines to
if (!entryOutput) return;and nothing else —
make testis 919/919 passed, exit 0. In that state bothhalves of the guarantee are satisfied by an entry point whose bundle was never
examined, and
make buildexits 0.The lookup is not hypothetically fragile:
repoRelative()resolves a RELATIVEpath against
process.cwd(), and esbuild's metafile output keys arecwd-relative, so any change to where the build runs from, to
outfilevs
outdir, or to output naming makes it miss. Today that throws loudly, whichis correct — but nothing pins it, and the ordering means it is the last line of
defence rather than the second.
Acceptable: move
record.entriesChecked.add(entry)to afterinputsissuccessfully built, and add a case to
tests/buildForbiddenInputs.test.jsasserting the no-matching-output throw.
2.
script/lib/forbiddenBundleInputs.js:51— an empty module list is a third vacuous pass, and it disarms both layers at onceassertForbiddenTableCovered()fails a stale KEY and a stale MODULE, bothconfirmed. It does not fail an entry whose list is empty, and neither does the
rule:
FORBIDDENthere isObject.values(FORBIDDEN_INPUTS).flat(), so an emptyvalue leaves it with nothing to look for.
Measured,
"src/background/index.js": []as the only edit, plusglobalThis.__probeEmpty = require("../shared/state").state;appended tosrc/background/index.js:make lintexit 0make buildexit 0grep -c StateNotLoadedError= 1 in BOTHdist/chrome/src/background/index.jsanddist/firefox/src/background/index.jsNothing is reported anywhere. The singleton is back in the shipped worker with
every check in the repo green — the precise outcome the header at
script/lib/forbiddenBundleInputs.js:46-49says cannot happen ("Both halves arechecked for rot ... each fail the build rather than passing vacuously"). Fixing
it is one line: fail when a listed entry names no modules.
3.
TODO.md— "the two it is known to miss ... pinned bytests/backgroundStateLintRule.test.js" is falseThat file has eleven cases and none of them is the computed specifier or the
symlink; both appear only in its header comment, lines 23-26. Nothing asserts
that the rule does not report them, so nothing would notice if that changed. The
PR body's own wording for the same fact ("record ... as measured known
divergences") is accurate;
TODO.mdsayspinned byand is not. Third round ina row that a claim about what pins or enforces what has been wrong, which is why
it is itemized rather than waived.
4.
tests/buildForbiddenInputs.test.js—recordBundledInputs()is imported but never actually pinnedEvery
assertForbiddenTableCovered()case hand-seedsrecord.bundledInputs.add(STATE), and the one case that reads the function'soutput asserts
bundledInputs.has(STATE)is false — which is trivially trueif it records nothing. Measured: replace the body of
recordBundledInputs()with a no-op and
make testis 919/919, exit 0. That function is the soledata source for the module half of the anti-rot check added this round, so that
half rests on an unpinned helper. Its failure direction is fail-safe
(under-recording throws), so this is the smallest of the four — but one case
driving the real function into the covered check closes it.
Disclosure correction, no code change asked for
The COPY residual's stated reason is that "a copy carries the singleton's own
guard, so a background read of an unloaded field throws
StateNotLoadedError... loud". True, and I reproduced the whole bound (
make buildexit 0,make lintexit 0, marker = 1 in both background bundles). But it only coversdefects 1-3 of #324. A copy also
carries
loadState(), and defects 4 and 5 — astate.rpcUrlread severalawaits after a load, and a load detaching the objects
backgroundRefresh()ismutating — are silent over a LOADED singleton, copy or not. The accepted
residual is wider than the reason given for accepting it, and the sentence
should say so.
Related, and worth one line where the two residuals are recorded: they compound.
A second background entry point is invisible to the build (confirmed:
src/background/worker2.jsrequiring the singleton, named nowhere in the table,is
make buildexit 0 with the marker = 1 in the new bundle, caught only bymake lintexit 2). A second entry point that reaches the singleton by ashape the rule also misses — computed specifier, symlink — is green everywhere.
Verified on this head, holding
Guarantee not evaded by anything I tried: quoted require, computed
require("../shared/" + "state")(lint 0 / build 2), globimport("../shared/" + variable)(build 2, chain correctly degraded),symlink (lint 0 / build 2), stale MODULE (exit 2, named message), stale
KEY, a module path spelled
./src/shared/state.js(exit 2 via the modulehalf).
require.main === moduledid not change the build:make buildexit 0,receipt written (15 files, 4 audited),
verify-buildandcheck-censored --require-distpass,StateNotLoadedError0 in both backgroundbundles and 1 in both popup bundles. The new test drives the SHIPPED helpers
(
require("../build")); the fail-first claim reproduces exactly — the key-onlyform of
assertForbiddenTableCovered()gives 1 failed, 918 passed, thatcase, that message — and inverting the input condition, the table lookup, or the
key check each fail 4, 4 and 1 cases respectively. The three behavioural
fail-first claims reproduce against
src/atnext(11155111nvs1n;"1.5"vs"0.0"; the "different network" refusal), and the rule test'srepository case fails there too, so it is not vacuous.
make checkgreen in myown clone: 51 suites, 919 tests,
check-censored175 files,[lint 1/1] RUN make lintexecuted uncached (DONE 5.3s, with output),prettier clean. CI green on
18ad93b(check, e2e-chrome, e2e-firefox);fast-forwardable onto
next; one commit, title ends(closes #324), bodycloses #320; base is
next; noAI-vendor reference or attribution trailer anywhere; working tree and pushed
branch identical, no probe, symlink, fixture or scratch file left, no container
started that survives.
Note on evidence attribution:
cf8cb24is no longer fetchable, so the eightcarried-forward rows cannot be re-derived at that commit. Everything the body
marks "re-measured on this head" I reproduced, and four of the carried rows I
re-measured independently on this head also match, so the attribution reads as
honest.
18ad93be45toc8d758f4ebPASS. All five round-5 claims and all five documented bounds re-measured independently on
c8d758fin a fresh clone and correct;make checkgreen (51 suites, 930 tests,test-verify-build46 cases,check-censored175 tracked files,[lint 1/1] RUN make lintexecuted uncachedDONE 5.2swith its output, prettier clean); CI green on the head;nextat36bc6beis an ancestor; one commit, no AI-vendor reference or trailer;build.jsread in full is coherent after the disclosedgit checkoutmishap, and no probe artifact, symlink or scratch file survives anywhere.Two items, neither blocking.
esbuild.build()call rather than throughbuild.js'sbundle()helper never reachesassertNoForbiddenInputs(). Measured on this head (src/background/worker2.jsrequiring the singleton, bundled directly):make buildexit 2 — but fromscript/verify-build(carries a debug marker but the build did not record it as containing src/shared/constants.js), not from the forbidden-input assertion. Not a silent hole, and reliably not one:src/shared/state.jsreachessrc/shared/constants.jsthrough bothpersistedState.jsandlog.js, so any bundle containing the singleton carries the marker. Recorded as a bound, not a defect.tests/buildForbiddenInputs.test.js:210, "the prefix is the one the lint rule is scoped to", reads nothing fromeslint.config.js; it assertsBACKGROUND_ENTRY_PREFIX === "src/background/"against a literal. The underlying claim is true —eslint.config.js:13-14,106builds its glob from the imported constant, and a top-levelsrc/background/worker2.jsis reported bymake lint— and the assertion is loud in the direction that matters, since changing the constant fails the case. The case name promises more than it asserts.Reviewer disclosure: the fail-first reproduction (
"1.5"vs"0.0",11155111nvs1n, and the "different network" refusal — all three reproduce againstsrc/atnext) was run withyarn jeston two files rather than through amake/scriptentrypoint. Everything else, and every lint run, went through them.