---
phase: 04-harden-clean-up-optional
plan: 02
subsystem: build-hygiene
tags: [setimmediate-polyfill, csp-hardening, generate-icons-cjs, dead-code-grep, roadmap-sc-3, roadmap-sc-4, tdd, charter-d-p4-01, rollup-plugin]

# Dependency graph
requires:
  - phase: 01-stabilize-video-pipeline
    provides: tests/build/no-remote-fonts.test.ts scaffold (Plan 01-12 — Wave 0 RED unit test scaffold mirrored verbatim for the SW-chunk CSP grep) + the Plan 01-12 Wave 7 deferred-items.md disclosure entry (closure target)
  - phase: 04-harden-clean-up-optional
    provides: Plan 04-01 baseline (vitest 180/180 GREEN on the clean run; 04-01 closed at HEAD f72bca5)
provides:
  - SW chunk `new Function` literal eliminated end-to-end via 4-layered CSP-hardening mitigation (runtime prelude + plugin exclude + resolve.alias + Rollup post-transform)
  - tests/build/no-new-function-in-sw-chunk.test.ts — SW-chunk CSP grep build-gate (RED→GREEN flip in this plan; future regression pin)
  - tests/build/dead-code-grep.test.ts — ROADMAP SC #4 regression pin against `permissions.request` re-introduction in src/
  - src/shared/setimmediate-stub.ts — minimal queueMicrotask-based polyfill, the resolve.alias.setimmediate target
  - generate-icons.cjs — ESM/CJS disambiguation under package.json type:module (ROADMAP SC #3 GREEN)
  - .planning/phases/01-stabilize-video-pipeline/deferred-items.md closure-flip (Plan 01-12 Wave 7 setimmediate entry now CLOSED)
  - 2 new build-gate vitest tests + 1 new it() in the build-prep gate = +3 net new tests (180 → 183 GREEN on clean run)
affects: [04-03 (flake stabilization; deferred-items.md format precedent + the established Option β post-transform plugin pattern available for similar future excisions), 04-07 (Phase 4 closure aggregator — this plan closes ROADMAP SC #3 + SC #4 + the setimmediate hardening side-quest)]

# Tech tracking
tech-stack:
  added: []  # No new runtime/dev dependencies; pure config + source changes against existing stack
  patterns:
    - "Layered-mitigation pattern for transitive-bundled CSP-unsafe code: runtime guard (prelude installs safe global BEFORE consumer evaluates) + bundler exclude (eliminates plugin-injected redundant polyfill) + resolve.alias (catches bare-specifier requires) + Rollup post-transform (excises text literals from pre-bundled distributions that bypass the resolver). Established at vite.config.ts for setimmediate; reusable for any future `new Function`/`eval` literal that escapes plugin-level filtering."
    - "Build-gate vitest pattern (continued from Plan 01-12 tests/build/no-remote-fonts.test.ts): SKIP_BUILD=1 escape hatch + recursive walk + countOccurrencesInFile + describe-block-per-needle. Phase 4 narrowed scope: glob-filter `^index\\.ts-.*\\.js$` for the SW chunk only (BLOCKER 1 fix from plan-checker iter-1: the earlier `index*-bg.js` pattern matched nothing)."
    - "Pre-bundled-dependency interception strategy: when a node_module ships a pre-bundled distribution (browser-field-mapped) that contains its own internal module registry, Vite's resolve.alias cannot reach inside; the Rollup `generateBundle` post-transform hook is the canonical interception point (executes against final chunk text, after all other plugins)."

key-files:
  created:
    - "tests/build/no-new-function-in-sw-chunk.test.ts"
    - "tests/build/dead-code-grep.test.ts"
    - "src/shared/setimmediate-stub.ts"
  modified:
    - "vite.config.ts (4 edits: node:url import + Plugin type import; nodePolyfills.exclude:['setimmediate']; resolve.alias.setimmediate → src/shared/setimmediate-stub.ts; stripSetimmediateNewFunction() Rollup post-transform plugin definition + registration in plugins array)"
    - "src/background/index.ts (17-line top-of-module prelude inserted BEFORE the first import; queueMicrotask-based setimmediate polyfill with typed widening cast)"
    - ".planning/phases/01-stabilize-video-pipeline/deferred-items.md (closure-flip block appended at EOF; Plan 01-12 Wave 7 entry now marked Resolved in Phase 4 Plan 04-02 with full 4-mechanism mitigation documentation)"
  renamed:
    - "generate-icons.js → generate-icons.cjs (history preserved via git mv; no code change)"

key-decisions:
  - "Adopted Option β (Rollup `generateBundle` post-transform plugin that text-replaces `(I=new Function(\"\"+I))` → `(I=function(){})` in any chunk containing the JSZip-bundled setimmediate IIFE) AFTER empirically discovering that Option α (force JSZip's unbundled lib/index.js entry via resolve.alias.jszip) broke UAT harness A30+ assertions. Option α reverted in the same Task 2 work session before commit; the failure mode was JSZip's async-write pipeline (readable-stream-browser) not transitively wiring correctly through Vite's resolver when forced off the browser-field-mapped pre-bundled distribution."
  - "Layered the mitigation across four mechanisms (runtime prelude + plugin exclude + resolve.alias + post-transform) rather than relying on any single one. The runtime prelude alone makes the bundle CSP-safe AT RUNTIME (JSZip's setimmediate IIFE's `if(!s.setImmediate){...}` guard skips the offending body once globalThis.setImmediate is installed), but the static `new Function` literal would still be present in the bundle text — failing the build-gate test AND remaining a static-analysis red flag for future audits. The post-transform plugin closes that gap surgically."
  - "Used `fileURLToPath(new URL('./...', import.meta.url))` for the resolve.alias target path instead of a bare `'/src/...'` prefix or `path.resolve(__dirname, ...)` — the leading-slash form is interpreted as filesystem root by Vite's resolver (would fail in non-root cwds), while `__dirname` is undefined under vite.config.ts's ESM mode. The `import.meta.url` form is the canonical ESM idiom per Vite docs."
  - "Documented the Option α attempt + reversion verbatim in the Task 2 commit body so future Phase 4+ executors investigating similar transitive-polyfill issues understand WHY the unbundled-entry approach fails for JSZip specifically (browser-field readable-stream-browser dep chain breakage)."

patterns-established:
  - "Layered transitive-polyfill CSP-hardening: runtime guard + bundler exclude + bundler alias + Rollup post-transform — applied to setimmediate, generalizable to any future polyfill of this shape (string-coercion fallback in unreachable IIFE branch)."
  - "When a node_module ships a pre-bundled browser-field distribution that contains its own internal module registry (CJS-style numbered slot table), Vite's resolve.alias cannot intercept the internal requires. The Rollup `generateBundle` hook is the canonical post-processing interception point — runs against final chunk text after all bundler plugins have completed; safe for surgical literal replacement when the upstream IIFE is unreachable at runtime."
  - "Build-gate test glob convention for SW-chunk-only assertions: `dist/assets/index.ts-*.js` (matches both the SW entry chunk and the loader chunk; excludes welcome/offscreen/CSS/font chunks that may legitimately contain different code patterns)."

requirements-completed: []

# Metrics
duration: ~41 min
completed: 2026-05-21
---

# Phase 4 Plan 02: harden-clean-up-optional Summary

**Eliminated the SW chunk's `new Function` literal via a 4-layered mitigation (runtime queueMicrotask polyfill prelude + nodePolyfills exclude + resolve.alias stub + Rollup post-transform plugin), renamed `generate-icons.js` → `.cjs` for ESM/CJS disambiguation under package.json type:module, and pinned dead-code absence via regression-guard vitest — all under Plan 04-02's TDD-strict RED→GREEN contract.**

## Performance

- **Duration:** ~41 min (RED scaffold + GREEN initial Option a attempt + Option α empirical reversal + Option β implementation + UAT harness re-verification + SUMMARY)
- **Started:** 2026-05-21T12:36:43Z
- **Completed:** 2026-05-21T13:18:30Z
- **Tasks:** 2 (Wave 0 RED + Wave 1 GREEN per the plan's `tdd: true` frontmatter)
- **Files modified:** 5 (2 new build-gate tests + 1 new polyfill stub + vite.config.ts + src/background/index.ts + generate-icons rename + deferred-items.md closure-flip)

## Accomplishments

- **MV3 CSP-hardening Gate 2 polarity flipped** end-to-end: `grep -c 'new Function' dist/assets/index.ts-*.js` returns **0/0/0** (was 1 hit in `index.ts-8LkXuqac.js` pre-fix; documented since Plan 01-12 Wave 7).
- **ROADMAP SC #3 GREEN:** `node generate-icons.cjs` exits 0; old `generate-icons.js` no longer exists (renamed via `git mv` preserving history); no other references to the old `.js` path exist outside the `.planning/` audit trail.
- **ROADMAP SC #4 GREEN:** `permissions.request` regression-pinned absent from `src/` via `tests/build/dead-code-grep.test.ts` (GREEN-on-arrival; acts as future regression guard).
- **Plan 01-12 Wave 7 deferred-items entry CLOSED** end-to-end; `.planning/phases/01-stabilize-video-pipeline/deferred-items.md` appended with a multi-paragraph closure block documenting the 4-mechanism mitigation + the Option α reversal.
- **vitest baseline 180/180 → 183/183 GREEN on clean run** (+3 from this plan's 2 new test files; the 2 files contribute 3 it() blocks total — 1 build-prep gate + 1 grep gate in `no-new-function-in-sw-chunk.test.ts` + 1 grep gate in `dead-code-grep.test.ts`). Pre-existing intermittent flakes (`blob-url-download.test.ts` + `webm-remux.test.ts` + `webm-playback.test.ts`) per 04-01-SUMMARY Issues Encountered persist and are owned by Plan 04-03.
- **UAT harness 33/33 GREEN preserved** (REVISION iter-2 WARNING 1 empirical pin: `HEADLESS=1 SKIP_PROD_REBUILD=0 npm run test:uat` exit 0 with verbatim `UAT harness: 33/33 assertions passed` stdout). Confirms JSZip's full zip-assembly pipeline operates correctly under the new bundle — the setimmediate polyfill replacement is observably transparent at the empirical SAVE→zip layer end-to-end.
- **Pre-checkpoint bundle gates 5/5 PASS** (Tier-1 FORBIDDEN_HOOK_STRINGS 13/13 GREEN; SW CSP-safety grep now 0 hits — polarity flipped; Node-globals + DOM-globals unchanged; manifest.json valid).
- **tsc-clean preserved** (`npx tsc --noEmit` exits 0).

## Task Commits

Each task was committed atomically per the plan's TDD cycle:

1. **Task 1: Wave 0 RED — build-gate grep tests** — `630d40c` (test)
   - 2 new test files at `tests/build/`: 1 RED gate (`no-new-function-in-sw-chunk` — 1 occurrence of `new Function` in the SW chunk) + 1 GREEN-on-arrival regression pin (`dead-code-grep` — 0 occurrences of `permissions.request` in `src/`).
   - Acceptance: `grep -v '^//' tests/build/no-new-function-in-sw-chunk.test.ts | grep -c 'new Function'` returned 3 (≥2 required); `grep -v '^//' tests/build/dead-code-grep.test.ts | grep -c 'permissions.request'` returned 2 (≥2 required).
2. **Task 2: Wave 1 GREEN — setimmediate polyfill replaced + generate-icons.cjs + deferred-items closure** — `f251297` (feat)
   - 4 edits in vite.config.ts (node:url import + Plugin type; nodePolyfills.exclude; resolve.alias.setimmediate; Rollup post-transform plugin) + 17-LOC prelude in src/background/index.ts + new src/shared/setimmediate-stub.ts + `git mv generate-icons.js generate-icons.cjs` + deferred-items.md closure-flip block. The no-new-function RED gate from Task 1 flipped GREEN; all 6 plan-defined acceptance gates pass; UAT harness 33/33 GREEN preserved.

**Plan metadata commit:** to follow this SUMMARY landing.

## Files Created/Modified

- `tests/build/no-new-function-in-sw-chunk.test.ts` (NEW; 167 LOC) — Wave 0 RED build-gate grep test. Mirrors `tests/build/no-remote-fonts.test.ts` (Plan 01-12 analog). Narrows file walk to `dist/assets/index.ts-*.js` glob (BLOCKER 1 fix from plan-checker iter-1; the earlier `*-bg.js` pattern matched nothing). Includes glob-existence pre-gate (asserts ≥1 SW chunk match before the grep gate runs) so the grep can never silently no-op.
- `tests/build/dead-code-grep.test.ts` (NEW; 175 LOC) — Wave 0 GREEN-on-arrival regression pin for ROADMAP SC #4. Asserts `permissions.request` absence in `src/`; documents the offscreen-inline-string sub-test as delegated to `tests/build/no-remote-fonts.test.ts` (no single literal sentinel pinnable post-Plan-01-06 collapse).
- `src/shared/setimmediate-stub.ts` (NEW; 50 LOC) — minimal queueMicrotask-based polyfill installed as a side-effect import. The resolve.alias.setimmediate target. CSP-safe (contains NO `new Function`, NO `eval`).
- `vite.config.ts` (modified; 4 edits) — see "Task Commits" above for the diff anatomy.
- `src/background/index.ts` (modified; 17-LOC prelude inserted before first import) — typed widening cast for the polyfill assignment (no `as any` per CLAUDE.md naming guidance).
- `generate-icons.js → generate-icons.cjs` (renamed via `git mv`; 100% similarity preserved) — Node 14+ treats `.cjs` as CJS regardless of `package.json` "type":"module" per nodejs.org/api/packages.html#determining-module-system.
- `.planning/phases/01-stabilize-video-pipeline/deferred-items.md` (modified; ~45 LOC closure-flip block appended at EOF) — multi-paragraph "Resolved in Phase 4 Plan 04-02" block documenting the 4-mechanism mitigation + the Option α attempt-and-reversal.

## Decisions Made

**Option β over Option α (post-transform Rollup plugin over forcing JSZip's unbundled entry):** Option α was attempted first (force-redirect `import JSZip from 'jszip'` to `node_modules/jszip/lib/index.js` via `resolve.alias.jszip` so the internal `require("setimmediate")` chain passes through our `resolve.alias.setimmediate`). Empirically broke UAT harness A30+ assertions: the unbundled JSZip entry's transitive readable-stream-browser browser-field mapping did not propagate correctly through Vite's resolver, so JSZip's async zip-write pipeline silently produced an empty `events.json`. Option α was reverted in the same work session before commit. The Rollup `generateBundle` post-transform plugin (Option β) preserves JSZip's pre-bundled distribution verbatim (zip-write behavior unchanged) while excising the single offending text literal in any chunk that contains the JSZip-bundled setimmediate IIFE.

**4-layer defense-in-depth over a single mechanism:** The runtime prelude alone makes the SW chunk CSP-safe AT RUNTIME (JSZip's setimmediate IIFE's `if(!s.setImmediate){...}` guard skips the body once `globalThis.setImmediate` is installed by our prelude). But the static `new Function` literal remains in the bundle text, failing the Plan 04-02 build-gate test AND remaining a static-analysis red flag. Layering the post-transform plugin closes that gap surgically. The `nodePolyfills.exclude:['setimmediate']` + `resolve.alias.setimmediate → setimmediate-stub.ts` are belt-and-suspenders for any future direct `import 'setimmediate'` consumer that would bypass the JSZip path (would otherwise re-introduce the literal).

**`fileURLToPath(new URL('./...', import.meta.url))` over `path.resolve(__dirname, ...)` for resolve.alias paths:** `__dirname` is undefined under vite.config.ts's ESM mode (the project's `package.json` declares `"type": "module"` since Plan 01-06). The `import.meta.url`-based form is the canonical ESM idiom per Vite docs and matches the Node 20+ recommendation.

**`(I=function(){})` over `(I=()=>{})` for the post-transform replacement:** keeps the `function` keyword family of the surrounding pre-bundled CJS code (mangler-friendly; byte-parity within the noise envelope) and avoids introducing arrow-function syntax that JSZip's pre-bundled distribution doesn't otherwise use.

**Did NOT introduce a Vite plugin file at `tools/vite-plugins/`** — the `stripSetimmediateNewFunction()` function is defined inline in vite.config.ts with a multi-paragraph documentation block at the file top because (1) it's a single-purpose Plan 04-02-specific surgical fix, (2) the rationale needs to live with the configuration that activates it for future auditors, and (3) extracting it would create a new directory + import surface for a 30-LOC function. If a second similar surgical excision arises in a future plan, this is the canonical extract-to-module trigger.

## Deviations from Plan

### Auto-fixed Issues

**1. [Rule 3 - Blocking] vite-plugin-node-polyfills `exclude` insufficient for JSZip's direct setimmediate require**

- **Found during:** Task 2 (first `npm run build` + grep verification after the plan-specified Option (a) edits landed)
- **Issue:** The plan's RESEARCH.md Q1 analysis specified that `nodePolyfills({ exclude: ['setimmediate'] })` would drop the setimmediate polyfill from the SW chunk, with JSZip falling back to its own inline MessageChannel/postMessage/setTimeout polyfill. Empirically, the `new Function` literal STILL appeared in the SW chunk after the plan-specified edits because JSZip's `package.json` `"browser"` field maps `./lib/index` → `./dist/jszip.min.js` (a pre-bundled CJS distribution with its own internal module registry containing the setimmediate polyfill at slot 54). The plugin's `exclude` only filters node-stdlib-browser-aliased polyfills; it cannot reach into JSZip's pre-bundled distribution.
- **Fix:** Added two additional mitigation layers: (1) `resolve.alias.setimmediate` → `src/shared/setimmediate-stub.ts` (catches any bare-specifier `import 'setimmediate'` consumer); (2) `stripSetimmediateNewFunction()` Rollup `generateBundle` post-transform plugin (excises the single offending text literal from JSZip's pre-bundled chunk after Vite has rolled it in).
- **Files modified:** `vite.config.ts` (added Plugin type import + node:url imports + 3-section plugin definition with full rationale comment), `src/shared/setimmediate-stub.ts` (NEW)
- **Verification:** Post-fix `grep -c 'new Function' dist/assets/index.ts-*.js` returns 0/0/0 (was 1 in one of three chunks); `grep -c "I=function" dist/assets/index.ts-*.js` returns 1 in the SW chunk (the replacement landed exactly once); the Task 1 RED test flipped GREEN.
- **Committed in:** `f251297` (Task 2 commit; deviation absorbed into the Wave 1 GREEN landing because the fix completes the same charter — eliminating the SW-chunk `new Function` literal — as the plan-specified edits).

**2. [Rule 4-adjacent ARCHITECTURAL but handled inline] Option α attempt + reversal (force JSZip unbundled `lib/index.js`)**

- **Found during:** Task 2 (after the Rule 3 fix above attempted via Option α first — force JSZip's unbundled entry via `resolve.alias.jszip`)
- **Issue:** Option α was attempted as the first interception strategy for JSZip's pre-bundled setimmediate slot (rationale: forcing the unbundled `lib/index.js` entry would route every internal require through Vite's resolver, at which point the existing `resolve.alias.setimmediate` would intercept JSZip's `lib/utils.js:7` `require("setimmediate")` and substitute the CSP-safe stub). UAT harness regression: A30+ assertions failed with `userEvents.length=0` in the produced zip despite the content-script-side events firing correctly and the SW logging `✓ Received 4 rrweb events, 5 user events` + `✓ Added user events: 5 events, 1199 bytes`. Root cause: JSZip's unbundled `lib/index.js` entry's transitive `readable-stream` → `readable-stream-browser` browser-field mapping did not propagate correctly through Vite's resolver, breaking JSZip's async zip-write pipeline at the StreamHelper layer.
- **Fix:** Reverted the `resolve.alias.jszip` entry; pivoted to the post-transform plugin (Option β) which preserves JSZip's pre-bundled distribution verbatim while excising the offending text literal post-bundle.
- **Files modified:** `vite.config.ts` (reverted the jszip alias addition; added the stripSetimmediateNewFunction plugin instead)
- **Verification:** Post-pivot UAT harness ran end-to-end at 33/33 GREEN (`grep -c 'UAT harness: 33/33 assertions passed' /tmp/04-02-uat-2.log` returned 1) — JSZip's zip-write pipeline restored verbatim.
- **Committed in:** `f251297` (Task 2 commit; both the Rule 3 fix above AND this Rule 4-adjacent pivot landed in the same commit because they're parts of the same coherent multi-mechanism landing per RESEARCH Q1 acceptance "must land coherently in the same plan task").
- **Decision rationale documented inline:** the Task 2 commit body has a dedicated "Architecture decision log" paragraph explaining the Option α attempt + empirical regression + Option β pivot in full so future Phase 4+ executors investigating similar transitive-polyfill issues have the prior-art breadcrumb.

---

**Total deviations:** 2 auto-fixed (1 blocking, 1 Rule 4-adjacent handled inline as part of the same Wave 1 coherent landing).
**Impact on plan:** Zero scope creep. The plan's must_have #1 (SW chunk `new Function` count flipped 1→0) is the same charter; the deviations were about HOW to achieve it (1 mechanism became 4 layered mechanisms; 1 attempted approach was empirically falsified and replaced inline). The plan's must_have #2 (JSZip MessageChannel/postMessage/setTimeout fallback chain handles JSZip's needs cleanly post-polyfill) is functionally preserved end-to-end — verified by UAT harness 33/33 GREEN — though architecturally the fallback chain is never actually engaged at runtime (our prelude pre-seeds `globalThis.setImmediate`, so JSZip's setimmediate IIFE skips entirely; the fallback chain is the bundle-time dead-code branch that gets excised by the post-transform plugin).

## Issues Encountered

**Puppeteer Chrome binary missing at first UAT harness invocation:** `HEADLESS=1 SKIP_PROD_REBUILD=0 npm run test:uat` on the first attempt errored with `Could not find Chrome (ver. 148.0.7778.167)`. Resolved via `npx puppeteer browsers install chrome` (one-time install; cached under `/home/parf/.cache/puppeteer`). Not a regression; a fresh-environment side-effect of the Phase 4 first-time UAT invocation on this machine. Plan 04-01 had not exercised the UAT harness (its work was content-script unit tests + build-gate tests only); Plan 04-02 is the first Phase 4 plan to require Puppeteer Chrome empirically per the REVISION iter-2 WARNING 1 contract.

**Pre-existing vitest flakes** — same 3 documented in 04-01-SUMMARY "Issues Encountered" (`tests/background/blob-url-download.test.ts` 5000ms timeout race, `tests/background/webm-remux.test.ts` ffprobe frame count, `tests/offscreen/webm-playback.test.ts` ffmpeg dry-run). Intermittent across baseline runs; characterized as "1-3 of these 3 fail per run; the same 3 are owned by Plan 04-03". Plan 04-02 introduces no new flakes — verified by a clean 183/183 GREEN run.

## Pre-Checkpoint Bundle Gates

Per saved memory `feedback-pre-checkpoint-bundle-gates.md` (5/5 standard inventory):

1. **Tier-1 FORBIDDEN_HOOK_STRINGS** — `tests/background/no-test-hooks-in-prod-bundle.test.ts` 13/13 GREEN; inventory unchanged at 12 strings (Plan 04-02 added no harness hooks; pure source-side polyfill + config polish).
2. **SW CSP-safety grep** — `grep -E 'new Function|\beval\(' dist/assets/index.ts-*.js` returns **0 hits across all three SW chunks** (loader-D5qBgxJ_.js + D0uUn23q.js + DfBxWCT9.js). **Polarity flipped from 1 documented exception (Plan 01-12 Wave 7 disclosure) to 0 hits** — the Plan 04-02 closure charter is empirically discharged.
3. **Node-globals grep** — `Buffer.copy / .isView / .length / .push / .shift / .slice / .write` in SW chunk — all from JSZip internals; unchanged from 04-01-SUMMARY Bundle Gate 3.
4. **DOM-globals grep** — `document.createElement / .createTextNode / .documentElement / .F` + `window.Math / .console / .localStorage / .process` in SW chunk — pre-existing shimmed-DOM references inside JSZip's text encoder fallback paths; unchanged from 04-01-SUMMARY Bundle Gate 4.
5. **manifest.json** — present at `dist/manifest.json`; `manifest_version: 3`; `name: "__MSG_extName__"` (chrome.i18n message resolution intact). Plan 04-02 did NOT touch `_locales/` so en↔ru parity is untouched.

## Empirical UAT Harness Pin (REVISION iter-2 WARNING 1)

```
$ HEADLESS=1 SKIP_PROD_REBUILD=0 npm run test:uat 2>&1 | tail -3
  [PASS] A32
========================================================================
UAT harness: 33/33 assertions passed

$ grep -c 'UAT harness: 33/33 assertions passed' /tmp/04-02-uat-2.log
1
```

JSZip's full zip-assembly pipeline (A24-A32 inclusive — exercising MediaRecorder segments via base64 port wire + remux + zip assembly + chrome.downloads + events.json + meta.json + screenshot) operates correctly under the new bundle. The setimmediate polyfill replacement is observably transparent at the empirical SAVE→zip layer.

## Self-Check

- **Files created:**
  - tests/build/no-new-function-in-sw-chunk.test.ts → FOUND (verified via `test -f`)
  - tests/build/dead-code-grep.test.ts → FOUND
  - src/shared/setimmediate-stub.ts → FOUND
- **Files modified:**
  - vite.config.ts → FOUND (diff shows 4 edits per "Files Created/Modified" above)
  - src/background/index.ts → FOUND (17-LOC prelude before first import)
  - .planning/phases/01-stabilize-video-pipeline/deferred-items.md → FOUND (closure block appended)
- **Files renamed:**
  - generate-icons.js → generate-icons.cjs → FOUND (`test ! -e generate-icons.js && test -f generate-icons.cjs` both pass)
- **Commits:**
  - 630d40c → FOUND (test(04-02): Wave 0 RED)
  - f251297 → FOUND (feat(04-02): Wave 1 GREEN)
- **Verification commands all green:**
  - `npm run build` → exit 0
  - `npx tsc --noEmit` → exit 0
  - `grep -c 'new Function' dist/assets/index.ts-*.js` → 0/0/0 (was 1)
  - `grep -rn 'permissions.request' src/` → exit 1 (no matches; correct)
  - `node generate-icons.cjs` → exit 0
  - `npm test -- tests/build/no-new-function-in-sw-chunk.test.ts tests/build/dead-code-grep.test.ts --run` → 3/3 GREEN
  - `npm test -- --run` (clean run) → 183/183 GREEN
  - `HEADLESS=1 SKIP_PROD_REBUILD=0 npm run test:uat` → 33/33 GREEN; `grep -c 'UAT harness: 33/33 assertions passed' /tmp/04-02-uat-2.log` returns 1

## Self-Check: PASSED

## Next Phase Readiness

- Plan 04-03 (flake stabilization: A29 cs-injection-world rewrite + parallel-vitest race + 2 ffprobe/ffmpeg flakes — `blob-url-download` + `webm-remux` + `webm-playback`) is **unblocked**. The pre-existing flakes persist as the same 3 intermittent items documented in 04-01-SUMMARY Issues Encountered. Plan 04-02 introduces zero new flakes.
- Plan 04-02 closes Plan 01-12 Wave 7's setimmediate disclosure end-to-end (`.planning/phases/01-stabilize-video-pipeline/deferred-items.md` now has a multi-paragraph "Resolved in Phase 4 Plan 04-02" closure block).
- ROADMAP success criteria status update: SC #3 (generate-icons ESM/CJS) — **GREEN closed**; SC #4 (dead-code grep `permissions.request`) — **GREEN regression-pinned**. SC #1 + SC #2 still owned by future Phase 4 plans (04-03 / 04-04 per the CONTEXT.md suggested grouping).
- The layered transitive-polyfill CSP-hardening pattern (runtime guard + bundler exclude + bundler alias + Rollup post-transform) is now an **established Phase 4 pattern**, available for future plans that encounter similar pre-bundled-distribution interception challenges. The pattern's rationale + Option α/β trade-off is documented inline in vite.config.ts AND in the Plan 01-12 deferred-items.md closure block.

---
*Phase: 04-harden-clean-up-optional*
*Completed: 2026-05-21*
