# Console JS Bundle Optimization Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Reduce the shipped console bundle from 228 KiB to ≤ 110 KiB by introducing dependency-graph-driven concatenation, esbuild whitespace+syntax minification, an external source map, and a sidecar build-meta JSON, while preserving every asset-contract test by reading unbundled sources instead of the minified bundle.

**Architecture:** Replace the hand-curated `sources` array in `scripts/build-console-assets.mjs` with a topological sort over `// @requires` directives in each source file. Pipe the concatenated output through `esbuild` with `minifyWhitespace + minifySyntax` (identifier minification disabled). Move `ConsoleBuildEpochMs` / `ConsoleBuildGitSha` out of the JS bundle into `console-build-meta.json`, inlined by `FeedbackConsoleAssets.kt` into `window.FixThisConsoleConfig.buildMeta`. Add a contract-symbol guard and a 110 KiB size budget guard to the build script.

**Tech Stack:** Node 20+ for the build script; `esbuild` (new dev-dependency, exact-pinned); Kotlin/JVM 21 for the Kotlin asset server change; existing Gradle/JUnit + `node --test` test runners.

---

## File Structure

Create these new files:

- `scripts/build-console-assets-test.mjs` — Node tests for the build script.
- `fixthis-mcp/src/main/resources/console/console-build-meta.json` — generated at build time.
- (after Task 5) `fixthis-mcp/src/main/resources/console/app.js.map` — generated.

Modify these existing files:

- `package.json` — add `esbuild` dev-dependency and `console:build:test` script.
- `package-lock.json` — regenerated by `npm install`.
- `scripts/build-console-assets.mjs` — rewrite for graph-driven, minified, sidecar output.
- `fixthis-mcp/src/main/console/*.js` (all 27) — add `// @requires` header.
- `fixthis-mcp/src/main/console/state.js` — remove `ConsoleBuildEpochMs` / `ConsoleBuildGitSha` references; read from `FixThisConsoleConfig.buildMeta` instead.
- `fixthis-mcp/src/main/kotlin/io/github/beyondwin/fixthis/mcp/console/FeedbackConsoleAssets.kt` — read `console-build-meta.json` and inline as `window.FixThisConsoleConfig.buildMeta`.
- `fixthis-mcp/src/test/kotlin/io/github/beyondwin/fixthis/mcp/console/ConsoleAssetContractTest.kt` (post-Item-3) — switch from bundled `app.js` to unbundled `src/main/console/*.js` reads.
- `CONTRIBUTING.md` — note size budget and source-map dev affordance.
- `docs/reference/feedback-console-contract.md` — note sidecar + minification.

## Conventions

- TDD: every behavior change has a failing Node `node:test` first.
- The legacy `scripts/build-console-assets.mjs` is rewritten in-place, not replaced with a new file, so contributors' muscle memory (`node scripts/build-console-assets.mjs`) still works.
- The `--check` flag verifies **all three** generated artifacts: `app.js`, `app.js.map`, `console-build-meta.json`.
- esbuild is exact-pinned in `package.json` (no `^`), so reproducible builds.
- Commit after each task.

---

### Task 1: Add esbuild Dev-Dependency and Empty Test Harness

**Files:**
- Modify: `package.json`
- Create: `scripts/build-console-assets-test.mjs`
- Modify: `package-lock.json` (regenerated)

- [ ] **Step 1: Write the failing test harness**

Create `scripts/build-console-assets-test.mjs`:

```js
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { execFileSync } from 'node:child_process';
import { existsSync, readFileSync } from 'node:fs';
import { gzipSync } from 'node:zlib';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';

const root = resolve(dirname(fileURLToPath(import.meta.url)), '..');
const script = resolve(root, 'scripts/build-console-assets.mjs');
const targetJs = resolve(root, 'fixthis-mcp/src/main/resources/console/app.js');
const targetMeta = resolve(root, 'fixthis-mcp/src/main/resources/console/console-build-meta.json');

const RAW_BUDGET = 110 * 1024;   // 112,640 bytes
const GZIP_BUDGET = 40 * 1024;   // 40,960 bytes

test('build script runs without arguments and produces app.js', () => {
  execFileSync('node', [script], { cwd: root, stdio: 'pipe' });
  assert.ok(existsSync(targetJs), 'app.js was not produced');
});

test('build script produces console-build-meta.json sidecar', () => {
  execFileSync('node', [script], { cwd: root, stdio: 'pipe' });
  assert.ok(existsSync(targetMeta), 'console-build-meta.json was not produced');
  const meta = JSON.parse(readFileSync(targetMeta, 'utf8'));
  assert.equal(typeof meta.buildEpochMs, 'number');
  assert.equal(typeof meta.gitSha, 'string');
});

test('app.js raw bytes are within the 110 KiB budget', () => {
  execFileSync('node', [script], { cwd: root, stdio: 'pipe' });
  const bytes = readFileSync(targetJs).byteLength;
  assert.ok(
    bytes <= RAW_BUDGET,
    `app.js is ${bytes} bytes, exceeds raw budget ${RAW_BUDGET} bytes (110 KiB)`,
  );
});

test('app.js gzipped bytes are within the 40 KiB budget', () => {
  execFileSync('node', [script], { cwd: root, stdio: 'pipe' });
  const raw = readFileSync(targetJs);
  const gz = gzipSync(raw, { level: 9 }).byteLength;
  assert.ok(
    gz <= GZIP_BUDGET,
    `app.js gzipped is ${gz} bytes, exceeds gzip budget ${GZIP_BUDGET} bytes (40 KiB)`,
  );
});

test('--check returns 0 immediately after a fresh build', () => {
  execFileSync('node', [script], { cwd: root, stdio: 'pipe' });
  execFileSync('node', [script, '--check'], { cwd: root, stdio: 'pipe' });
});
```

- [ ] **Step 2: Run to verify FAIL on size budget**

```bash
node --test scripts/build-console-assets-test.mjs
```

Expected (FAIL on "app.js is at most 110 KiB"): the current bundle is 228 KiB. The sidecar test also FAILS because the file does not exist yet.

- [ ] **Step 3: Add `esbuild` as exact-pinned dev-dependency**

Modify `package.json`:

