Files
mokosh/.planning/phases/04-harden-clean-up-optional/04-02-PLAN.md
Mark 526ac78046 docs(04): create phase plan — 7 plans for Phase 4 hardening (audit P1 polish + flake stabilization + SW persistence + visual polish + closure)
Wave structure:
- W1 (parallel): 04-01 (Audit P1 polish #11/#14/#15 TDD) + 04-02 (build/CSP hygiene: setimmediate polyfill + dead-code + generate-icons.cjs)
- W2: 04-03 (A29 cs-injection-world rewrite; closes flake)
- W3: 04-04 (A33 SW state persistence; spike-first + CDP worker.close())
- W4: 04-05 (A34 fetch+XHR network_error; ROADMAP SC #2 + validates Plan 04-01 P1 #11 end-to-end)
- W5: 04-06 (dark-logo currentColor + cursor verification + 01-07-SUMMARY back-patch; operator empirical)
- W6: 04-07 (04-VERIFICATION.md aggregator + ROADMAP backfill + v1 close prep)

Honors locked decisions D-P4-01..05 (full Phase 4 + all 3 P1 polish + both visual items + alpha-independent + ROADMAP backfill).
Implements RESEARCH Q1 (setimmediate option a), Q2 (spike-first SW persistence), Q3 (A29 cs-injection-world), Finding 4 (cursor already shipped — verification only).
UI-SPEC dark-logo currentColor strategy with inline-SVG injection landed per UI-SPEC §"Implementation amendment".

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 09:30:49 +02:00

24 KiB

phase, slug, plan, type, wave, depends_on, files_modified, autonomous, requirements, tags, user_setup, must_haves
phase slug plan type wave depends_on files_modified autonomous requirements tags user_setup must_haves
04 harden-clean-up-optional 02 tdd 1
vite.config.ts
src/background/index.ts
generate-icons.js
generate-icons.cjs
tests/build/no-new-function-in-sw-chunk.test.ts
tests/build/dead-code-grep.test.ts
.planning/phases/01-stabilize-video-pipeline/deferred-items.md
true
build-hygiene
csp-hardening
setimmediate-polyfill
dead-code-grep
generate-icons-cjs
roadmap-sc-3
roadmap-sc-4
tdd
charter-d-p4-01
truths artifacts key_links
grep -c 'new Function' dist/assets/index.ts-*.js returns 0 (was 1 — the setimmediate polyfill literal)
JSZip's inline polyfill chain (MessageChannel/postMessage/setTimeout) handles setImmediate fallback cleanly in the SW chunk after the dependency-side polyfill is excluded
ripgrep 'permissions.request' src/ returns 0 hits (was removed in Phase 1 Plan 01-05; regression-pinned)
node generate-icons.cjs exits 0 under package.json type: module (CJS-explicit via .cjs extension)
vitest baseline +2 (no-new-function-in-sw-chunk + dead-code-grep tests) GREEN
Pre-checkpoint bundle Gate 2 polarity flipped (1 hit → 0 hits for 'new Function' in dist/assets/index.ts-*.js)
path provides contains min_lines
tests/build/no-new-function-in-sw-chunk.test.ts Wave 0 RED — grep gate pinning 0 hits of 'new Function' in SW chunk after Plan 04-02 polyfill replacement new Function 50
path provides contains min_lines
tests/build/dead-code-grep.test.ts Wave 0 GREEN-on-arrival — regression pin for ROADMAP SC #4 (permissions.request removed in Plan 01-05) permissions.request 40
path provides contains
vite.config.ts nodePolyfills config with exclude: ['setimmediate'] added exclude: ['setimmediate']
path provides contains
src/background/index.ts Top-of-module queueMicrotask-based setImmediate polyfill prelude (4 LOC; inserted BEFORE first import) queueMicrotask
path provides contains
generate-icons.cjs Renamed from generate-icons.js to disambiguate CJS under package.json type: module require
path provides contains
.planning/phases/01-stabilize-video-pipeline/deferred-items.md Appended closure-flip block — 'Resolved in Phase 4 Plan 04-02' (referenced from existing Plan 01-12 Wave 7 disclosure block) Resolved in Phase 4 Plan 04-02
from to via pattern
vite.config.ts nodePolyfills exclude: ['setimmediate'] dist/assets/index.ts-*.js (SW chunk grep) vite build pipeline excludes setimmediate transitive dep exclude: ['setimmediate']
from to via pattern
src/background/index.ts top-of-module setImmediate prelude JSZip + transitive deps that call setImmediate(fn) globalThis.setImmediate = (fn, ...args) => queueMicrotask(() => fn(...args)) queueMicrotask(() => fn(...args))
from to via pattern
tests/build/no-new-function-in-sw-chunk.test.ts dist/assets/index.ts-<hash>.js (post-build grep) execFile npm run build + readFileSync + countOccurrences FORBIDDEN_SW_CSP_PATTERNS
Three independent build-hygiene fixes consolidated into one plan because they share the test-scaffold pattern (build-gate grep tests) AND none touch harness or content-script files (zero conflict with Plan 04-01):
  1. setimmediate polyfill replacement (RESEARCH Q1, Option a): Drop new Function from the SW chunk by adding exclude: ['setimmediate'] to vite-plugin-node-polyfills config + a 4-LOC queueMicrotask-based inline polyfill prelude at the top of src/background/index.ts. The plugin's transitive setimmediate package ships a CSP-unsafe new Function(string) fallback for the never-called setImmediate(string) form; JSZip (the only legitimate consumer in our bundle) falls back to its own inline MessageChannel/postMessage/setTimeout polyfill chain when globalThis.setImmediate is unset, so the explicit fast-path is a strict superset of behavior.

  2. Dead-code grep (ROADMAP SC #4): permissions.request was removed in Phase 1 Plan 01-05; pin the absence via a new vitest grep test so a future regression breaks the build.

  3. generate-icons ESM/CJS (ROADMAP SC #3): package.json declares "type": "module", so .js files are parsed as ESM, but generate-icons.js uses require('fs') (CJS). Rename generate-icons.jsgenerate-icons.cjs (Node treats .cjs as CJS regardless of the enclosing type per Node packages docs). Single-file rename; no code change.

TDD-strict: Tests 1 + 2 are Wave 0 RED-by-build-state (test 1 is RED until the polyfill landed; test 2 is GREEN-on-arrival since permissions.request was already removed in Phase 1). Test 1 flips RED→GREEN after the polyfill replacement; test 2 acts as regression pin. The setimmediate fix has both vite.config.ts config change AND src/background/index.ts prelude change — they must land coherently in one commit.

Purpose: Each item closes an audit residual without touching app behavior. (1) tightens MV3 CSP posture by removing the static-analysis red flag in production code. (2) prevents accidental re-introduction of the dead permissions.request API call. (3) makes node generate-icons.cjs work under the project's ESM-by-default module setting.

Output: 2 new test files at tests/build/; 1-line addition to vite.config.ts; 8-line prelude at top of src/background/index.ts; 1 file rename generate-icons.jsgenerate-icons.cjs; 1-block append to deferred-items.md flipping the Plan 01-12 Wave 7 setimmediate disclosure to "Resolved in Phase 4 Plan 04-02".

<execution_context> @$HOME/.claude/get-shit-done/workflows/execute-plan.md @$HOME/.claude/get-shit-done/templates/summary.md </execution_context>

@.planning/PROJECT.md @.planning/ROADMAP.md @.planning/STATE.md @.planning/phases/04-harden-clean-up-optional/04-CONTEXT.md @.planning/phases/04-harden-clean-up-optional/04-RESEARCH.md @.planning/phases/04-harden-clean-up-optional/04-PATTERNS.md @.planning/phases/01-stabilize-video-pipeline/deferred-items.md

Source files — locus of the build-hygiene edits

@vite.config.ts @src/background/index.ts @generate-icons.js @package.json

Analog test scaffold to mirror — build-gate grep test pattern

@tests/build/no-remote-fonts.test.ts

From vite.config.ts (current nodePolyfills config — lines 14-22):

nodePolyfills({
  include: ['buffer'],
  globals: {
    Buffer: true,
    global: false,
    process: false,
  },
  protocolImports: false,
}),

After Phase 4 Plan 04-02 (1-line addition):

nodePolyfills({
  include: ['buffer'],
  exclude: ['setimmediate'],   // Plan 04-02 CSP hardening — drops `new Function` from SW chunk
  globals: {
    Buffer: true,
    global: false,
    process: false,
  },
  protocolImports: false,
}),

From src/background/index.ts:1 (current top — first line is the Logger import):

import { Logger } from '../shared/logger';
import { base64ToBlob, blobToBase64 } from '../shared/binary';
// ... long import block

After Phase 4 Plan 04-02 (8-line prelude INSERTED BEFORE first import):

// Plan 04-02 CSP hardening — replace vite-plugin-node-polyfills' setimmediate
// polyfill (which includes a CSP-unsafe `new Function(string)` fallback for
// string-form setImmediate calls that this codebase never uses). JSZip falls
// back to its inline polyfill chain (MessageChannel / postMessage / setTimeout)
// when globalThis.setImmediate is unset; we provide the safest fast-path
// explicitly. Reversible by `git revert`.
//
// Reference: RESEARCH §"Code Examples" Pattern 2; Q1 finding.
if (typeof globalThis.setImmediate === 'undefined') {
  (globalThis as { setImmediate?: (fn: (...args: unknown[]) => void, ...args: unknown[]) => void }).setImmediate =
    (fn, ...args) => queueMicrotask(() => fn(...args));
}

import { Logger } from '../shared/logger';
// ... rest of existing imports unchanged

From generate-icons.js:1 (current — CJS smoking gun):

const fs = require('fs');

After Phase 4 Plan 04-02:

  • File renamed via git mv generate-icons.js generate-icons.cjs (no code change).
  • Node 14+ treats .cjs as CJS regardless of enclosing "type": "module" per https://nodejs.org/api/packages.html#determining-module-system
  • Verify no scripts in package.json reference the old .js name; verify no docs reference the old .js name (rg 'generate-icons\\.js' . before the rename).

From tests/build/no-remote-fonts.test.ts (existing test scaffold — full 145 lines):

  • Imports: execFile + existsSync + readFileSync + readdirSync + statSync + resolve + promisify + vitest
  • Pattern: build → recursive walk → countOccurrences → describe block with one it() per forbidden string
  • runProductionBuild(): execFileAsync('npm', ['run', 'build'], { timeout: 90_000 })
  • Skip gate: if (process.env.SKIP_BUILD !== '1') { await runProductionBuild(); } — re-uses existing built dist/ when SKIP_BUILD=1 (developer-velocity escape hatch)
  • The Plan 04-02 RED test inherits the same scaffold; SCOPES the walk to dist/assets/ filtered by ^index\\.ts-.*\\.js$ regex (the SW chunk only).
Task 1: Wave 0 RED — build-gate grep tests (no-new-function-in-sw-chunk + dead-code-grep) tests/build/no-new-function-in-sw-chunk.test.ts, tests/build/dead-code-grep.test.ts tests/build/no-remote-fonts.test.ts, .planning/phases/04-harden-clean-up-optional/04-PATTERNS.md (sections "tests/build/no-new-function-in-sw-chunk.test.ts" + "tests/build/dead-code-grep.test.ts") no-new-function-in-sw-chunk.test.ts (1 build-prep test + 1 grep test): - it: 'npm run build completes and dist/assets/ exists' → SKIP_BUILD=1 escape hatch + production-build invocation - it: 'dist/assets/index.ts-*.js does not contain "new Function" (Plan 04-02 CSP hardening — Q1 finding)' → RED today (existing setimmediate polyfill literal); GREEN after Task 2 lands the polyfill replacement.
dead-code-grep.test.ts (1 multi-pattern grep test):
- it: 'src/ does not contain "permissions.request" (removed Phase 1 Plan 01-05)' → GREEN-on-arrival; acts as regression pin.
- it: 'vite.config.ts does not contain the offscreen inline-string sentinel (removed Phase 1 Plan 01-06)' → if the planner can pin a specific sentinel from pre-01-06 vite.config.ts (e.g., a unique HTML literal the inline plugin emitted), include this; if not pinnable to a single string, drop this sub-test and document in the plan SUMMARY that ROADMAP SC #4's vite.config.ts-side audit is regression-pinned at the offscreen-related Vite plugin layer indirectly via `tests/build/no-remote-fonts.test.ts` (which audits the full dist/ for `googleapis` etc. — empirically covers the inline-string removal).
1. Read `tests/build/no-remote-fonts.test.ts` (~145 lines) once. Extract the imports + helper signatures + describe-block scaffold. 2. Create `tests/build/no-new-function-in-sw-chunk.test.ts`: - Mirror the imports verbatim (execFile + node:fs + node:path + node:util + vitest). - Define `const FORBIDDEN_SW_CSP_PATTERNS: ReadonlyArray = ['new Function'];` - Define `const DIST_ASSETS_DIR = resolvePath(process.cwd(), 'dist', 'assets');` - Define a helper `listSwChunkFiles(): ReadonlyArray` that returns `readdirSync(DIST_ASSETS_DIR)` filtered by `/^index\.ts-.*\.js$/`. The SW entry is bundled to a hashed file matching this regex (verify post-build: `ls dist/assets/index.ts-*.js`). - Define `countOccurrencesInFile` helper identical to the analog. - `describe('production SW chunk has no `new Function` literal (MV3 CSP hardening — Plan 04-02 / RESEARCH Q1)', ...)` with one it() for the build-prep gate + one it() per needle in FORBIDDEN_SW_CSP_PATTERNS. - Set SKIP_BUILD env-gate per the analog.
3. Create `tests/build/dead-code-grep.test.ts`:
   - Mirror imports + helpers (NO build invocation — this test reads `src/` and `vite.config.ts` directly).
   - Define `const FORBIDDEN_DEAD_CODE: ReadonlyArray<{ readonly needle: string; readonly searchPaths: ReadonlyArray<string>; readonly rationale: string }> = [...]` with the entries from 04-PATTERNS.md section.
   - One it() per entry; each runs `rg <needle> <paths>` (via execFile spawn of `rg` OR a recursive readdir+filter in pure Node — analog uses pure Node which is preferred for portability).
   - The `permissions.request` test is GREEN today (regression pin). If the planner empirically determines a vite.config.ts offscreen-inline-string sentinel CAN be pinned, add the second sub-test; otherwise document the rationale in the SUMMARY and skip.

Filter-pipeline form (no for-of with continue) per CLAUDE.md. Absolute imports.

RED gate: `npm test -- tests/build/no-new-function-in-sw-chunk.test.ts tests/build/dead-code-grep.test.ts --run` — the dead-code-grep test is GREEN; the no-new-function test is RED ('new Function' count = 1 in current dist/).
npm run build && npm test -- tests/build/no-new-function-in-sw-chunk.test.ts tests/build/dead-code-grep.test.ts --run 2>&1 | tee /tmp/04-02-task-1.log; grep -cE 'FAIL|✗' /tmp/04-02-task-1.log; grep -cE 'PASS|✓' /tmp/04-02-task-1.log - File `tests/build/no-new-function-in-sw-chunk.test.ts` exists with ≥ 2 it() blocks (1 build-prep + 1+ grep). - File `tests/build/dead-code-grep.test.ts` exists with ≥ 1 it() block. - Running both files: `no-new-function-in-sw-chunk.test.ts` has at least 1 RED (the 'new Function' grep); `dead-code-grep.test.ts` is fully GREEN. - `grep -v '^#' tests/build/no-new-function-in-sw-chunk.test.ts | grep -c 'new Function'` returns ≥ 2 (1 in FORBIDDEN array + 1 in the it() description). - `grep -v '^#' tests/build/dead-code-grep.test.ts | grep -c 'permissions.request'` returns ≥ 2 (1 in FORBIDDEN_DEAD_CODE array + 1 in the it() description). 2 build-gate test files committed; 1 RED + 1 GREEN-on-arrival. Atomic commit: `test(04-02): Wave 0 — no-new-function-in-sw-chunk RED + dead-code-grep regression pin`. Task 2: Wave 1 GREEN — setimmediate polyfill replacement (vite.config.ts + src/background/index.ts prelude) + generate-icons.js → .cjs rename + deferred-items.md flip vite.config.ts, src/background/index.ts, generate-icons.js, generate-icons.cjs, .planning/phases/01-stabilize-video-pipeline/deferred-items.md vite.config.ts, src/background/index.ts (top-of-module — lines 1-30), generate-icons.js, .planning/phases/01-stabilize-video-pipeline/deferred-items.md, package.json (scripts section + type field) All 3 changes land in one commit per "8-line config change must land coherently with the SW-entry prelude" rule (RESEARCH Q1 acceptance gate). Post-fix: - `npm run build` exit 0 - `grep -c 'new Function' dist/assets/index.ts-*.js` returns 0 (was 1) - `npm test -- tests/build/no-new-function-in-sw-chunk.test.ts --run` GREEN (the RED from Task 1 flips) - `node generate-icons.cjs` exit 0 - `node generate-icons.js` exit 1 (the file no longer exists; this is the desired state — verifies the rename worked) - UAT harness 33/33 GREEN preserved (JSZip falls back to inline polyfill chain cleanly — RESEARCH Q1 Assumption A2) - vitest 171→179 (per Plan 04-01) → 181 GREEN (+2 from this plan's Task 1) Edit 1 — vite.config.ts (1-line addition): - Read full file (~75 lines). - Use Edit tool with surrounding context to insert ` exclude: ['setimmediate'], // Plan 04-02 CSP hardening — drops `new Function` from SW chunk` between the existing `include: ['buffer'],` line and the `globals: {` block. Preserve indentation (4 spaces for the array entry).
Edit 2 — src/background/index.ts top-of-module prelude:
- Read lines 1-30 (the import block top).
- Use Edit tool to insert the 11-line prelude (comment block + if-guard + assignment) BEFORE the existing `import { Logger } from '../shared/logger';` line. Match the exact prose from the `<interfaces>` block above (the executor can copy-paste verbatim).
- Verify the typed widening cast uses inline interface intersection (no `as any`) per CLAUDE.md.
- Run `npx tsc --noEmit` to confirm tsc-clean.

Edit 3 — generate-icons.js → generate-icons.cjs:
- First grep for references: `rg 'generate-icons\\.js' . --files-with-matches` to enumerate any package.json scripts, README mentions, CLAUDE.md mentions, etc.
- `git mv generate-icons.js generate-icons.cjs` (preserves git history).
- For each reference found by the grep, use Edit tool to replace `generate-icons.js` → `generate-icons.cjs` in that file (likely package.json scripts; possibly README).
- Verify: `node generate-icons.cjs` exits 0 (no Node ERR_REQUIRE_ESM error — that error was the smoking gun this rename fixes).

Edit 4 — deferred-items.md flip:
- Read full file (~42 lines).
- Append a new bullet at the end of the existing Plan 01-12 Wave 7 entry (after line 42), citing the Plan 04-02 commit hash (placeholder `<HASH>` — replace post-commit; the SUMMARY task will substitute the real hash). Use the exact prose from 04-PATTERNS.md section "deferred-items.md flip".

Coherence check: all 4 edits + the rename land in ONE commit per RESEARCH Q1 acceptance ("must land coherently in the same plan task"). If TypeScript errors surface during Edit 2, fix the typed cast iteratively, but do NOT split into a separate commit. Pre-checkpoint bundle Gate 2 polarity flip (from 1 → 0 hits of `new Function` in `dist/assets/index.ts-*.js`) is the post-Edit-1+2 invariant; the bundle-gate test from Task 1 verifies it.

Run the focused test: `npm test -- tests/build/no-new-function-in-sw-chunk.test.ts tests/build/dead-code-grep.test.ts --run` — expect 100% GREEN. Run the full vitest: `npm test -- --run` — expect 181 GREEN (or post-Plan-04-01 baseline + 2). Run the UAT harness: `HEADLESS=1 SKIP_PROD_REBUILD=0 npm run test:uat` — expect 33/33 GREEN (the SKIP_PROD_REBUILD=0 forces a rebuild against the new vite config + prelude so the UAT runs against the post-fix bundle).
npm run build && grep -c 'new Function' dist/assets/index.ts-*.js | head -1; node generate-icons.cjs; npm test -- tests/build/no-new-function-in-sw-chunk.test.ts tests/build/dead-code-grep.test.ts --run; npx tsc --noEmit - `npm run build` exits 0; `grep -c 'new Function' dist/assets/index.ts-*.js` returns 0 (was 1). - `node generate-icons.cjs` exits 0; `test ! -e generate-icons.js` (the old file no longer exists; rename via git mv preserves history). - `npm test -- tests/build/ --run` exits 0; all Plan 04-02 Task 1 tests GREEN (the no-new-function RED from Task 1 flipped GREEN). - `npx tsc --noEmit` exits 0 (the typed widening cast at the prelude is tsc-clean). - `grep -c "exclude: \\['setimmediate'\\]" vite.config.ts` returns 1. - `grep -c "queueMicrotask" src/background/index.ts | head -1` returns ≥ 1 (the polyfill assignment). - `grep -c "Resolved in Phase 4 Plan 04-02" .planning/phases/01-stabilize-video-pipeline/deferred-items.md` returns ≥ 1. - Full vitest passes: `npm test -- --run` exits 0 (Plan 04-01 baseline +2 from this plan = ≥ 181 GREEN). - UAT harness 33/33 GREEN preserved: `HEADLESS=1 SKIP_PROD_REBUILD=0 npm run test:uat` exits 0 (verifies JSZip fallback works correctly post-polyfill-removal). Polyfill replacement landed; SW chunk grep flipped 1 → 0; generate-icons CJS-renamed; deferred-items.md closure-flipped. Atomic commit: `feat(04-02): Wave 1 — setimmediate polyfill replaced + generate-icons.cjs + deferred-items closure`.

<threat_model>

Trust Boundaries

Boundary Description
Vite build pipeline → SW chunk → MV3 CSP The SW chunk is loaded under MV3's script-src 'self' CSP; new Function(string) literals are a static-analysis red flag for future tighter CSP (MV3 default does allow it but auditors will flag)
JSZip dep → setImmediate global → SW realm JSZip calls setImmediate(fn) (function form only — never string form); the polyfill chain has to provide a callable setImmediate; if the global is undefined, JSZip falls back to its own MessageChannel/postMessage/setTimeout polyfill (verified RESEARCH Q1)

STRIDE Threat Register

Threat ID Category Component Disposition Mitigation Plan
T-04-02-01 Elevation of Privilege SW chunk new Function(string) literal — a static-analysis red flag for tighter future CSP and a security-audit eyebrow-raiser even under current MV3 CSP defaults mitigate Replace transitive setimmediate polyfill with explicit queueMicrotask-based polyfill in SW entry; verifiable by grep against built dist/. Reversible by git revert.
T-04-02-02 DoS (functional) JSZip relies on setImmediate to yield between zip-entry writes; if our explicit polyfill (fn, ...args) => queueMicrotask(() => fn(...args)) is incompatible with JSZip's internal use, the zip-assembly could starve or deadlock accept (verified by UAT) The polyfill matches JSZip's signature expectation (function + variadic args); UAT harness 33/33 GREEN under the new bundle confirms behavior preserved (A24 specifically tests SAVE→zip; A28/A29/A30/A31 all exercise the zip-assembly path empirically)
T-04-02-03 Information Disclosure leftover permissions.request literal in src/ could give a future audit the impression the codebase still relies on the deleted permission flow, masking the post-01-05 architecture mitigate (regression pin) dead-code-grep.test.ts pins absence — re-introduction breaks CI
T-04-02-04 Tampering .cjs extension override applies only to the renamed file; future contributors writing .js files under the same project type:module would re-introduce the ESM/CJS error accept Convention is documented in the SUMMARY; CI catches future require( in .js files via npx tsc --noEmit at build time + the explicit node generate-icons.cjs invocation in any future ROADMAP backfill
</threat_model>
- `npm run build` exits 0 with no new warnings. - `grep -c 'new Function' dist/assets/index.ts-*.js` returns 0 (was 1; Gate 2 polarity flipped). - `grep -c 'permissions.request' src/` returns 0 (regression pin). - `node generate-icons.cjs` exits 0. - vitest baseline +2 to ≥ 181 (Plan 04-01 baseline + 2 new build-gate tests). - tsc-clean. - UAT harness 33/33 GREEN preserved (validates JSZip fallback under the new polyfill regime). - Pre-checkpoint bundle Gate 2 (SW CSP-safety: `grep -rn "new Function\\|eval(" dist/assets/`) returns 0 hits (was 1 documented exception).

<success_criteria>

  • 2 new Wave 0 test files committed (Task 1).
  • vite.config.ts + src/background/index.ts + generate-icons rename + deferred-items.md flipped in one Wave 1 commit (Task 2).
  • ROADMAP SC #3 (generate-icons ESM/CJS) GREEN — node generate-icons.cjs exits 0.
  • ROADMAP SC #4 (dead-code grep) GREEN — regression pinned in vitest.
  • setimmediate polyfill replacement GREEN — new Function count in SW chunk = 0.
  • UAT harness 33/33 GREEN preserved.
  • Pre-checkpoint bundle Gate 2 polarity flipped (1 → 0). </success_criteria>
After completion, create `.planning/phases/04-harden-clean-up-optional/04-02-SUMMARY.md` capturing: - 2 new build-gate test files added (Task 1) - vite.config.ts diff (1-line exclude addition) - src/background/index.ts diff (11-line prelude) - generate-icons.js → .cjs rename + any package.json/README updates - deferred-items.md closure-flip block - Pre-fix vs post-fix `grep -c 'new Function' dist/assets/index.ts-*.js` (1 → 0) - vitest baseline before/after - UAT harness GREEN preservation evidence - RED→GREEN flip for the no-new-function test (Task 1 RED → Task 2 GREEN) - Commit refs (Task 1 + Task 2)