docs(04-02): complete harden-clean-up-optional plan 04-02 — build hygiene

Plan 04-02 closes three independent build-hygiene fixes consolidated into
one plan because they share the build-gate-grep test-scaffold pattern:

1. **setimmediate polyfill replacement** — layered 4-mechanism CSP-hardening
   eliminates the `new Function` literal from the SW chunk (grep -c flips
   1→0 across all three SW chunks). Runtime guard + nodePolyfills exclude
   + resolve.alias + Rollup post-transform plugin. Option α (force JSZip
   unbundled lib/index.js) attempted + reverted because it broke
   readable-stream-browser propagation causing UAT A30+ regressions;
   Option β (post-transform plugin) preserves JSZip's pre-bundled
   distribution verbatim while excising the offending literal.

2. **ROADMAP SC #3** (generate-icons ESM/CJS) — `git mv generate-icons.js
   generate-icons.cjs` resolves the `require('fs')` under
   `package.json type: module` via Node's `.cjs`-as-CJS rule.

3. **ROADMAP SC #4** (dead-code grep) — `tests/build/dead-code-grep.test.ts`
   regression-pins `permissions.request` absence in `src/`.

Plus closure of Plan 01-12 Wave 7's setimmediate deferred-items entry.

Task commits:
  - 630d40c test(04-02): Wave 0 RED — no-new-function + dead-code-grep
  - f251297 feat(04-02): Wave 1 GREEN — setimmediate replacement + CJS rename + closure

Verification:
  - vitest 180/180 → 183/183 GREEN on clean run (+3 net new tests)
  - UAT harness 33/33 GREEN preserved (REVISION iter-2 WARNING 1 empirical pin)
  - Pre-checkpoint bundle gates 5/5 PASS; SW CSP-safety polarity flipped 1→0
  - tsc-clean preserved; npm run build exit 0; node generate-icons.cjs exit 0

STATE.md: Plan 3/7 (Plan 04-02 complete); 25/30 total plans; 83% progress.
ROADMAP.md: Phase 4 progress 2/7 plans complete (04-01 + 04-02).
deferred-items.md: Plan 01-12 Wave 7 setimmediate entry CLOSED end-to-end.

SUMMARY at `.planning/phases/04-harden-clean-up-optional/04-02-SUMMARY.md`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-21 15:41:54 +02:00
parent f251297256
commit 6a1fc32826
3 changed files with 227 additions and 12 deletions

View File

@@ -264,7 +264,7 @@ finalized at plan time):
**Plans**: 7 plans (04-01 through 04-07). Wave 1 parallel (04-01 + 04-02) -> Wave 2 sequential (04-03 A29 rewrite -> 04-04 A33 SW persistence -> 04-05 A34 fetch+XHR) -> Wave 5 visual polish (04-06; operator empirical) -> Wave 6 closure (04-07). **Plans**: 7 plans (04-01 through 04-07). Wave 1 parallel (04-01 + 04-02) -> Wave 2 sequential (04-03 A29 rewrite -> 04-04 A33 SW persistence -> 04-05 A34 fetch+XHR) -> Wave 5 visual polish (04-06; operator empirical) -> Wave 6 closure (04-07).
- [x] 04-01-PLAN.md — Audit P1 polish #11 + #14 + #15 (TDD; 3 unit tests + 3 src/content/index.ts edits) - [x] 04-01-PLAN.md — Audit P1 polish #11 + #14 + #15 (TDD; 3 unit tests + 3 src/content/index.ts edits)
- [ ] 04-02-PLAN.md — Build/CSP hygiene (setimmediate polyfill replacement + dead-code grep + generate-icons.cjs rename) - [x] 04-02-PLAN.md — Build/CSP hygiene (setimmediate polyfill replacement + dead-code grep + generate-icons.cjs rename)
- [ ] 04-03-PLAN.md — A29 cs-injection-world rewrite (strict-sentinel filter; closes ~1/3 flake) - [ ] 04-03-PLAN.md — A29 cs-injection-world rewrite (strict-sentinel filter; closes ~1/3 flake)
- [ ] 04-04-PLAN.md — A33 SW state persistence (spike-first; 5-min idle + worker.close() CDP; ROADMAP SC #1) - [ ] 04-04-PLAN.md — A33 SW state persistence (spike-first; 5-min idle + worker.close() CDP; ROADMAP SC #1)
- [ ] 04-05-PLAN.md — A34 fetch + XHR network_error empirical (ROADMAP SC #2; validates Plan 04-01 P1 #11 end-to-end) - [ ] 04-05-PLAN.md — A34 fetch + XHR network_error empirical (ROADMAP SC #2; validates Plan 04-01 P1 #11 end-to-end)
@@ -281,4 +281,4 @@ Phases execute in numeric order: 1 → 2 → 3 → 4 → 5.
| 1. Stabilize video pipeline | 14/14 | **CLOSED 2026-05-20** via gsd-verifier audit GREEN (17/17 must-haves; commit 586836f); all markers flipped | Functional contract closed 2026-05-19 via Plan 01-13 harness PASS; design/brand contract closed 2026-05-20 via Plan 01-12 brand-fit ack; welcome-tab contract closed 2026-05-20 via Plan 01-10 cycle-2 operator ack "All good" + 5 inter-cycle debug fixes | | 1. Stabilize video pipeline | 14/14 | **CLOSED 2026-05-20** via gsd-verifier audit GREEN (17/17 must-haves; commit 586836f); all markers flipped | Functional contract closed 2026-05-19 via Plan 01-13 harness PASS; design/brand contract closed 2026-05-20 via Plan 01-12 brand-fit ack; welcome-tab contract closed 2026-05-20 via Plan 01-10 cycle-2 operator ack "All good" + 5 inter-cycle debug fixes |
| 2. Stabilize export pipeline | 0/4 | Plans landed 2026-05-20 (4 plans: Wave 0 RED → Wave 1 Blob URL + meta.urls parallel → Wave 2 harness + operator checkpoint); execution pending | - | | 2. Stabilize export pipeline | 0/4 | Plans landed 2026-05-20 (4 plans: Wave 0 RED → Wave 1 Blob URL + meta.urls parallel → Wave 2 harness + operator checkpoint); execution pending | - |
| 3. SPEC §10 smoke + DOM/event-log verification | 0/TBD | Not started (absorbed Phase-2 DOM verification per 2026-05-20 re-phasing; ~2-3 plans) | - | | 3. SPEC §10 smoke + DOM/event-log verification | 0/TBD | Not started (absorbed Phase-2 DOM verification per 2026-05-20 re-phasing; ~2-3 plans) | - |
| 4. Harden + clean up (optional) | 0/7 | Plans landed 2026-05-21 (7 plans; 6 waves: W1 parallel pair + W2/3/4 sequential harness chain + W5 visual polish + W6 closure); execution pending | - | | 4. Harden + clean up (optional) | 2/7 | In Progress| |

View File

@@ -3,15 +3,15 @@ gsd_state_version: 1.0
milestone: v2.0.0 milestone: v2.0.0
milestone_name: milestone milestone_name: milestone
status: executing status: executing
stopped_at: /gsd-resume-work — user selected Full Phase 4 (/gsd-execute-phase 4); HANDOFF.json consumed + deleted per workflow; ready for /clear then execute kickoff stopped_at: Completed 04-02-PLAN.md (setimmediate polyfill replaced via layered 4-mechanism mitigation; SW new Function polarity 1→0; UAT 33/33 GREEN preserved)
last_updated: "2026-05-21T12:31:09.937Z" last_updated: "2026-05-21T13:36:35.894Z"
last_activity: 2026-05-21 last_activity: 2026-05-21
progress: progress:
total_phases: 4 total_phases: 4
completed_phases: 3 completed_phases: 3
total_plans: 30 total_plans: 30
completed_plans: 24 completed_plans: 25
percent: 80 percent: 83
--- ---
# Project State # Project State
@@ -29,11 +29,11 @@ no server, no password leaks.
Phase: 04 (harden-clean-up-optional) — EXECUTING Phase: 04 (harden-clean-up-optional) — EXECUTING
Phase 4 of 4 (Hardening — optional) — Plan 04-01 closed (audit P1 polish 3/3); 6 plans remain (04-02 build hygiene queued NEXT in Wave 1) Phase 4 of 4 (Hardening — optional) — Plan 04-01 closed (audit P1 polish 3/3); 6 plans remain (04-02 build hygiene queued NEXT in Wave 1)
Plan: 2 of 7 Plan: 3 of 7
Status: Executing Phase 04 — Plan 04-01 closed Status: Ready to execute
Last activity: 2026-05-21 -- Plan 04-01 closed (audit P1 #11/#14/#15 polish; 9 new tests; vitest 171 -> 180/180 GREEN) Last activity: 2026-05-21
Progress: [████████░░] 80% Progress: [████████░░] 83%
### Plan 01-10 closure (2026-05-20) ### Plan 01-10 closure (2026-05-20)
@@ -148,6 +148,7 @@ Progress: [████████░░] 80%
| Phase 01 P12 | ~10h cumulative (7 waves; 10 plan tasks + 1 operator empirical checkpoint) | 10 tasks (7 waves + Wave 7 pre-checkpoint + brand-fit ack) | ~50+ files (8 WOFF2 + 3 PNG + 2 _locales + tokens.css + 6 unit-test files + harness + scripts + 4 source files modified) | | Phase 01 P12 | ~10h cumulative (7 waves; 10 plan tasks + 1 operator empirical checkpoint) | 10 tasks (7 waves + Wave 7 pre-checkpoint + brand-fit ack) | ~50+ files (8 WOFF2 + 3 PNG + 2 _locales + tokens.css + 6 unit-test files + harness + scripts + 4 source files modified) |
| Phase 01 P10 | ~5h cumulative (4 waves; 5 plan tasks + 5 inter-cycle debug sessions + cycle-2 follow-up brand-rename ack) | 5 tasks (Wave 0 RED + Wave 1 bundle + Wave 2 SW wiring + Wave 3 harness + Wave 4 operator UAT cycle-2) | 14 files (4 new src/welcome/* + globals.d.ts + 2 unit-test files + 3 harness files + src/background/index.ts + manifest + 2 Vite configs + closure-cycle debug touches: _locales + README + package.json + onstartup-notification.test.ts + onboarding-tests + manifest-i18n.test.ts) | | Phase 01 P10 | ~5h cumulative (4 waves; 5 plan tasks + 5 inter-cycle debug sessions + cycle-2 follow-up brand-rename ack) | 5 tasks (Wave 0 RED + Wave 1 bundle + Wave 2 SW wiring + Wave 3 harness + Wave 4 operator UAT cycle-2) | 14 files (4 new src/welcome/* + globals.d.ts + 2 unit-test files + 3 harness files + src/background/index.ts + manifest + 2 Vite configs + closure-cycle debug touches: _locales + README + package.json + onstartup-notification.test.ts + onboarding-tests + manifest-i18n.test.ts) |
| Phase 04 P01 | 30m | 2 tasks | 5 files | | Phase 04 P01 | 30m | 2 tasks | 5 files |
| Phase 04 P02 | 41min | 2 tasks | 5 files |
## Accumulated Context ## Accumulated Context
@@ -196,6 +197,8 @@ current work:
- [Phase 01-10]: Three-pipeline DOM population pattern established for src/welcome/welcome.ts: populateMark() walks [data-mokosh-slot='mark'] (canonical SVG via Vite ?url import); populateCopy() walks [data-mokosh-key] (textContent from in-file COPY map for non-tagline strings); populateI18n() walks [data-mokosh-i18n-key] (textContent from chrome.i18n.getMessage with `|| <en-const>` fallback for the D-08 tagline strings). Init order populateMark → populateCopy → populateI18n. Filter-pipeline form throughout (no continue per project style). data-mokosh-slot wrapper attribute preserved as design-swap landmark for forward-compat. - [Phase 01-10]: Three-pipeline DOM population pattern established for src/welcome/welcome.ts: populateMark() walks [data-mokosh-slot='mark'] (canonical SVG via Vite ?url import); populateCopy() walks [data-mokosh-key] (textContent from in-file COPY map for non-tagline strings); populateI18n() walks [data-mokosh-i18n-key] (textContent from chrome.i18n.getMessage with `|| <en-const>` fallback for the D-08 tagline strings). Init order populateMark → populateCopy → populateI18n. Filter-pipeline form throughout (no continue per project style). data-mokosh-slot wrapper attribute preserved as design-swap landmark for forward-compat.
- [Phase 01-10]: Closure-cycle debug commit `a2dfc8c` removed pre-D-01 dead code from startVideoCapture (chrome.tabs.query({active:true}) + throw 'No active tab found') — the legacy block was load-bearing in the chrome.tabCapture era but functionally dead post-D-01 (getDisplayMedia whole-desktop in offscreen has no tab dependency). The bug surfaced via the notifications.onClicked path after the new CTA copy in 4bba679 explicitly invited the click. captureScreenshot() + saveArchive() retain their own genuine tab queries (out of scope for surgical fix). +3 RED→GREEN tests at start-video-capture-no-tab.test.ts pinning the new no-active-tab contract. - [Phase 01-10]: Closure-cycle debug commit `a2dfc8c` removed pre-D-01 dead code from startVideoCapture (chrome.tabs.query({active:true}) + throw 'No active tab found') — the legacy block was load-bearing in the chrome.tabCapture era but functionally dead post-D-01 (getDisplayMedia whole-desktop in offscreen has no tab dependency). The bug surfaced via the notifications.onClicked path after the new CTA copy in 4bba679 explicitly invited the click. captureScreenshot() + saveArchive() retain their own genuine tab queries (out of scope for surgical fix). +3 RED→GREEN tests at start-video-capture-no-tab.test.ts pinning the new no-active-tab contract.
- [Phase ?]: [Phase 04-01]: Audit P1 polish landed end-to-end via TDD pair (3dbc51c RED + 7da30af GREEN). Three surgical edits in src/content/index.ts: (1) module-level let previousUrl tracker initialized at module load with typeof-window node-env guard, swapped-and-emitted in handleNavigation so meta.previousUrl carries the operator's actual prior URL (was always 'unknown'); (2) instanceof Request type-narrow inlined at both fetch-wrapper sites (ok-branch line ~190 + catch-branch line ~210), replacing args[0]?.toString() that resolved to literal '[object Request]' for fetch(new Request(url)); (3) event.timestamp = Date.now() prepended in rrweb record() emit callback at line 315, normalizing rrweb-internal page-load-relative timestamps to Unix-epoch ms so cleanupOldEvents (now - event.timestamp) arithmetic at line 33 is meaningful. 9 new vitest tests under tests/content/ (NEW directory) pin all three contracts; baseline 171 -> 180/180 GREEN; tsc-clean preserved; Tier-1 FORBIDDEN_HOOK_STRINGS inventory unchanged at 12. Audit P1 polish backlog CLOSED 3/3. - [Phase ?]: [Phase 04-01]: Audit P1 polish landed end-to-end via TDD pair (3dbc51c RED + 7da30af GREEN). Three surgical edits in src/content/index.ts: (1) module-level let previousUrl tracker initialized at module load with typeof-window node-env guard, swapped-and-emitted in handleNavigation so meta.previousUrl carries the operator's actual prior URL (was always 'unknown'); (2) instanceof Request type-narrow inlined at both fetch-wrapper sites (ok-branch line ~190 + catch-branch line ~210), replacing args[0]?.toString() that resolved to literal '[object Request]' for fetch(new Request(url)); (3) event.timestamp = Date.now() prepended in rrweb record() emit callback at line 315, normalizing rrweb-internal page-load-relative timestamps to Unix-epoch ms so cleanupOldEvents (now - event.timestamp) arithmetic at line 33 is meaningful. 9 new vitest tests under tests/content/ (NEW directory) pin all three contracts; baseline 171 -> 180/180 GREEN; tsc-clean preserved; Tier-1 FORBIDDEN_HOOK_STRINGS inventory unchanged at 12. Audit P1 polish backlog CLOSED 3/3.
- [Phase ?]: [Phase 04-02]: Layered 4-mechanism CSP-hardening for transitive-polyfill pre-bundled-distribution interception (runtime queueMicrotask polyfill prelude + nodePolyfills exclude + resolve.alias.setimmediate + stripSetimmediateNewFunction Rollup post-transform plugin). Option α (force JSZip unbundled lib/index.js) attempted + reverted because it broke readable-stream-browser browser-field propagation causing UAT A30+ regressions. Option β preserves JSZip pre-bundled distribution verbatim while excising the offending literal post-bundle.
- [Phase ?]: [Phase 04-02]: ROADMAP SC #3 (generate-icons ESM/CJS) closed via git mv generate-icons.js generate-icons.cjs — Node 14+ treats .cjs as CJS regardless of package.json type:module per nodejs.org/api/packages.html#determining-module-system. No code change. ROADMAP SC #4 (dead-code grep permissions.request) GREEN regression-pinned via tests/build/dead-code-grep.test.ts. Plan 01-12 Wave 7 setimmediate deferred-items entry CLOSED end-to-end. SW chunk new Function count polarity flipped 1 → 0. UAT 33/33 GREEN preserved.
### Pending Todos ### Pending Todos
@@ -218,8 +221,8 @@ Items acknowledged and carried forward from previous milestone close:
## Session Continuity ## Session Continuity
Last session: 2026-05-21T12:29:05.588Z Last session: 2026-05-21T13:36:35.869Z
Stopped at: /gsd-resume-work — user selected Full Phase 4 (/gsd-execute-phase 4); HANDOFF.json consumed + deleted per workflow; ready for /clear then execute kickoff Stopped at: Completed 04-02-PLAN.md (setimmediate polyfill replaced via layered 4-mechanism mitigation; SW new Function polarity 1→0; UAT 33/33 GREEN preserved)
Resume file: None Resume file: None
Prior session: 2026-05-21T08:22:59.958Z — /gsd-pause-work saved Phase 4 execution-ready handoff (dbcf482); Phase 4 plans validated iter-2 PASSED + 3 cosmetic advisories fixed Prior session: 2026-05-21T08:22:59.958Z — /gsd-pause-work saved Phase 4 execution-ready handoff (dbcf482); Phase 4 plans validated iter-2 PASSED + 3 cosmetic advisories fixed

View File

@@ -0,0 +1,212 @@
---
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*