```json
{
  "devDependencies": {
    "esbuild": "0.21.5",
    "playwright": "^1.59.1"
  },
  "scripts": {
    "console:smoke": "node scripts/console-browser-smoke.mjs",
    "console:responsive:stress": "node scripts/console-responsive-stress.mjs",
    "console:availability:test": "node --test scripts/console-availability-test.mjs",
    "console:pending:test": "node --test scripts/pendingItemRecovery-test.mjs",
    "console:beforeunload:test": "node --test scripts/beforeunloadGuard-test.mjs",
    "console:undo:test": "node --test scripts/undoRedo-test.mjs scripts/undoKeymatch-test.mjs",
    "console:draft:test": "node --test scripts/draftWorkspace-test.mjs scripts/draftWorkspaceHistory-test.mjs scripts/draftStorageAdapter-test.mjs scripts/draftApiAdapter-test.mjs scripts/draftUseCases-test.mjs scripts/draftCommandQueue-test.mjs scripts/draftPresentationContract-test.mjs scripts/draftWorkflowInvariant-test.mjs",
    "console:build:test": "node --test scripts/build-console-assets-test.mjs"
  }
}
```

Run:

```bash
npm install
```

Expected: `package-lock.json` updates; `esbuild` lands in `node_modules/.bin/esbuild`.

- [ ] **Step 4: Verify the test harness compiles and currently FAILS on size**

```bash
npm run console:build:test 2>&1 | head -40
```

Expected: at least one failing assertion, specifically the 110 KiB budget (because the build script has not been rewritten yet).

- [ ] **Step 5: Commit**

```bash
git add package.json package-lock.json scripts/build-console-assets-test.mjs
git commit -m "$(cat <<'EOF'
build(console): add esbuild dev-dep and failing bundle-size budget test

Pins esbuild 0.21.5 exactly for reproducible bundle output.
Adds scripts/build-console-assets-test.mjs which asserts a 110 KiB ceiling
and a console-build-meta.json sidecar — both currently RED. Subsequent
tasks make them GREEN.
EOF
)"
```

---

### Task 2: Annotate Source Files with `// @requires` Headers

**Files:**
- Modify: all 27 files in `fixthis-mcp/src/main/console/*.js`

The annotations encode the same load order that the current
`scripts/build-console-assets.mjs:8-36` `sources` array implies. No
behavioral change at this point — the build script does not yet consume the
annotations.

- [ ] **Step 1: Annotate root modules (no dependencies)**

Add as the first line of each of these files (after any existing one-line
comments):

```js
// @requires (none)
```

Files (5):

- `fixthis-mcp/src/main/console/state.js`
- `fixthis-mcp/src/main/console/draftPorts.js`
- `fixthis-mcp/src/main/console/draftWorkspace.js`
- `fixthis-mcp/src/main/console/draftWorkspaceHistory.js`
- `fixthis-mcp/src/main/console/beforeunloadGuard.js`

- [ ] **Step 2: Annotate the remaining non-root modules**

For each file, add a `// @requires <deps>` line. Discover the full set of
non-root files by globbing the source directory and excluding the roots
from Step 1:

```bash
ls fixthis-mcp/src/main/console/*.js | wc -l
# Sanity check: expected >= 27 files; current count: $N.
# Item 1 (state-machine expansion) may grow this number — that is fine,
# but every new file must carry a // @requires header.
```

Use the dependency edges below as a starting matrix (derived from the
legacy hand-curated order in `scripts/build-console-assets.mjs:8-36` —
later files may reference earlier files). If the glob surfaces modules not
listed here (because Item 1 has landed new files), annotate them by
inspecting their actual references in the source:

| File | `// @requires` |
|---|---|
| `staleness.js` | state.js |
| `pendingPersistence.js` | state.js |
| `draftStorageAdapter.js` | draftPorts.js, draftWorkspace.js |
| `draftApiAdapter.js` | draftPorts.js, draftWorkspace.js |
| `draftUseCases.js` | draftWorkspace.js, draftWorkspaceHistory.js, draftPorts.js |
| `draftCommandQueue.js` | draftUseCases.js, draftWorkspace.js |
| `undoRedo.js` | state.js |
| `undoKeymatch.js` | state.js |
| `previewStaleness.js` | state.js |
| `activityDrift.js` | state.js |
| `api.js` | state.js |
| `connection.js` | state.js, api.js |
| `availability.js` | state.js |
| `devices.js` | state.js, api.js |
| `preview.js` | state.js, api.js, draftWorkspace.js |
| `annotations.js` | state.js, draftWorkspace.js, draftUseCases.js, draftCommandQueue.js |
| `history.js` | state.js, draftWorkspace.js |
| `prompt.js` | state.js, draftWorkspace.js, draftUseCases.js |
| `rendering.js` | state.js, annotations.js, draftWorkspace.js |
| `sessions-polling.js` | state.js, api.js |
| `shortcuts.js` | state.js, undoRedo.js |
| `main.js` | state.js, connection.js, devices.js, preview.js, annotations.js, history.js, prompt.js, rendering.js, sessions-polling.js, shortcuts.js, draftUseCases.js, draftCommandQueue.js |

- [ ] **Step 3: Verify presence of annotation in every file (no exceptions)**

```bash
EXPECTED_MIN=27
COUNT=$(ls fixthis-mcp/src/main/console/*.js | wc -l | tr -d ' ')
if [ "$COUNT" -lt "$EXPECTED_MIN" ]; then
  echo "FAIL: expected >= $EXPECTED_MIN .js files, found $COUNT"
  exit 1
fi
echo "Found $COUNT module files (>= $EXPECTED_MIN required)"

MISSING=0
for f in fixthis-mcp/src/main/console/*.js; do
  head -3 "$f" | grep -q '// @requires' || { echo "MISSING: $f"; MISSING=1; }
done
[ "$MISSING" -eq 0 ] || exit 1
```

Expected: file count printed; no "MISSING:" lines. The build script
(Task 3) enforces this same invariant at every build via a presence test,
so a future contributor who adds a module without a header fails CI
(see Task 3 Step 3 below).

- [ ] **Step 4: Regenerate bundle the legacy way and verify no diff**

```bash
node scripts/build-console-assets.mjs
node scripts/build-console-assets.mjs --check
```

