diff --git a/.github/scripts/frontend-js-size.mts b/.github/scripts/frontend-js-size.mts index 04a92679c7..370f49c082 100644 --- a/.github/scripts/frontend-js-size.mts +++ b/.github/scripts/frontend-js-size.mts @@ -368,6 +368,8 @@ function getChunkComparisonRows(keys: string[], before: Record[number]; + type ChunkAggregate = { beforeSize: number; afterSize: number; @@ -375,6 +377,8 @@ type ChunkAggregate = { afterCount: number; }; +const smallDeltaThreshold = 5; + function sumChunkSizes(chunks: FileEntry[]) { return chunks.reduce((sum, chunk) => sum + chunk.size, 0); } @@ -390,6 +394,19 @@ function generatedAggregate(before: FileEntry[], after: FileEntry[]): ChunkAggre }; } +function hasSmallDelta(row: ChunkComparisonRow) { + return Math.abs(row.afterSize - row.beforeSize) <= smallDeltaThreshold; +} + +function comparisonRowsAggregate(rows: ChunkComparisonRow[]): ChunkAggregate { + return { + beforeSize: rows.reduce((sum, row) => sum + row.beforeSize, 0), + afterSize: rows.reduce((sum, row) => sum + row.afterSize, 0), + beforeCount: rows.filter(row => row.beforeFile != null).length, + afterCount: rows.filter(row => row.afterFile != null).length, + }; +} + function comparableMap(chunks: FileEntry[]) { const entries: [string, FileEntry][] = []; for (const chunk of chunks) { @@ -410,14 +427,14 @@ function formatChunkChangeSummary(label: string, summary: ReturnType[number], b: ReturnType[number]) { +function compareChunkComparisonRows(a: ChunkComparisonRow, b: ChunkComparisonRow) { return Math.abs(b.afterSize - b.beforeSize) - Math.abs(a.afterSize - a.beforeSize) || (b.afterSize - b.beforeSize) - (a.afterSize - a.beforeSize) || b.sortSize - a.sortSize || a.name.localeCompare(b.name); } -function chunkFileDisplay(row: ReturnType[number]) { +function chunkFileDisplay(row: ChunkComparisonRow) { if (row.beforeFile == null) return row.afterFile ?? ''; if (row.afterFile == null || row.beforeFile === row.afterFile) return row.beforeFile; return `${row.beforeFile} → ${row.afterFile}`; @@ -427,23 +444,19 @@ function chunkMarkdownTable( rows: ReturnType, total?: { beforeSize: number; afterSize: number }, generated?: ChunkAggregate, + other?: ChunkAggregate, ) { - if (rows.length === 0 && total == null && generated == null) return '_No data_'; + const hasGenerated = generated != null && (generated.beforeCount > 0 || generated.afterCount > 0); + const hasOther = other != null && (other.beforeCount > 0 || other.afterCount > 0); + if (rows.length === 0 && total == null && !hasGenerated && !hasOther) return '_No data_'; const lines = [ '| Chunk | Before | After | Δ | Δ (%) |', '| --- | ---: | ---: | ---: | ---: |', ]; - let hasSummaryRow = false; if (total != null) { lines.push(`| (total) | ${util.formatBytes(total.beforeSize)} | ${util.formatBytes(total.afterSize)} | ${util.calcAndFormatDeltaBytes(total.beforeSize, total.afterSize, 1000)} | ${util.calcAndFormatDeltaPercent(total.beforeSize, total.afterSize, 0.1).replaceAll('\\%', '\\\\%')} |`); - hasSummaryRow = true; } - if (generated != null && (generated.beforeCount > 0 || generated.afterCount > 0)) { - lines.push(`| (other generated chunks) | ${util.formatBytes(generated.beforeSize)} | ${util.formatBytes(generated.afterSize)} | ${util.calcAndFormatDeltaBytes(generated.beforeSize, generated.afterSize, 1000)} | ${util.calcAndFormatDeltaPercent(generated.beforeSize, generated.afterSize, 0.1).replaceAll('\\%', '\\\\%')} |`); - hasSummaryRow = true; - } - if (hasSummaryRow && rows.length > 0) lines.push('| | | | | |'); for (const row of rows) { const chunkFile = chunkFileDisplay(row); @@ -455,7 +468,14 @@ function chunkMarkdownTable( lines.push(`|
\`${escapeCell(row.name)}\` \`${escapeCell(chunkFile)}\`
| ${util.formatBytes(row.beforeSize)} | ${util.formatBytes(row.afterSize)} | ${util.calcAndFormatDeltaBytes(row.beforeSize, row.afterSize, 1000)} | ${util.calcAndFormatDeltaPercent(row.beforeSize, row.afterSize, 0.1).replaceAll('\\%', '\\\\%')} |`); } } - if (generated != null && (generated.beforeCount > 0 || generated.afterCount > 0)) { + if (hasGenerated || hasOther) lines.push('| | | | | |'); + if (hasGenerated) { + lines.push(`| (other generated chunks) | ${util.formatBytes(generated.beforeSize)} | ${util.formatBytes(generated.afterSize)} | ${util.calcAndFormatDeltaBytes(generated.beforeSize, generated.afterSize, 1000)} | ${util.calcAndFormatDeltaPercent(generated.beforeSize, generated.afterSize, 0.1).replaceAll('\\%', '\\\\%')} |`); + } + if (hasOther) { + lines.push(`| (other) | ${util.formatBytes(other.beforeSize)} | ${util.formatBytes(other.afterSize)} | ${util.calcAndFormatDeltaBytes(other.beforeSize, other.afterSize, 1000)} | ${util.calcAndFormatDeltaPercent(other.beforeSize, other.afterSize, 0.1).replaceAll('\\%', '\\\\%')} |`); + } + if (hasGenerated) { lines.push(''); lines.push(`_${generated.beforeCount} before / ${generated.afterCount} after generated chunks are grouped._`); } @@ -475,7 +495,8 @@ function renderFrontendChunkReport(before: Awaited !hasSmallDelta(row)).sort(compareChunkComparisonRows).slice(0, 30); // TODO: 実際に30を超えて切り捨てられたrowがあった場合はその旨をmarkdown内に表示するようにする const beforeStartupFiles = new Set(before.startupFiles); const afterStartupFiles = new Set(after.startupFiles); @@ -485,8 +506,9 @@ function renderFrontendChunkReport(before: Awaited !hasSmallDelta(row)).sort(compareChunkComparisonRows); const startupTotal = { beforeSize: sumChunkSizes(beforeStartupChunks), afterSize: sumChunkSizes(afterStartupChunks), @@ -501,14 +523,14 @@ function renderFrontendChunkReport(before: Awaited', `${formatChunkChangeSummary('Chunk size diff', diffSummary)}`, '', - chunkMarkdownTable(diffRows, diffTotal, diffGenerated), + chunkMarkdownTable(diffRows, diffTotal, diffGenerated, diffOther), '', '', '', '
', `${formatChunkChangeSummary('Startup chunk size', startupSummary)}`, '', - chunkMarkdownTable(startupRows, startupTotal, startupGenerated), + chunkMarkdownTable(startupRows, startupTotal, startupGenerated, startupOther), '', `_Startup chunks are the Vite entry for \`src/_boot_.ts\` and its static imports._`, '', diff --git a/.github/scripts/frontend-js-size.test.mts b/.github/scripts/frontend-js-size.test.mts index 347d21548c..f688ae3270 100644 --- a/.github/scripts/frontend-js-size.test.mts +++ b/.github/scripts/frontend-js-size.test.mts @@ -86,10 +86,10 @@ test('groups generated chunks while preserving full and startup totals', async t const report = await runReport( t, fixture('before', 'dist', { entry: 100, generatedA: 10, generatedB: 20, vue: 40, i18n: 50 }), - fixture('after', 'esm', { entry: 110, generatedA: 30, generatedB: 40, vue: 45, i18n: 50 }), + fixture('after', 'esm', { entry: 110, generatedA: 30, generatedB: 40, vue: 55, i18n: 50 }), ); - assert.match(report, /\| \(total\) \| 220 B \| 275 B \|/); + assert.match(report, /\| \(total\) \| 220 B \| 285 B \|/); assert.equal(report.match(/\| \(other generated chunks\) \| 30 B \| 70 B \|/g)?.length, 2); assert.equal(report.match(/_2 before \/ 2 after generated chunks are grouped\._/g)?.length, 2); assert.doesNotMatch(report, /`(?:dist|esm)`<\/summary>/); @@ -97,6 +97,27 @@ test('groups generated chunks while preserving full and startup totals', async t assert.match(report, /`vue`<\/summary>/); }); +test('groups small deltas at the bottom while preserving all change counts', async t => { + const before = fixture('before', 'dist', { entry: 100, generatedA: 10, generatedB: 20, vue: 40, i18n: 50 }); + before.manifest._removedSmall = { file: 'scripts/removed-small-before.js', src: 'src/removed-small.ts' }; + before.manifest['src/_boot_.ts'].imports?.push('_removedSmall'); + before.sizes['scripts/removed-small-before.js'] = 5; + + const after = fixture('after', 'esm', { entry: 106, generatedA: 30, generatedB: 40, vue: 45, i18n: 50 }); + after.manifest._addedSmall = { file: 'scripts/added-small-after.js', src: 'src/added-small.ts' }; + after.manifest['src/_boot_.ts'].imports?.push('_addedSmall'); + after.sizes['scripts/added-small-after.js'] = 5; + + const report = await runReport(t, before, after); + + assert.match(report, /Chunk size diff \(2 updated, 1 added, 1 removed\)<\/summary>/); + assert.match(report, /Startup chunk size \(2 updated, 1 added, 1 removed\)<\/summary>/); + assert.equal(report.match(/`src\/_boot_\.ts`<\/summary>/g)?.length, 2); + assert.doesNotMatch(report, /`(?:vue|i18n|src\/added-small\.ts|src\/removed-small\.ts)`<\/summary>/); + assert.match(report, /\| \(total\) \| 225 B \| 276 B \|[^\n]*\n\|
`src\/_boot_\.ts`<\/summary>[^\n]*\n\| \| \| \| \| \|\n\| \(other generated chunks\) \| 30 B \| 70 B \|[^\n]*\n\| \(other\) \| 45 B \| 50 B \|/); + assert.match(report, /\| \(total\) \| 225 B \| 276 B \|[^\n]*\n\|
`src\/_boot_\.ts`<\/summary>[^\n]*\n\| \| \| \| \| \|\n\| \(other generated chunks\) \| 30 B \| 70 B \|[^\n]*\n\| \(other\) \| 95 B \| 100 B \|/); +}); + test('fails instead of overwriting duplicate stable chunk keys', async t => { const duplicateVue = fixture('before', 'dist', { entry: 100, generatedA: 10, generatedB: 20, vue: 40, i18n: 50 }); duplicateVue.manifest._vueDuplicate = { file: 'scripts/vue-duplicate-before.js', name: 'vue' }; @@ -115,7 +136,7 @@ test('shows both filenames for an updated stable chunk', async t => { const report = await runReport( t, fixture('before', 'dist', { entry: 100, generatedA: 10, generatedB: 20, vue: 40, i18n: 50 }), - fixture('after', 'esm', { entry: 110, generatedA: 30, generatedB: 40, vue: 45, i18n: 50 }), + fixture('after', 'esm', { entry: 110, generatedA: 30, generatedB: 40, vue: 55, i18n: 50 }), ); assert.match(report, /`ja-JP\/entry-before\.js → ja-JP\/entry-after\.js`/); diff --git a/.gitignore b/.gitignore index 748b9f5d18..e975691b93 100644 --- a/.gitignore +++ b/.gitignore @@ -83,6 +83,3 @@ vite.config.local-dev.ts.timestamp-* # Affinity *.af~lock~ - -# Agent worktrees -/.worktrees/ diff --git a/docs/superpowers/plans/2026-07-16-frontend-bundle-small-delta-aggregation.md b/docs/superpowers/plans/2026-07-16-frontend-bundle-small-delta-aggregation.md deleted file mode 100644 index 8b0ac359f3..0000000000 --- a/docs/superpowers/plans/2026-07-16-frontend-bundle-small-delta-aggregation.md +++ /dev/null @@ -1,114 +0,0 @@ -# Frontend Bundle Small-Delta Aggregation 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:** Group stable frontend chunks with an absolute size delta of at most `5 B` into a bottom-of-table `(other)` row while preserving complete change counts. - -**Architecture:** Keep comparison and summary calculation unchanged, then partition render candidates into significant and small-delta rows. Render individual rows first and append generated/small-delta aggregates after one empty separator row in both full and startup tables. - -**Tech Stack:** TypeScript, Node.js test runner, Markdown report generation. - -## Global Constraints - -- The threshold is inclusive: `Math.abs(afterSize - beforeSize) <= 5` is grouped. -- Summary updated/added/removed counts use all stable changed rows before grouping. -- Full-report candidates are changed stable rows; startup candidates include every stable startup row, including unchanged rows. -- Aggregate order is `(other generated chunks)` followed by `(other)`. -- A single empty Markdown table row separates individual rows from aggregates. -- The existing individual-row limit is applied after small-delta rows are removed. -- Do not run code review or repository-wide lint, per the user's request. - ---- - -### Task 1: Aggregate and move small-delta rows - -**Files:** -- Modify: `.github/scripts/frontend-js-size.test.mts` -- Modify: `.github/scripts/frontend-js-size.mts:350-520` -- Verify: `docs/superpowers/specs/2026-07-15-frontend-bundle-generated-chunks-design.md` - -**Interfaces:** -- Consumes: stable comparison rows from `getChunkComparisonRows`, physical generated aggregates, and the existing `chunkMarkdownTable` renderer. -- Produces: a `ChunkAggregate` for small-delta rows and Markdown ordered as total, significant rows, separator, generated aggregate, small-delta aggregate. - -- [ ] **Step 1: Write the failing end-to-end test** - -Add a test based on the existing `fixture` helper. Use an entry delta of `+6 B`, a Vue delta of `+5 B`, unchanged i18n, a `5 B` removed source chunk in the before startup imports, and a `5 B` added source chunk in the after startup imports. - -Assert all of the following: - -```ts -assert.match(report, /Chunk size diff \(2 updated, 1 added, 1 removed\)<\/summary>/); -assert.match(report, /Startup chunk size \(2 updated, 1 added, 1 removed\)<\/summary>/); -assert.match(report, /`src\/_boot_\.ts`<\/summary>/); -assert.doesNotMatch(report, /`(?:vue|i18n|src\/added-small\.ts|src\/removed-small\.ts)`<\/summary>/); -assert.match(report, /\| \| \| \| \| \|\n\| \(other generated chunks\) \| 30 B \| 70 B \|[^\n]*\n\| \(other\) \| 45 B \| 50 B \|/); -assert.match(report, /\| \| \| \| \| \|\n\| \(other generated chunks\) \| 30 B \| 70 B \|[^\n]*\n\| \(other\) \| 95 B \| 100 B \|/); -``` - -- [ ] **Step 2: Run the focused test and verify RED** - -Run: - -```powershell -& 'C:\Program Files\nodejs\node.exe' --test .github/scripts/frontend-js-size.test.mts -``` - -Expected: the new test fails because `(other)` is absent and `(other generated chunks)` is still directly below `(total)`. - -- [ ] **Step 3: Add the small-delta partition and aggregate** - -Add a constant and helpers near `ChunkAggregate`: - -```ts -const smallDeltaThreshold = 5; - -type ChunkComparisonRow = ReturnType[number]; - -function hasSmallDelta(row: ChunkComparisonRow) { - return Math.abs(row.afterSize - row.beforeSize) <= smallDeltaThreshold; -} - -function comparisonRowsAggregate(rows: ChunkComparisonRow[]): ChunkAggregate { - return { - beforeSize: rows.reduce((sum, row) => sum + row.beforeSize, 0), - afterSize: rows.reduce((sum, row) => sum + row.afterSize, 0), - beforeCount: rows.filter(row => row.beforeFile != null).length, - afterCount: rows.filter(row => row.afterFile != null).length, - }; -} -``` - -Calculate summaries before filtering. Partition `changedRows` and `startupComparisonRows`, aggregate small rows, and apply sorting/30-row limiting only to rows for which `hasSmallDelta` is false. - -- [ ] **Step 4: Render aggregate rows at the table bottom** - -Extend `chunkMarkdownTable` with an optional `other?: ChunkAggregate`. Render `(total)`, then individual rows. If either aggregate contains chunks, append one `| | | | | |` separator, followed by `(other generated chunks)` and `(other)` when present. - -Use the existing numeric formatting for both aggregates: - -```ts -`| (other) | ${util.formatBytes(other.beforeSize)} | ${util.formatBytes(other.afterSize)} | ${util.calcAndFormatDeltaBytes(other.beforeSize, other.afterSize, 1000)} | ${util.calcAndFormatDeltaPercent(other.beforeSize, other.afterSize, 0.1).replaceAll('\\%', '\\\\%')} |` -``` - -Pass the full and startup small-delta aggregates to their corresponding table calls. - -- [ ] **Step 5: Run the focused suite and verify GREEN** - -Run: - -```powershell -& 'C:\Program Files\nodejs\node.exe' --test .github/scripts/frontend-js-size.test.mts -``` - -Expected: all existing tests plus the new test pass with zero failures and clean output. - -- [ ] **Step 6: Remove this transient implementation plan and commit** - -Delete `docs/superpowers/plans/2026-07-16-frontend-bundle-small-delta-aggregation.md`, then run: - -```powershell -git diff --check -git add .github/scripts/frontend-js-size.mts .github/scripts/frontend-js-size.test.mts -git commit -m "fix(dev): group small frontend chunk deltas" -```