Expected: PASS (`--check` is a no-op because the only file change was new
comment lines, which the legacy script copies verbatim into `app.js`).

- [ ] **Step 5: Commit**

```bash
git add fixthis-mcp/src/main/console/*.js fixthis-mcp/src/main/resources/console/app.js
git commit -m "$(cat <<'EOF'
build(console): annotate every source module with // @requires header

No behavior change — the legacy build script still uses its hand-curated
order. Subsequent tasks switch the script to consume these annotations.
EOF
)"
```

---

### Task 3: Switch Build Script to Topological Sort

**Files:**
- Modify: `scripts/build-console-assets.mjs`

- [ ] **Step 1: Write failing topological-sort tests**

Append to `scripts/build-console-assets-test.mjs`:

```js
test('topological order matches legacy hand-curated array', () => {
  execFileSync('node', [script], { cwd: root, stdio: 'pipe' });
  const generated = readFileSync(targetJs, 'utf8');
  // Module boundary banners survive even when the build script switches
  // to graph-driven order. Earlier-loading module banners appear before
  // later ones.
  const stateIdx = generated.indexOf('//#region state.js');
  const annotationsIdx = generated.indexOf('//#region annotations.js');
  const renderingIdx = generated.indexOf('//#region rendering.js');
  const mainIdx = generated.indexOf('//#region main.js');
  assert.ok(stateIdx < annotationsIdx, 'state.js must precede annotations.js');
  assert.ok(annotationsIdx < renderingIdx, 'annotations.js must precede rendering.js');
  assert.ok(renderingIdx < mainIdx, 'main.js must be last');
});

test('cycle in @requires aborts the build', () => {
  const fixtureDir = resolve(root, '.tmp-console-cycle-test');
  // (helper omitted — see implementation below)
  // For now the assertion is structural: the rewritten script exports
  // a `topoSort(deps)` function we can require directly.
});
```

- [ ] **Step 2: Run to verify FAIL**

```bash
npm run console:build:test 2>&1 | head -40
```

Expected: FAIL on `stateIdx < annotationsIdx` because the current build
script does not emit `//#region` banners.

- [ ] **Step 3: Rewrite the build script's source-resolution step**

Replace lines 8-51 of `scripts/build-console-assets.mjs` (the hand-curated
`sources` array and the on-disk validation) with:

```js
const sourceDir = resolve(root, 'fixthis-mcp/src/main/console');
const onDisk = readdirSync(sourceDir).filter((name) => name.endsWith('.js'));

function parseRequires(content) {
  const headerMatch = content.match(/^\s*\/\/\s*@requires\s+(.+)$/m);
  if (!headerMatch) return [];
  const value = headerMatch[1].trim();
  if (value === '(none)' || value === '') return [];
  return value.split(',').map((s) => s.trim()).filter(Boolean);
}

const graph = new Map();
for (const name of onDisk) {
  const content = readFileSync(resolve(sourceDir, name), 'utf8');
  graph.set(name, { content, deps: parseRequires(content) });
}

function topoSort(g) {
  const visited = new Set();
  const visiting = new Set();
  const order = [];
  function visit(name, stack = []) {
    if (visited.has(name)) return;
    if (visiting.has(name)) {
      throw new Error(
        `Cycle in // @requires graph: ${[...stack, name].join(' -> ')}`,
      );
    }
    visiting.add(name);
    const node = g.get(name);
    if (!node) throw new Error(`Unknown module referenced: ${name}`);
    for (const dep of node.deps) visit(dep, [...stack, name]);
    visiting.delete(name);
    visited.add(name);
    order.push(name);
  }
  for (const name of [...g.keys()].sort()) visit(name);
  return order;
}

const sources = topoSort(graph);
```

Replace lines 75-83 (the concatenation step) with:

```js
const entries = sources.map((name) => {
  const path = resolve(sourceDir, name);
  return `//#region ${name}\n${readFileSync(path, 'utf8').trimEnd()}\n//#endregion ${name}\n`;
});
const concatenated = entries.join('\n');
```

(The build-header splice is removed entirely; Task 4 moves the metadata into
the sidecar.)

- [ ] **Step 4: Run to verify Step 1 assertions PASS**

```bash
npm run console:build:test 2>&1 | head -40
```

Expected: the topological-order test PASSES; the size-budget test still
FAILS because we have not minified yet.

- [ ] **Step 5: Add a cycle test fixture**

Create `scripts/build-console-cycle-fixture/` with two minimal `.js` files
that reference each other via `// @requires`. Update the second test in
Step 1 to import the topoSort function directly. To enable that, export it
from `scripts/build-console-assets.mjs`:

```js
export { topoSort };
```

And update the test:

```js
test('cycle in @requires aborts the build', async () => {
  const { topoSort } = await import('../scripts/build-console-assets.mjs');
  const cyclic = new Map([
    ['a.js', { content: '', deps: ['b.js'] }],
    ['b.js', { content: '', deps: ['a.js'] }],
  ]);
  assert.throws(() => topoSort(cyclic), /Cycle in .* @requires graph/);
});
```

Run:

```bash
npm run console:build:test 2>&1 | head -40
```

Expected: cycle test PASSES; size budget still FAILS.

- [ ] **Step 6: Add `// @requires` presence test (CI-failing, not WARN)**

Append to `scripts/build-console-assets-test.mjs`:

```js
import { readdirSync } from 'node:fs';

test('every console module (except entry point) carries a // @requires header', () => {
  const sourceDir = resolve(root, 'fixthis-mcp/src/main/console');
  const ENTRY_POINT = 'main.js';
  const files = readdirSync(sourceDir).filter((f) => f.endsWith('.js') && f !== ENTRY_POINT);
  assert.ok(files.length >= 26, `expected >=26 non-entry modules, found ${files.length}`);
  const missing = [];
  for (const name of files) {
    const text = readFileSync(resolve(sourceDir, name), 'utf8');
    if (!/^\s*\/\/\s*@requires\s+/m.test(text)) missing.push(name);
  }
  assert.deepEqual(missing, [], `Missing // @requires header in: ${missing.join(', ')}`);
});
```

In `scripts/build-console-assets.mjs`, also fail the build (non-zero exit)
when any non-entry-point module lacks a header. Replace the
"missing → treat as root" branch in `parseRequires` callers with:

```js
for (const name of onDisk) {
  if (name === 'main.js') continue;
  const content = readFileSync(resolve(sourceDir, name), 'utf8');
  if (!/^\s*\/\/\s*@requires\s+/m.test(content)) {
    console.error(`ERROR: fixthis-mcp/src/main/console/${name} has no // @requires header.`);
    process.exit(1);
  }
}
```

Run:

```bash
npm run console:build:test
```

Expected: PASS (every module has a header from Task 2).

- [ ] **Step 7: Commit**

```bash
git add scripts/build-console-assets.mjs scripts/build-console-assets-test.mjs fixthis-mcp/src/main/resources/console/app.js
git commit -m "$(cat <<'EOF'
build(console): drive bundle order from @requires DAG

Replaces the hand-curated sources array with a topological sort over the
@requires annotations introduced in the previous commit. Adds //#region
banners between modules so order is verifiable post-bundling. Exports
topoSort for cycle-detection testing. Fails the build on any non-entry
module missing a // @requires header.
EOF
)"
```

---

### Task 4: Move Build Metadata to Sidecar JSON

**Files:**
- Modify: `scripts/build-console-assets.mjs`
- Modify: `fixthis-mcp/src/main/console/state.js`
- Modify: `fixthis-mcp/src/main/kotlin/io/github/beyondwin/fixthis/mcp/console/FeedbackConsoleAssets.kt`
- Create: `fixthis-mcp/src/main/resources/console/console-build-meta.json`

- [ ] **Step 1: Find all in-source consumers of the legacy header**

```bash
grep -rnE "ConsoleBuildEpochMs|ConsoleBuildGitSha" \
  fixthis-mcp/src/main/console/ \
  fixthis-mcp/src/main/kotlin/ \
  fixthis-mcp/src/test/kotlin/
```

Capture every hit. If any test asserts on `// build-header`, those tests
must be relaxed in Step 4.

- [ ] **Step 2: Verify FAIL on missing sidecar**

```bash
npm run console:build:test 2>&1 | grep -E "sidecar|console-build-meta"
```

Expected: FAIL on the sidecar test (still RED from Task 1).

- [ ] **Step 3: Implement sidecar emission in the build script**

After the `concatenated` string is computed and before any minification, add
to `scripts/build-console-assets.mjs`. Normalization happens at **write
time** when `FIXTHIS_BUNDLE_REPRODUCIBLE=1` is set, not at compare time —
so the stored bytes are deterministic and `--check` is a simple byte
compare (see spec §R3):

```js
const metaPath = resolve(root, 'fixthis-mcp/src/main/resources/console/console-build-meta.json');
const reproducible = process.env.FIXTHIS_BUNDLE_REPRODUCIBLE === '1'
  || process.argv.includes('--check');
const meta = reproducible
  ? { buildEpochMs: 0, gitSha: 'reproducible' }
  : { buildEpochMs: epochMs, gitSha };
const metaSerialized = JSON.stringify(meta, null, 2) + '\n';

if (process.argv.includes('--check')) {
  if (!existsSync(metaPath)) {
    console.error('Generated console-build-meta.json is missing. Run FIXTHIS_BUNDLE_REPRODUCIBLE=1 node scripts/build-console-assets.mjs.');
    process.exit(1);
  }
  const currentMeta = readFileSync(metaPath, 'utf8');
  if (currentMeta !== metaSerialized) {
    console.error('Generated console-build-meta.json is out of date. Run FIXTHIS_BUNDLE_REPRODUCIBLE=1 node scripts/build-console-assets.mjs.');
    process.exit(1);
  }
} else {
  writeFileSync(metaPath, metaSerialized);
}
```

CI invokes the script as `FIXTHIS_BUNDLE_REPRODUCIBLE=1 node
scripts/build-console-assets.mjs` (or simply via `--check`, which forces
reproducible mode). Local developers run plain `node
scripts/build-console-assets.mjs` to get real `buildEpochMs` / `gitSha` in
the sidecar — but those reproducible values are what land on `main`.

`FeedbackConsoleAssets.kt` substitutes a runtime epoch and the JVM-readable
git sha (or "unknown") into `window.FixThisConsoleConfig.buildMeta` when
the sidecar contains the reproducible placeholder, so the served HTML
still carries a useful build identifier in production.

Remove the legacy `buildHeader` injection block (`scripts/build-console-assets.mjs:54-73, 81`).

- [ ] **Step 4: Update Kotlin asset server to inline meta**

Modify `fixthis-mcp/src/main/kotlin/io/github/beyondwin/fixthis/mcp/console/FeedbackConsoleAssets.kt`.
Locate the existing `window.FixThisConsoleConfig = ...` inlining block. Add
a sibling read of the JSON resource and inline the meta:

```kotlin
private fun consoleBuildMetaJson(): String =
    FeedbackConsoleAssets::class.java
        .getResource("/console/console-build-meta.json")
        ?.readText()
        ?: "{\"buildEpochMs\":0,\"gitSha\":\"unknown\"}"
```

Where the existing inline is done, append:

```kotlin
val configJson = ...existing...
val metaJson = consoleBuildMetaJson()
val script = "<script>window.FixThisConsoleConfig = ${configJson};" +
             "window.FixThisConsoleConfig.buildMeta = ${metaJson};</script>"
```

- [ ] **Step 5: Remove the legacy constants from `state.js`**

Open `fixthis-mcp/src/main/console/state.js`. Find any reference to
`ConsoleBuildEpochMs` or `ConsoleBuildGitSha` (use Step 1's `grep` output).
Replace with reads from `window.FixThisConsoleConfig.buildMeta`:

```js
// Before: ConsoleBuildEpochMs  (declared by build header)
// After:  window.FixThisConsoleConfig?.buildMeta?.buildEpochMs ?? 0
```

(If `state.js` does not reference these constants directly — they may live
only in `// build-header` comments — leave it alone and verify via Step 1
that no source file does.)

- [ ] **Step 6: Run to verify sidecar test PASSES**

```bash
node scripts/build-console-assets.mjs
npm run console:build:test 2>&1 | grep -E "sidecar|console-build-meta"
```

Expected: sidecar test PASSES. Size-budget test still FAILS.

- [ ] **Step 7: Run Gradle tests to confirm no Kotlin regression**

```bash
./gradlew :fixthis-mcp:test --tests "io.github.beyondwin.fixthis.mcp.console.*"
```

Expected: all PASS.

- [ ] **Step 8: Commit**

```bash
git add scripts/build-console-assets.mjs \
        scripts/build-console-assets-test.mjs \
        fixthis-mcp/src/main/console/state.js \
        fixthis-mcp/src/main/kotlin/io/github/beyondwin/fixthis/mcp/console/FeedbackConsoleAssets.kt \
        fixthis-mcp/src/main/resources/console/console-build-meta.json \
        fixthis-mcp/src/main/resources/console/app.js
git commit -m "$(cat <<'EOF'
build(console): move build epoch + git sha into sidecar JSON

The JS bundle no longer carries the rounded-minute build header. Instead,
FeedbackConsoleAssets.kt inlines window.FixThisConsoleConfig.buildMeta
from console-build-meta.json on serve. --check verifies all three
artifacts (app.js, console-build-meta.json) are byte-equivalent to a
fresh regeneration.
EOF
)"
```

---

### Task 5: Pipe Bundle Through esbuild (Minify + Source Map)

**Files:**
- Modify: `scripts/build-console-assets.mjs`

- [ ] **Step 1: Verify FAIL on size budget**

```bash
npm run console:build:test 2>&1 | grep -E "110 KiB|size"
```

Expected: FAIL (still 220+ KiB).

- [ ] **Step 2: Implement esbuild step**

Add to the top of `scripts/build-console-assets.mjs`:

```js
import { build as esbuild } from 'esbuild';
```

Replace the `writeFileSync(target, output)` block (the final write) with:

```js
const mapPath = `${target}.map`;
const result = await esbuild({
  stdin: { contents: concatenated, loader: 'js', sourcefile: 'app.js' },
  bundle: false,
  write: false,
  minify: false,
  minifyWhitespace: true,
  minifySyntax: true,
  minifyIdentifiers: false,
  target: ['es2020'],
  sourcemap: 'external',
  legalComments: 'inline',
});

const outFiles = Object.fromEntries(result.outputFiles.map((f) => [f.path, f]));
// esbuild names the outputs <stdin>.js and <stdin>.js.map
const jsBytes = result.outputFiles.find((f) => f.path.endsWith('.js')).contents;
const mapBytes = result.outputFiles.find((f) => f.path.endsWith('.map')).contents;
```

After computing `jsBytes`, add the raw + gzip size-budget guard:

```js
import { gzipSync } from 'node:zlib';

const RAW_BUDGET_BYTES = 110 * 1024;   // 112,640
const GZIP_BUDGET_BYTES = 40 * 1024;   // 40,960
if (jsBytes.byteLength > RAW_BUDGET_BYTES) {
  console.error(
    `Bundle (raw) is ${jsBytes.byteLength} bytes, exceeds raw budget of ${RAW_BUDGET_BYTES} bytes (110 KiB).`,
  );
  process.exit(1);
}
const gzBytes = gzipSync(Buffer.from(jsBytes), { level: 9 }).byteLength;
if (gzBytes > GZIP_BUDGET_BYTES) {
  console.error(
    `Bundle (gzipped) is ${gzBytes} bytes, exceeds gzip budget of ${GZIP_BUDGET_BYTES} bytes (40 KiB).`,
  );
  process.exit(1);
}
```

Add the `--check` logic for both `app.js` and `app.js.map`:

```js
if (process.argv.includes('--check')) {
  // existing app.js check ...
  if (!existsSync(mapPath)) {
    console.error('Generated app.js.map is missing.');
    process.exit(1);
  }
  const currentMap = readFileSync(mapPath);
  if (!Buffer.from(mapBytes).equals(currentMap)) {
    console.error('Generated app.js.map is out of date. Run node scripts/build-console-assets.mjs.');
    process.exit(1);
  }
  process.exit(0);
}

writeFileSync(target, jsBytes);
writeFileSync(mapPath, mapBytes);
```

Convert the script's top-level body to an `async function main() { ... }` wrapper because `esbuild` is async; call `main().catch(...)` at module bottom.

The `app.js` file must end with a `//# sourceMappingURL=app.js.map` line (esbuild adds this when `sourcemap: 'external'` is set; verify after building).

- [ ] **Step 3: Run to verify both size budgets PASS**

```bash
node scripts/build-console-assets.mjs
wc -c fixthis-mcp/src/main/resources/console/app.js
gzip -c fixthis-mcp/src/main/resources/console/app.js | wc -c
```

Expected: raw bytes ≤ 112,640 (110 KiB); gzipped bytes ≤ 40,960 (40 KiB).

```bash
npm run console:build:test
```

Expected: all 5 tests PASS (build-runs, sidecar, raw budget, gzip budget,
`--check`).

- [ ] **Step 4: Verify the source map maps correctly**

```bash
node -e "const m = JSON.parse(require('fs').readFileSync('fixthis-mcp/src/main/resources/console/app.js.map','utf8')); console.log(m.version, m.sources.length, m.sources.slice(0,3));"
```

Expected: `3 1 ['app.js']` (esbuild's stdin source map references one source by default; that is sufficient because the `//#region <file>` banners survive in the minified output and DevTools shows them as anchor comments).

If a more granular map is desired (one source entry per module file), the
plan does **not** require it for Phase 1 — the size budget is the priority.
The improved map is filed as a follow-up.

- [ ] **Step 5: Exclude source maps from the shipped JAR**

The source map must stay on disk (DevTools needs it under
`--console-assets-dir`) but must **not** be packaged into the production
JAR. Edit `fixthis-mcp/build.gradle.kts` and add to `processResources`:

```kotlin
tasks.named<Copy>("processResources") {
    exclude("**/*.map")
}
```

If a sibling task name is more idiomatic in this module
(e.g. `jvmProcessResources` for Kotlin Multiplatform), use that name; the
behavior is the same.

Add a verification assertion in
`fixthis-mcp/src/test/kotlin/io/github/beyondwin/fixthis/mcp/console/ConsoleAssetContractTest.kt`
(or a new sibling test) that enumerates the console resources visible to
the classloader and rejects any `.map` entry:

```kotlin
@Test
fun `JAR resources do not include source maps`() {
    val cl = javaClass.classLoader
    val mapUrl = cl.getResource("console/app.js.map")
    assertNull(mapUrl, "app.js.map leaked into the packaged resources")
}
```

Run:

```bash
./gradlew :fixthis-mcp:processResources
find fixthis-mcp/build/resources/main/console -name '*.map'
```

Expected: no output (no `.map` files in the staged resources).

- [ ] **Step 6: Run the full Gradle matrix to confirm asset-contract tests survive**

```bash
./gradlew :fixthis-mcp:test --tests "io.github.beyondwin.fixthis.mcp.console.*"
```

Expected: PASS. If any test fails on a substring that the minifier rewrote
or removed, capture the failing assertions; they get migrated in Task 7.

- [ ] **Step 7: Commit**

```bash
git add scripts/build-console-assets.mjs \
        fixthis-mcp/build.gradle.kts \
        fixthis-mcp/src/test/kotlin/io/github/beyondwin/fixthis/mcp/console/ConsoleAssetContractTest.kt \
        fixthis-mcp/src/main/resources/console/app.js \
        fixthis-mcp/src/main/resources/console/app.js.map
git commit -m "$(cat <<'EOF'
build(console): minify bundle with esbuild, emit external source map

Pipes the topologically-sorted concatenated source through esbuild with
--minify-whitespace --minify-syntax. Identifier minification is OFF so
that asset-contract tests grep'ing for function names still match. Emits
app.js.map alongside app.js. Asserts a 110 KiB budget. Excludes *.map
from processResources so the shipped JAR stays lean (map is still served
via --console-assets-dir in dev).
EOF
)"
```

---

### Task 6: Add Contract-Symbol Guard

**Files:**
- Modify: `scripts/build-console-assets.mjs`
- Modify: `scripts/build-console-assets-test.mjs`

- [ ] **Step 1: Enumerate contract symbols**

Read the asset-contract tests (post-Item-3 split:
`fixthis-mcp/src/test/kotlin/io/github/beyondwin/fixthis/mcp/console/ConsoleAssetContractTest.kt`)
and extract every JS symbol they grep for. Concretely:

```bash
grep -oE '"[a-zA-Z][a-zA-Z0-9]*"' \
  fixthis-mcp/src/test/kotlin/io/github/beyondwin/fixthis/mcp/console/ConsoleAssetContractTest.kt \
  | sort -u | head -50
```

Capture every symbol that is *both* (a) an identifier (matches `^[A-Z_a-z][A-Z_a-z0-9]*$`) and (b) referenced by `assertTrue` / `assertContains` in the test bodies. Maintain the list as a constant in the build script.

- [ ] **Step 2: Write the failing guard test**

Append to `scripts/build-console-assets-test.mjs`:

```js
test('contract-symbol guard catches a dropped symbol', async () => {
  // Drive the guard with a synthetic minified output that omits
  // withMutationLock.
  const { assertContractSymbols } = await import('../scripts/build-console-assets.mjs');
  assert.throws(
    () => assertContractSymbols('function foo(){}', ['withMutationLock']),
    /contract symbol 'withMutationLock'/,
  );
});

test('contract-symbol guard passes when all symbols are present', async () => {
  const { assertContractSymbols } = await import('../scripts/build-console-assets.mjs');
  assert.doesNotThrow(() =>
    assertContractSymbols('function withMutationLock(fn){}', ['withMutationLock']),
  );
});
```

Run:

```bash
npm run console:build:test 2>&1 | head -40
```

Expected: FAIL on `Cannot find package` or `assertContractSymbols is not exported`.

- [ ] **Step 3: Implement the guard**

In `scripts/build-console-assets.mjs` add:

```js
const CONTRACT_SYMBOLS = [
  'withMutationLock',
  'pollSessionsTick',
  'mergeSessionIntoState',
  'startSessionsPolling',
  'MaxConsecutivePollFailures',
  'DefaultLivePreviewIntervalMs',
  'PreviewIntervalStorageKey',
  // ... (full list from Step 1)
];

export function assertContractSymbols(output, symbols = CONTRACT_SYMBOLS) {
  for (const symbol of symbols) {
    if (!output.includes(symbol)) {
      throw new Error(
        `esbuild dropped contract symbol '${symbol}'. ` +
        `Add @preserve banner or stop minifying.`,
      );
    }
  }
}
```

Call it after minification, before the size budget check:

```js
const jsText = new TextDecoder().decode(jsBytes);
assertContractSymbols(jsText);
```

- [ ] **Step 4: Run to verify PASS**

```bash
npm run console:build:test
```

Expected: all tests PASS.

- [ ] **Step 5: Smoke a synthetic regression**

Temporarily replace `withMutationLock` with `__renamed__` in
`fixthis-mcp/src/main/console/state.js` (around line 186):

```js
async function __renamed__(fn) { ... }
```

Run:

```bash
node scripts/build-console-assets.mjs 2>&1 | head -5
```

Expected: non-zero exit; stderr contains
`esbuild dropped contract symbol 'withMutationLock'`.

Revert the rename:

```bash
git checkout -- fixthis-mcp/src/main/console/state.js
node scripts/build-console-assets.mjs
```

Expected: PASS.

- [ ] **Step 6: Runtime-reachability assertion against the bundled JS**

The string-grep guard from Step 3 only proves the symbol's *bytes*
survive; the minifier could still inline the function such that the name
appears in a comment but is unreachable at runtime. Add a Node test that
loads the bundled JS and asserts each contract symbol is actually a
defined global on a sandbox `globalThis`:

Append to `scripts/build-console-assets-test.mjs`:

```js
test('bundled JS exposes every contract symbol at runtime', () => {
  execFileSync('node', [script], { cwd: root, stdio: 'pipe' });
  const code = readFileSync(targetJs, 'utf8');
  // Strip the //# sourceMappingURL trailer to keep vm happy.
  const stripped = code.replace(/\/\/# sourceMappingURL=.*$/m, '');
  const sandbox = {
    window: {},
    document: { addEventListener() {}, querySelectorAll: () => [], createElement: () => ({}) },
    localStorage: { getItem: () => null, setItem() {} },
    fetch: async () => ({ ok: true, json: async () => ({}) }),
    setInterval: () => 0, clearInterval() {}, setTimeout: () => 0, clearTimeout() {},
  };
  sandbox.globalThis = sandbox;
  // Run the script in a fresh VM context so module-level declarations
  // attach to `sandbox` (script-scoped, like the browser).
  const { runInNewContext } = require('node:vm');
  runInNewContext(stripped, sandbox);
  for (const symbol of [
    'withMutationLock', 'pollSessionsTick', 'mergeSessionIntoState',
    'startSessionsPolling', 'MaxConsecutivePollFailures',
    'DefaultLivePreviewIntervalMs', 'PreviewIntervalStorageKey',
  ]) {
    assert.ok(
      typeof sandbox[symbol] !== 'undefined',
      `Bundled JS does not expose '${symbol}' at runtime (minifier inlined or dropped it)`,
    );
  }
});
```

A Playwright equivalent under `scripts/console-browser-smoke.mjs` is an
acceptable alternative if the `node:vm` shim above proves too brittle —
load the served console, then `await page.evaluate(() => typeof
withMutationLock)` per symbol.

Run:

```bash
npm run console:build:test
```

Expected: PASS.

- [ ] **Step 7: Commit**

```bash
git add scripts/build-console-assets.mjs scripts/build-console-assets-test.mjs fixthis-mcp/src/main/resources/console/app.js
git commit -m "$(cat <<'EOF'
build(console): assert contract symbols survive minification

Hard-coded list of identifiers grep'd by ConsoleAssetContractTest.kt. The
build aborts with a named error if esbuild drops any symbol, catching the
class of "minifier removed dead code that wasn't dead" bugs.
EOF
)"
```

---

### Task 7: Migrate Asset Contract Tests to Unbundled Source

**Files:**
- Modify: `fixthis-mcp/src/test/kotlin/io/github/beyondwin/fixthis/mcp/console/ConsoleAssetContractTest.kt`
- Modify: `fixthis-mcp/src/test/kotlin/io/github/beyondwin/fixthis/mcp/fixtures/` (helper file)

- [ ] **Step 1: Add a fixture helper that reads unbundled sources**

Create or extend a fixtures helper. If `ConsoleRouteTestFixtures.kt` (from
Item 3) is already present, add a top-level function alongside the class:

```kotlin
// fixthis-mcp/src/test/kotlin/io/github/beyondwin/fixthis/mcp/fixtures/ConsoleSourceFixtures.kt
package io.github.beyondwin.fixthis.mcp.fixtures

import java.nio.file.Files
import java.nio.file.Paths

object ConsoleSourceFixtures {
    private val sourceDir = Paths.get("src/main/console")

    fun read(moduleFileName: String): String =
        Files.readString(sourceDir.resolve(moduleFileName))

    fun readAll(): String =
        Files.list(sourceDir).use { paths ->
            paths.filter { it.toString().endsWith(".js") }
                .map(Files::readString)
                .toList()
                .joinToString("\n")
        }
}
```

- [ ] **Step 2: Audit which contract tests fail after minification**

```bash
./gradlew :fixthis-mcp:test --tests "io.github.beyondwin.fixthis.mcp.console.ConsoleAssetContractTest" 2>&1 | tee /tmp/contract-failures.log
grep "FAILED" /tmp/contract-failures.log
```

Record each failing test name.

- [ ] **Step 3: For each failing test, switch its source-of-truth to unbundled**

The tests currently do something like:

```kotlin
val js = bundledConsoleJs()
assertTrue(js.contains("function withMutationLock"))
```

Change to:

```kotlin
val js = ConsoleSourceFixtures.read("state.js")
assertTrue(js.contains("function withMutationLock"))
```

For tests that don't know which module their symbol lives in, use `readAll()`.

- [ ] **Step 4: Run only the asset contract tests to verify GREEN**

```bash
./gradlew :fixthis-mcp:test --tests "io.github.beyondwin.fixthis.mcp.console.ConsoleAssetContractTest"
```

Expected: all PASS.

- [ ] **Step 5: Run the full Gradle matrix**

```bash
./gradlew :fixthis-mcp:test
```

Expected: PASS.

- [ ] **Step 6: Commit**

```bash
git add fixthis-mcp/src/test/kotlin/io/github/beyondwin/fixthis/mcp/fixtures/ConsoleSourceFixtures.kt \
        fixthis-mcp/src/test/kotlin/io/github/beyondwin/fixthis/mcp/console/ConsoleAssetContractTest.kt
git commit -m "$(cat <<'EOF'
test(console): read unbundled console source for contract assertions

Asset contract tests now read fixthis-mcp/src/main/console/<file>.js
directly instead of grep'ing the minified app.js. The minified bundle
is verified only for size and contract-symbol presence by the build
script.
EOF
)"
```

---

### Task 8: Run End-to-End Smoke and Browser Tests

**Files:**
- (verification only)

- [ ] **Step 1: Rebuild bundle from scratch**

```bash
rm fixthis-mcp/src/main/resources/console/app.js \
   fixthis-mcp/src/main/resources/console/app.js.map \
   fixthis-mcp/src/main/resources/console/console-build-meta.json
node scripts/build-console-assets.mjs
node scripts/build-console-assets.mjs --check
```

Expected: PASS for both.

- [ ] **Step 2: Run the Playwright smoke**

```bash
npm run console:smoke
```

Expected: PASS. If a scenario fails because a runtime expression was
rewritten by `--minify-syntax`, capture the failure, revert just that
syntax-rewrite by adding the expression to a `// esbuild-disable-syntax`
comment block (the build script can detect this and re-emit the affected
region verbatim — implementation deferred unless triggered).

- [ ] **Step 3: Run the full Node test set**

```bash
npm run console:availability:test
npm run console:pending:test
npm run console:beforeunload:test
npm run console:undo:test
npm run console:draft:test
npm run console:build:test
```

Expected: PASS for every script.

- [ ] **Step 4: Run the full Gradle matrix**

```bash
./gradlew :fixthis-mcp:test
./gradlew check
```

Expected: PASS.

- [ ] **Step 5: Measure final size (raw + gzipped)**

```bash
wc -c fixthis-mcp/src/main/resources/console/app.js
gzip -c fixthis-mcp/src/main/resources/console/app.js | wc -c
wc -c fixthis-mcp/src/main/resources/console/app.js.map
wc -c fixthis-mcp/src/main/resources/console/console-build-meta.json
```

Record both raw and gzipped numbers in the commit message of Task 9.
Budgets: raw ≤ 112,640 (110 KiB), gzip ≤ 40,960 (40 KiB).

- [ ] **Step 6: Commit (no file changes — verification only)**

If any prior commit needs amending based on smoke output, do **not**
`git commit --amend`. Land a follow-up commit instead. Otherwise skip this
step.

---

### Task 9: Documentation Sync

**Files:**
- Modify: `CONTRIBUTING.md`
- Modify: `docs/reference/feedback-console-contract.md`
- Modify: `CLAUDE.md`

- [ ] **Step 1: Update CONTRIBUTING.md**

Find the existing "console rebundle" section. Append the size budget and
source-map notes:

```markdown
### Console bundle

`node scripts/build-console-assets.mjs` produces three files under
`fixthis-mcp/src/main/resources/console/`:

- `app.js` — minified bundle (must be ≤ 110 KiB; the build aborts otherwise).
- `app.js.map` — external source map; load DevTools to step through the
  unminified 27 source modules.
- `console-build-meta.json` — sidecar with `buildEpochMs` and `gitSha`,
  inlined into `window.FixThisConsoleConfig.buildMeta` by
  `FeedbackConsoleAssets.kt`.

`node scripts/build-console-assets.mjs --check` verifies all three artifacts
are byte-equivalent to a fresh regeneration. CI runs this check.

If the build aborts with "esbuild dropped contract symbol", a JS function
the asset contract tests rely on was inlined or renamed by the minifier;
audit the change or extend the `CONTRACT_SYMBOLS` list.
```

- [ ] **Step 2: Update docs/reference/feedback-console-contract.md**

Find the "static assets" or "served bundle" section and add:

```markdown
The console bundle is generated by `scripts/build-console-assets.mjs` from
the 27 source files in `fixthis-mcp/src/main/console/`. Source order is a
topological sort over `// @requires` directives at the top of each file.
The build emits a minified `app.js` (≤ 110 KiB), an external source map,
and a `console-build-meta.json` sidecar. Identifier minification is
disabled to keep asset contract tests stable.
```

- [ ] **Step 3: Update CLAUDE.md "Console UI iteration" bullet**

The current bullet points contributors at the legacy bundler. Update the
relevant line in `/Users/kws/source/android/FixThis/CLAUDE.md` so it
reflects the new pipeline:

```markdown
- **Console UI iteration** — pass `--console-assets-dir` to read HTML/CSS/JS
  from source instead of the packaged JAR; rebundle JS via
  `node scripts/build-console-assets.mjs` after edits (produces minified
  `app.js` ≤ 110 KiB raw / 40 KiB gzip, an `app.js.map` source map for
  DevTools, and a `console-build-meta.json` sidecar), then verify with
  `node scripts/build-console-assets.mjs --check`. Details in
  [`docs/reference/feedback-console-contract.md`](docs/reference/feedback-console-contract.md).
```

- [ ] **Step 4: Run the doc-link verification commands**

```bash
grep -rn "build-console-assets" CONTRIBUTING.md docs/ CLAUDE.md
```

Confirm every reference still makes sense after the rewrite.

- [ ] **Step 5: Run the complete required-local-checks recipe**

```bash
./gradlew :fixthis-mcp:test
./gradlew check
node scripts/build-console-assets.mjs --check
npm run console:build:test
node --syntax-check fixthis-mcp/src/main/resources/console/app.js \
  || node -e "new Function(require('fs').readFileSync('fixthis-mcp/src/main/resources/console/app.js','utf8'))"
git diff --check
```

Expected: PASS for every command.

- [ ] **Step 6: Commit**

```bash
git add CONTRIBUTING.md docs/reference/feedback-console-contract.md CLAUDE.md
git commit -m "$(cat <<'EOF'
docs(console): document bundle minification, sidecar, and budget guard

CONTRIBUTING.md gains a Console bundle section with the three artifacts
emitted by build-console-assets.mjs and a note on the 110 KiB raw /
40 KiB gzip budgets. feedback-console-contract.md describes the
@requires-driven topological order. CLAUDE.md's "Console UI iteration"
pointer is updated to mention the new minified output and source map.
Asset contract tests read unbundled sources; minification preserves all
symbols grep'd by those tests.

Final bundle size: <fill from Task 8 Step 5> bytes raw,
<fill> bytes gzipped (was 228,237 raw).
EOF
)"
```

---

## Self-Review Checklist

- [ ] Spec coverage:
  - §3.1 Pipeline: Tasks 3 (DAG), 4 (sidecar), 5 (esbuild + JAR map exclusion).
  - §3.2 Dependency declaration + §R5 missing-header CI failure: Tasks 2, 3 (Step 6).
  - §3.5 Contract-stable minification (string + runtime reachability):
        Tasks 5 and 6 (Steps 3+6).
  - §3.6 Build-meta sidecar with reproducible-mode write normalization
        (§R3): Task 4.
  - §3.8 Raw + gzip size budgets: Tasks 1 (both tests) and 5 (raw
        enforcement); gzip enforcement in CI workflow (spec §4
        merge-gate snippet).
  - §R2 JAR map exclusion: Task 5 Step 5.
- [ ] Every task ends with a single commit; no `--amend`, no rebase.
- [ ] Tests are RED before they are GREEN in every task.
- [ ] No placeholder code; every Step shows the actual change to make.
- [ ] No files are deleted except by `rm` in Task 8 Step 1 (transient regenerate-from-scratch check).
- [ ] CI's existing `--check` workflow continues to gate the bundle.
- [ ] esbuild is exact-pinned, so reproducible builds.
- [ ] Asset contract tests are migrated to unbundled source reads (Task 7),
      decoupling them from minifier output behavior.
