From d74a7a515cfaf2236ce9f777cf315e9842ad1894 Mon Sep 17 00:00:00 2001 From: syuilo <4439005+syuilo@users.noreply.github.com> Date: Wed, 15 Jul 2026 20:46:42 +0900 Subject: [PATCH 1/8] docs: design generated chunk handling --- ...frontend-bundle-generated-chunks-design.md | 153 ++++++++++++++++++ 1 file changed, 153 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-15-frontend-bundle-generated-chunks-design.md diff --git a/docs/superpowers/specs/2026-07-15-frontend-bundle-generated-chunks-design.md b/docs/superpowers/specs/2026-07-15-frontend-bundle-generated-chunks-design.md new file mode 100644 index 0000000000..95e0de006b --- /dev/null +++ b/docs/superpowers/specs/2026-07-15-frontend-bundle-generated-chunks-design.md @@ -0,0 +1,153 @@ +# Frontend bundle report: generated chunk handling + +## Background + +The frontend bundle report compares the Vite manifests produced for the pull request base and head builds. Chunks with a `src` value are currently identified by that source path. Chunks without `src` are identified by Rolldown's generated `name`. + +Generated names are not unique or stable identities. Multiple unrelated shared chunks can all be called `dist`, `esm`, `index`, or a similar path-derived name. The current collection logic stores those chunks in a `Map` under `chunk:`, so later entries overwrite earlier entries. This produces two problems: + +1. unrelated chunks can be compared as if they were the same chunk; and +2. overwritten chunks are omitted from the reported total. + +Individual size changes for these generated shared chunks are not sufficiently actionable to justify heuristic matching. + +## Goals + +- Never compare unrelated generated chunks as the same chunk. +- Keep generated chunks out of the individual chunk-diff rows. +- Preserve the contribution of every physical JavaScript chunk in totals. +- Show the aggregate size change of generated chunks so unexplained bundle growth remains visible. +- Apply the same rules to the full chunk report and the startup chunk report. +- Continue comparing source-backed chunks and intentionally named chunks. + +## Non-goals + +- Inferring chunk identity from module-set similarity. +- Preserving an `updated` relationship for every shared chunk after code splitting changes. +- Changing the frontend's code-splitting strategy or output filenames. +- Suppressing specific names such as `esm` or `dist` with a denylist. + +## Approaches considered + +### Denylist generated-looking names + +Exclude names such as `esm`, `dist`, `lib`, and `index`. + +This is not selected because Rolldown can generate many other names, and a denylist can also hide an intentionally named chunk. It would leave the underlying name collision and total-size bug in place. + +### Compare only stable identities and aggregate the rest + +Classify chunks by whether the report has a stable semantic identity. Source-backed chunks and explicitly supported manual chunk names remain individually comparable. All other generated chunks are retained as physical files but shown only as an aggregate. + +This is the selected approach. It removes false comparisons without introducing matching heuristics, and it keeps all bytes visible. + +### Match shared chunks by their module sets + +Use visualizer metadata to compare exact or similar sets of module IDs. + +This could retain more individual diffs, but splits, merges, dependency-version paths, and small module movements make the matching policy complex and potentially misleading. It can be added later if aggregate data proves insufficient. + +## Design + +### Separate physical chunks from comparable identities + +`collectReport` will no longer use a single map keyed by the comparison identity as its source of truth. It will collect every resolved JavaScript output file exactly once into a physical chunk collection. + +Each physical chunk contains: + +- its resolved relative output path; +- its byte size; +- its manifest key, when present; +- its display name; and +- an optional comparison key. + +The physical output path is used only for de-duplication within one build. It is not used to match changed chunks across builds. + +### Comparison-key classification + +A chunk receives a comparison key only in one of these cases: + +1. `chunk.src` is present: use `src:`. +2. `chunk.name` is in an explicit allowlist of intentionally stable manual chunks: use `named:`. + +The initial stable-name allowlist is: + +- `vue` +- `i18n` + +These names correspond to the explicit `codeSplitting.groups` configuration in `packages/frontend/vite.config.ts`. + +All other manifest chunks without `src`, including generated names such as `esm` and `dist`, receive no comparison key. JavaScript files found in the localized output directory but absent from the manifest also receive no comparison key. + +If a supposedly stable comparison key occurs more than once within one build, the report must not silently overwrite it. Collection will fail with a descriptive error because duplicate `src` or allowlisted manual names violate the assumptions required for a correct comparison. + +### Full chunk report + +The report computes three independent values: + +1. **Total:** the sum of every unique physical JavaScript chunk. +2. **Generated chunk aggregate:** the sum of chunks without a comparison key. +3. **Individual rows:** before/after comparisons for stable comparison keys only. + +The table starts with the existing `(total)` row. When either build contains generated chunks, it then includes one aggregate row such as: + +```text +(other generated chunks) +``` + +This row compares aggregate sizes, not individual chunk identities. Generated chunks do not participate in the updated/added/removed row counts. + +The report includes a short note stating how many generated chunks were grouped on each side. This makes the scope of the aggregate explicit without listing noisy filenames. + +### Startup chunk report + +Startup traversal continues to follow the entry chunk's static `imports`, but it records manifest keys or resolved physical file paths rather than generated comparison keys. This prevents two startup chunks named `dist` from collapsing into one. + +Startup totals include every unique physical startup chunk. Stable startup chunks receive individual rows, while startup chunks without stable identities contribute to the same `(other generated chunks)` aggregate row within the startup table. + +### Displayed filenames + +For an individually comparable chunk, the details should expose both filenames when they differ. A single before-side filename must not imply that the after size belongs to that same file. + +The display can use one filename when unchanged and `before → after` when the content-hashed filename differs. + +The generated aggregate row does not display an arbitrary representative filename. + +## Data flow + +1. Read each build's Vite manifest. +2. Resolve localized output files and collect unique physical chunks. +3. Attach optional stable comparison keys using the classification rules. +4. Traverse startup imports using manifest identities and map them to physical chunks. +5. Calculate full and startup totals from physical chunks. +6. Calculate generated aggregates from chunks without comparison keys. +7. Compare only unique stable keys for individual rows. +8. Render totals, generated aggregates, and stable rows. + +## Error handling + +- A manifest entry whose expected localized JavaScript file is absent remains a fatal report-generation error. +- Duplicate physical output paths are de-duplicated and counted once. +- Duplicate stable comparison keys fail with an error that includes the key and conflicting files. +- Missing or malformed manifest data remains fatal rather than producing a partial report. + +## Testing + +Add focused fixtures or pure-function tests covering: + +- two unrelated `dist` chunks are both counted in total and grouped into the generated aggregate; +- `dist` and `esm` chunks never produce individual comparison rows; +- source-backed chunks with the same `src` are reported as updated; +- source-backed additions and removals remain visible; +- allowlisted `vue` and `i18n` chunks remain individually comparable; +- duplicate stable keys produce a descriptive error instead of overwriting; +- full totals equal the sum of all unique physical chunks; +- startup totals include multiple same-name generated chunks exactly once each; +- generated aggregate changes do not affect the updated/added/removed summary counts; and +- differing before/after filenames are rendered without attributing both sizes to one file. + +Validation should include the focused tests and repository lint. No CHANGELOG entry is required because this changes developer-facing CI reporting rather than Misskey user behavior. + +## Future extension + +If aggregate generated-chunk data is later found insufficient, visualizer module metadata can support a separate opt-in analysis. That extension must preserve the physical-chunk accounting introduced here and must not reintroduce name-based identity. From 9076a4ecf33ad5a8854973d47baa62b1f89db6aa Mon Sep 17 00:00:00 2001 From: syuilo <4439005+syuilo@users.noreply.github.com> Date: Wed, 15 Jul 2026 20:56:56 +0900 Subject: [PATCH 2/8] docs: plan generated chunk reporting fix --- ...-07-15-frontend-bundle-generated-chunks.md | 661 ++++++++++++++++++ 1 file changed, 661 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-15-frontend-bundle-generated-chunks.md diff --git a/docs/superpowers/plans/2026-07-15-frontend-bundle-generated-chunks.md b/docs/superpowers/plans/2026-07-15-frontend-bundle-generated-chunks.md new file mode 100644 index 0000000000..6a58d5e322 --- /dev/null +++ b/docs/superpowers/plans/2026-07-15-frontend-bundle-generated-chunks.md @@ -0,0 +1,661 @@ +# Frontend Bundle Generated Chunks 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:** Prevent generated `esm`/`dist`-style chunks from being compared individually while retaining every JavaScript output file in full and startup totals. + +**Architecture:** Keep physical chunk accounting separate from stable before/after identities. Source-backed chunks and the explicit `vue`/`i18n` code-splitting groups remain individually comparable; all other generated chunks are grouped into one aggregate row. Exercise the report generator through filesystem fixtures so tests cover manifest parsing, localized file resolution, startup traversal, and Markdown rendering together. + +**Tech Stack:** Node.js 26.4, TypeScript `.mts` scripts with native type stripping, Node test runner, Vite manifest JSON, GitHub Actions. + +## Global Constraints + +- Never compare unrelated generated chunks as the same chunk. +- Keep generated chunks out of the individual chunk-diff rows. +- Preserve the contribution of every physical JavaScript chunk in totals. +- Show the aggregate size change of generated chunks so unexplained bundle growth remains visible. +- Apply the same rules to the full chunk report and the startup chunk report. +- Continue comparing source-backed chunks and intentionally named chunks. +- The only stable generated-name allowlist entries are `vue` and `i18n`. +- Do not infer chunk identity from module-set similarity. +- Do not change the frontend code-splitting strategy or output filenames. +- Do not suppress generated chunks with a name denylist. +- Do not manually edit locale files other than `locales/ja-JP.yml`; this change does not require any locale edit. +- New `.mts` files must include the repository SPDX header. +- This developer-facing report change does not require a `CHANGELOG.md` entry. + +## File Structure + +- Create `.github/scripts/frontend-js-size.test.mts`: end-to-end fixtures for manifest collection and Markdown output. +- Modify `.github/scripts/frontend-js-size.mts`: physical chunk collection, stable identity classification, generated aggregation, startup accounting, duplicate-key validation, and filename rendering. +- Modify `.github/workflows/lint.yml`: run the focused Node test whenever the report script or its utility changes. +- Reference `docs/superpowers/specs/2026-07-15-frontend-bundle-generated-chunks-design.md`: approved behavior and non-goals; no implementation edit is required. + +--- + +### Task 1: Preserve all physical chunks and aggregate generated chunks + +**Files:** +- Create: `.github/scripts/frontend-js-size.test.mts` +- Modify: `.github/scripts/frontend-js-size.mts:39-134` +- Modify: `.github/scripts/frontend-js-size.mts:315-426` + +**Interfaces:** +- Consumes: Vite `manifest.json`, localized JavaScript files under `built/_frontend_vite_/ja-JP`, and the existing five CLI arguments of `frontend-js-size.mts`. +- Produces: `CollectedReport` with `chunks: FileEntry[]`, `comparableChunks: Record`, `chunksByManifestKey: Record`, and `startupFiles: string[]`; Markdown with `(total)` and `(other generated chunks)` rows. + +- [ ] **Step 1: Write the failing end-to-end test** + +Create `.github/scripts/frontend-js-size.test.mts` with this initial content: + +```ts +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import assert from 'node:assert/strict'; +import { promises as fs } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import test, { type TestContext } from 'node:test'; +import * as util from './utility.mts'; + +type Manifest = Record; + +const repoDir = path.resolve(import.meta.dirname, '../..'); +const reportScript = path.join(repoDir, '.github/scripts/frontend-js-size.mts'); + +async function writeBuild(repo: string, manifest: Manifest, sizes: Record) { + const outDir = path.join(repo, 'built/_frontend_vite_'); + await fs.mkdir(path.join(outDir, 'ja-JP'), { recursive: true }); + await fs.writeFile(path.join(outDir, 'manifest.json'), JSON.stringify(manifest)); + for (const [file, size] of Object.entries(sizes)) { + const localizedFile = file.replace(/^scripts\//, ''); + const outputFile = path.join(outDir, 'ja-JP', localizedFile); + await fs.mkdir(path.dirname(outputFile), { recursive: true }); + await fs.writeFile(outputFile, Buffer.alloc(size)); + } +} + +async function runReport(t: TestContext, before: { manifest: Manifest; sizes: Record }, after: { manifest: Manifest; sizes: Record }) { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'frontend-js-size-')); + t.after(() => fs.rm(root, { recursive: true, force: true })); + const beforeDir = path.join(root, 'before'); + const afterDir = path.join(root, 'after'); + const beforeStats = path.join(root, 'before-stats.json'); + const afterStats = path.join(root, 'after-stats.json'); + const reportFile = path.join(root, 'report.md'); + await writeBuild(beforeDir, before.manifest, before.sizes); + await writeBuild(afterDir, after.manifest, after.sizes); + await fs.writeFile(beforeStats, '{}'); + await fs.writeFile(afterStats, '{}'); + await util.run(process.execPath, [reportScript, beforeDir, afterDir, beforeStats, afterStats, reportFile]); + return fs.readFile(reportFile, 'utf8'); +} + +function fixture(suffix: string, generatedName: string, sizes: { entry: number; generatedA: number; generatedB: number; vue: number; i18n: number }) { + const files = { + entry: `scripts/entry-${suffix}.js`, + generatedA: `scripts/generated-a-${suffix}.js`, + generatedB: `scripts/generated-b-${suffix}.js`, + vue: `scripts/vue-${suffix}.js`, + i18n: `scripts/i18n-${suffix}.js`, + }; + return { + manifest: { + 'src/_boot_.ts': { file: files.entry, src: 'src/_boot_.ts', name: 'entry', isEntry: true, imports: ['_generatedA', '_generatedB', '_vue', '_i18n'] }, + _generatedA: { file: files.generatedA, name: generatedName }, + _generatedB: { file: files.generatedB, name: generatedName }, + _vue: { file: files.vue, name: 'vue' }, + _i18n: { file: files.i18n, name: 'i18n' }, + }, + sizes: Object.fromEntries(Object.entries(files).map(([key, file]) => [file, sizes[key as keyof typeof sizes]])), + }; +} + +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 }), + ); + + assert.match(report, /\| \(total\) \| 220 B \| 275 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>/); + assert.match(report, /`src\/_boot_\.ts`<\/summary>/); + assert.match(report, /`vue`<\/summary>/); +}); +``` + +- [ ] **Step 2: Run the test and verify the current implementation fails** + +Run: + +```bash +node --test .github/scripts/frontend-js-size.test.mts +``` + +Expected: FAIL because `(total)` omits one of the duplicate generated-name chunks, no `(other generated chunks)` row exists, and an individual `dist` or `esm` row is rendered. + +- [ ] **Step 3: Replace name-based identity with physical accounting and stable classification** + +In `.github/scripts/frontend-js-size.mts`, replace `FileEntry`, `stableChunkKey`, `collectStartupKeys`, and `collectReport` with these structures and functions while retaining the existing `resolveBuiltFile` function: + +```ts +const stableNamedChunks = new Set(['vue', 'i18n']); + +type FileEntry = { + comparisonKey: string | null; + displayName: string; + file: string; + manifestKeys: string[]; + size: number; +}; + +type CollectedReport = { + manifest: Manifest; + chunks: FileEntry[]; + comparableChunks: Record; + chunksByManifestKey: Record; + startupFiles: string[]; +}; + +function stableChunkKey(chunk: Manifest[string]) { + if (chunk.src != null) return `src:${util.normalizePath(chunk.src)}`; + if (chunk.name != null && stableNamedChunks.has(chunk.name)) return `named:${chunk.name}`; + return null; +} + +function collectStartupManifestKeys(manifest: Manifest) { + const entryKey = findEntryKey(manifest); + const keys = new Set(); + if (entryKey == null) return keys; + + function visit(key: string) { + if (keys.has(key)) return; + const chunk = manifest[key]; + if (!chunk || !chunk.file?.endsWith('.js')) return; + keys.add(key); + for (const importKey of chunk.imports ?? []) visit(importKey); + } + + visit(entryKey); + return keys; +} + +async function collectReport(repoDir: string): Promise { + const outDir = path.join(repoDir, 'built/_frontend_vite_'); + const manifestPath = path.join(outDir, 'manifest.json'); + const manifest = JSON.parse(await fs.readFile(manifestPath, 'utf8')) as Manifest; + const chunksByFile = new Map(); + const comparableChunks = new Map(); + const chunksByManifestKey = new Map(); + + for (const [manifestKey, chunk] of Object.entries(manifest)) { + if (!chunk.file?.endsWith('.js')) continue; + const builtFile = await resolveBuiltFile(outDir, chunk.file); + const comparisonKey = stableChunkKey(chunk); + let entry = chunksByFile.get(builtFile.relativePath); + if (entry == null) { + entry = { + comparisonKey, + displayName: chunk.src ?? chunk.name ?? manifestKey, + file: builtFile.relativePath, + manifestKeys: [manifestKey], + size: await util.fileSize(builtFile.absolutePath), + }; + chunksByFile.set(entry.file, entry); + } else if (entry.comparisonKey !== comparisonKey) { + throw new Error(`Conflicting identities for ${entry.file}`); + } else { + entry.manifestKeys.push(manifestKey); + } + chunksByManifestKey.set(manifestKey, entry); + if (comparisonKey != null) { + const existing = comparableChunks.get(comparisonKey); + if (existing != null && existing.file !== entry.file) { + throw new Error(`Duplicate stable chunk key "${comparisonKey}": ${existing.file}, ${entry.file}`); + } + comparableChunks.set(comparisonKey, entry); + } + } + + const localeDir = path.join(outDir, locale); + if (await util.fileExists(localeDir)) { + for await (const fullPath of util.traverseDirectory(localeDir)) { + if (!fullPath.endsWith('.js')) continue; + const relativePath = util.normalizePath(path.relative(outDir, fullPath)); + if (chunksByFile.has(relativePath)) continue; + chunksByFile.set(relativePath, { + comparisonKey: null, + displayName: relativePath, + file: relativePath, + manifestKeys: [], + size: await util.fileSize(fullPath), + }); + } + } + + const startupFiles = new Set(); + for (const manifestKey of collectStartupManifestKeys(manifest)) { + const entry = chunksByManifestKey.get(manifestKey); + if (entry != null) startupFiles.add(entry.file); + } + + return { + manifest, + chunks: [...chunksByFile.values()], + comparableChunks: Object.fromEntries(comparableChunks), + chunksByManifestKey: Object.fromEntries(chunksByManifestKey), + startupFiles: [...startupFiles], + }; +} +``` + +- [ ] **Step 4: Update comparison rows and table rendering** + +Change `getChunkComparisonRows` to consume comparable maps and retain both filenames: + +```ts +function getChunkComparisonRows(keys: string[], before: Record, after: Record) { + return keys.map(key => { + const beforeEntry = before[key]; + const afterEntry = after[key]; + const beforeSize = beforeEntry?.size ?? 0; + const afterSize = afterEntry?.size ?? 0; + return { + key, + name: entryDisplayName(beforeEntry ?? afterEntry), + beforeFile: beforeEntry?.file, + afterFile: afterEntry?.file, + beforeSize, + afterSize, + changeType: beforeEntry == null ? 'added' : afterEntry == null ? 'removed' : beforeSize !== afterSize ? 'updated' : 'unchanged', + sortSize: Math.max(beforeSize, afterSize), + }; + }); +} + +type ChunkAggregate = { + beforeSize: number; + afterSize: number; + beforeCount: number; + afterCount: number; +}; + +function sumChunkSizes(chunks: FileEntry[]) { + return chunks.reduce((sum, chunk) => sum + chunk.size, 0); +} + +function generatedAggregate(before: FileEntry[], after: FileEntry[]): ChunkAggregate { + const beforeGenerated = before.filter(chunk => chunk.comparisonKey == null); + const afterGenerated = after.filter(chunk => chunk.comparisonKey == null); + return { + beforeSize: sumChunkSizes(beforeGenerated), + afterSize: sumChunkSizes(afterGenerated), + beforeCount: beforeGenerated.length, + afterCount: afterGenerated.length, + }; +} + +function comparableMap(chunks: FileEntry[]) { + const entries: [string, FileEntry][] = []; + for (const chunk of chunks) { + if (chunk.comparisonKey != null) entries.push([chunk.comparisonKey, chunk]); + } + return Object.fromEntries(entries); +} +``` + +Replace `chunkMarkdownTable` with this version, which adds a third `generated` argument: + +```ts +function chunkMarkdownTable( + rows: ReturnType, + total?: { beforeSize: number; afterSize: number }, + generated?: ChunkAggregate, +) { + if (rows.length === 0 && total == null && generated == null) 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 = row.beforeFile ?? row.afterFile ?? ''; + if (row.changeType === 'added') { + lines.push(`|
\`${escapeCell(row.name)}\` \`${escapeCell(chunkFile)}\`
| ${util.formatBytes(row.beforeSize)} | ${util.formatBytes(row.afterSize)} | ${util.calcAndFormatDeltaBytes(row.beforeSize, row.afterSize, 1000)} | $\\color{orange}{\\text{( + )}}$ |`); + } else if (row.changeType === 'removed') { + lines.push(`|
\`${escapeCell(row.name)}\` \`${escapeCell(chunkFile)}\`
| ${util.formatBytes(row.beforeSize)} | ${util.formatBytes(row.afterSize)} | ${util.calcAndFormatDeltaBytes(row.beforeSize, row.afterSize, 1000)} | $\\color{green}{\\text{( - )}}$ |`); + } else { + 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)) { + lines.push(''); + lines.push(`_${generated.beforeCount} before / ${generated.afterCount} after generated chunks are grouped._`); + } + return lines.join('\n'); +} +``` + +Update `renderFrontendChunkReport` so the full report uses all physical chunks for totals, stable maps for rows, and unidentifiable chunks for the aggregate: + +```ts +const beforeComparable = before.comparableChunks; +const afterComparable = after.comparableChunks; +const allChunkKeys = [...new Set([...Object.keys(beforeComparable), ...Object.keys(afterComparable)])]; +const allComparisonRows = getChunkComparisonRows(allChunkKeys, beforeComparable, afterComparable); +const diffTotal = { + beforeSize: sumChunkSizes(before.chunks), + afterSize: sumChunkSizes(after.chunks), +}; +const diffGenerated = generatedAggregate(before.chunks, after.chunks); +``` + +For startup accounting, filter physical chunks by `startupFiles`, rebuild comparable maps from those filtered arrays, and calculate totals and aggregate from the filtered arrays: + +```ts +const beforeStartupFiles = new Set(before.startupFiles); +const afterStartupFiles = new Set(after.startupFiles); +const beforeStartupChunks = before.chunks.filter(chunk => beforeStartupFiles.has(chunk.file)); +const afterStartupChunks = after.chunks.filter(chunk => afterStartupFiles.has(chunk.file)); +const beforeStartupComparable = comparableMap(beforeStartupChunks); +const afterStartupComparable = comparableMap(afterStartupChunks); +const startupKeys = [...new Set([...Object.keys(beforeStartupComparable), ...Object.keys(afterStartupComparable)])]; +const startupComparisonRows = getChunkComparisonRows(startupKeys, beforeStartupComparable, afterStartupComparable); +const startupTotal = { + beforeSize: sumChunkSizes(beforeStartupChunks), + afterSize: sumChunkSizes(afterStartupChunks), +}; +const startupGenerated = generatedAggregate(beforeStartupChunks, afterStartupChunks); +``` + +Pass the aggregates with these exact calls: + +```ts +chunkMarkdownTable(diffRows, diffTotal, diffGenerated) +chunkMarkdownTable(startupRows, startupTotal, startupGenerated) +``` + +- [ ] **Step 5: Run the focused test and verify it passes** + +Run: + +```bash +node --test .github/scripts/frontend-js-size.test.mts +``` + +Expected: PASS with `1` test and `0` failures. The Markdown contains two aggregate rows because the fixture makes all chunks startup chunks as well as full-report chunks. + +- [ ] **Step 6: Commit the physical-accounting change** + +```bash +git add .github/scripts/frontend-js-size.mts .github/scripts/frontend-js-size.test.mts +git commit -m "fix(dev): aggregate generated frontend chunks" +``` + +--- + +### Task 2: Reject duplicate stable identities and render honest filenames + +**Files:** +- Modify: `.github/scripts/frontend-js-size.test.mts` +- Modify: `.github/scripts/frontend-js-size.mts:315-372` + +**Interfaces:** +- Consumes: the `Duplicate stable chunk key` validation and comparison rows produced in Task 1. +- Produces: a fatal diagnostic for duplicate `src`/allowlisted identities and a file label that renders `before → after` when hashes differ. + +- [ ] **Step 1: Add failing coverage for duplicate stable names and filename changes** + +Append these tests to `.github/scripts/frontend-js-size.test.mts`: + +```ts +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' }; + duplicateVue.sizes['scripts/vue-duplicate-before.js'] = 60; + + await assert.rejects( + runReport(t, duplicateVue, fixture('after', 'esm', { entry: 110, generatedA: 30, generatedB: 40, vue: 45, i18n: 50 })), + /Duplicate stable chunk key "named:vue".*vue-before\.js.*vue-duplicate-before\.js/, + ); +}); + +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 }), + ); + + assert.match(report, /`ja-JP\/entry-before\.js → ja-JP\/entry-after\.js`/); + assert.match(report, /`ja-JP\/vue-before\.js → ja-JP\/vue-after\.js`/); +}); +``` + +- [ ] **Step 2: Run the tests and confirm the filename test fails** + +Run: + +```bash +node --test .github/scripts/frontend-js-size.test.mts +``` + +Expected: the duplicate-key test passes using Task 1 validation, while `shows both filenames for an updated stable chunk` fails because the table still shows only one file. + +- [ ] **Step 3: Render both filenames without changing identity logic** + +Add this helper near `chunkMarkdownTable`: + +```ts +function chunkFileDisplay(row: ReturnType[number]) { + if (row.beforeFile == null) return row.afterFile ?? ''; + if (row.afterFile == null || row.beforeFile === row.afterFile) return row.beforeFile; + return `${row.beforeFile} → ${row.afterFile}`; +} +``` + +At the start of each `for (const row of rows)` iteration, compute: + +```ts +const chunkFile = chunkFileDisplay(row); +``` + +Replace every `${escapeCell(row.chunkFile)}` interpolation in the three row variants with `${escapeCell(chunkFile)}`. Do not display any representative filename on the aggregate row. + +- [ ] **Step 4: Run all focused tests** + +Run: + +```bash +node --test .github/scripts/frontend-js-size.test.mts +``` + +Expected: PASS with `3` tests and `0` failures. + +- [ ] **Step 5: Commit the validation and display behavior** + +```bash +git add .github/scripts/frontend-js-size.mts .github/scripts/frontend-js-size.test.mts +git commit -m "fix(dev): clarify frontend chunk comparisons" +``` + +--- + +### Task 3: Run the report regression test in CI + +**Files:** +- Modify: `.github/workflows/lint.yml:3-33` +- Modify: `.github/workflows/lint.yml:139` + +**Interfaces:** +- Consumes: `.github/scripts/frontend-js-size.test.mts` from Tasks 1 and 2. +- Produces: a `frontend-bundle-report-test` GitHub Actions job using the repository's `.node-version`. + +- [ ] **Step 1: Verify the focused test is not currently referenced by CI** + +Run: + +```bash +rg -n "frontend-js-size.test|frontend-bundle-report-test" .github/workflows +``` + +Expected: no matches. + +- [ ] **Step 2: Add report-script paths to both lint workflow filters** + +Under both `on.push.paths` and `on.pull_request.paths` in `.github/workflows/lint.yml`, add: + +```yaml + - .github/scripts/frontend-js-size*.mts + - .github/scripts/utility.mts +``` + +Keep `.github/workflows/lint.yml` itself in both filters. + +- [ ] **Step 3: Add a focused CI job** + +Append this job under `jobs` at the same level as `check-dts`: + +```yaml + frontend-bundle-report-test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6.0.3 + - uses: actions/setup-node@v6.4.0 + with: + node-version-file: '.node-version' + - run: node --test .github/scripts/frontend-js-size.test.mts +``` + +This job intentionally does not depend on `pnpm_install` because the test and report generator use only Node built-ins and the local `utility.mts` module. + +- [ ] **Step 4: Run the exact CI test command locally** + +Run: + +```bash +node --test .github/scripts/frontend-js-size.test.mts +``` + +Expected: PASS with `3` tests and `0` failures. + +- [ ] **Step 5: Confirm CI now references the test exactly once** + +Run: + +```bash +rg -n "node --test \.github/scripts/frontend-js-size\.test\.mts" .github/workflows/lint.yml +``` + +Expected: one match in the `frontend-bundle-report-test` job. + +- [ ] **Step 6: Commit the CI coverage** + +```bash +git add .github/workflows/lint.yml +git commit -m "test(dev): check frontend bundle report" +``` + +--- + +### Task 4: Complete Misskey pre-ship validation + +**Files:** +- Verify: `.github/scripts/frontend-js-size.mts` +- Verify: `.github/scripts/frontend-js-size.test.mts` +- Verify: `.github/workflows/lint.yml` +- Verify: `docs/superpowers/specs/2026-07-15-frontend-bundle-generated-chunks-design.md` + +**Interfaces:** +- Consumes: all implementation and CI changes from Tasks 1-3. +- Produces: fresh evidence that focused behavior, repository lint, SPDX, locale safety, and final diff checks pass. + +- [ ] **Step 1: Run the focused regression tests** + +Run: + +```bash +node --test .github/scripts/frontend-js-size.test.mts +``` + +Expected: PASS with `3` tests and `0` failures. + +- [ ] **Step 2: Run repository lint** + +Run: + +```bash +pnpm lint +``` + +Expected: exit code `0`. If an unrelated pre-existing failure occurs, record its exact command and output rather than claiming lint passed. + +- [ ] **Step 3: Check the new file's SPDX header** + +Run: + +```bash +rg -n "SPDX-FileCopyrightText: syuilo and misskey-project|SPDX-License-Identifier: AGPL-3.0-only" .github/scripts/frontend-js-size.test.mts +``` + +Expected: two matches, one for each required SPDX line. + +- [ ] **Step 4: Verify locale safety and unchanged migration/API surfaces** + +Run: + +```bash +git diff --name-only develop -- locales +git diff --name-only develop -- packages/backend/migration packages/backend/src packages/misskey-js/src/autogen +``` + +Expected: both commands produce no output. Therefore locale generation, migration checking, backend API review, and misskey-js regeneration are not applicable. + +- [ ] **Step 5: Verify the final diff and worktree** + +Run: + +```bash +git diff --check develop...HEAD +git status --short +git log --oneline develop..HEAD +``` + +Expected: `git diff --check` exits `0`, `git status --short` is empty, and the log contains the approved design commit plus the three implementation commits from this plan. + +- [ ] **Step 6: Review the final report behavior against the approved design** + +Confirm all of these from the focused test output and diff: + +```text +[x] Physical chunks are counted once by output path. +[x] Only src-backed, vue, and i18n chunks are individually compared. +[x] Generated chunks are aggregated in full and startup reports. +[x] Duplicate stable keys fail instead of overwriting. +[x] Updated stable rows show before and after filenames. +[x] No locale, migration, backend API, frontend Vue, or user-facing behavior changed. +[x] No CHANGELOG entry is needed. +``` From b6996a2766d0b853bbef7014bc64281855710b96 Mon Sep 17 00:00:00 2001 From: syuilo <4439005+syuilo@users.noreply.github.com> Date: Wed, 15 Jul 2026 21:00:39 +0900 Subject: [PATCH 3/8] chore: ignore local worktrees --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index e975691b93..e1fdaa271a 100644 --- a/.gitignore +++ b/.gitignore @@ -83,3 +83,6 @@ vite.config.local-dev.ts.timestamp-* # Affinity *.af~lock~ + +# Local Git worktrees +/.worktrees/ From 945fd405786d8cbed98c69b5c5693e81d97d6795 Mon Sep 17 00:00:00 2001 From: syuilo <4439005+syuilo@users.noreply.github.com> Date: Wed, 15 Jul 2026 21:15:17 +0900 Subject: [PATCH 4/8] fix(dev): aggregate generated frontend chunks --- .github/scripts/frontend-js-size.mts | 202 +++++++++++++++------- .github/scripts/frontend-js-size.test.mts | 85 +++++++++ 2 files changed, 226 insertions(+), 61 deletions(-) create mode 100644 .github/scripts/frontend-js-size.test.mts diff --git a/.github/scripts/frontend-js-size.mts b/.github/scripts/frontend-js-size.mts index 4d6dd0a0bc..b4a61e659a 100644 --- a/.github/scripts/frontend-js-size.mts +++ b/.github/scripts/frontend-js-size.mts @@ -40,13 +40,24 @@ function escapeCell(value: string) { type Manifest = Record; +const stableNamedChunks = new Set(['vue', 'i18n']); + type FileEntry = { - key: string; + comparisonKey: string | null; displayName: string; file: string; + manifestKeys: string[]; size: number; }; +type CollectedReport = { + manifest: Manifest; + chunks: FileEntry[]; + comparableChunks: Record; + chunksByManifestKey: Record; + startupFiles: string[]; +}; + function entryDisplayName(entry: FileEntry) { if (!entry) return ''; return entry.displayName || entry.file; @@ -60,11 +71,13 @@ function findEntryKey(manifest: Manifest) { ?? null; } -function stableChunkKey(manifestKey: string, chunk: Manifest[string]) { - return chunk.src ?? (chunk.name ? `chunk:${chunk.name}` : manifestKey); +function stableChunkKey(chunk: Manifest[string]) { + if (chunk.src != null) return `src:${util.normalizePath(chunk.src)}`; + if (chunk.name != null && stableNamedChunks.has(chunk.name)) return `named:${chunk.name}`; + return null; } -function collectStartupKeys(manifest: Manifest) { +function collectStartupManifestKeys(manifest: Manifest) { const entryKey = findEntryKey(manifest); const keys = new Set(); if (entryKey == null) return keys; @@ -73,10 +86,8 @@ function collectStartupKeys(manifest: Manifest) { if (keys.has(key)) return; const chunk = manifest[key]; if (!chunk || !chunk.file?.endsWith('.js')) return; - keys.add(stableChunkKey(key, chunk)); - for (const importKey of chunk.imports ?? []) { - visit(importKey); - } + keys.add(key); + for (const importKey of chunk.imports ?? []) visit(importKey); } visit(entryKey); @@ -102,26 +113,41 @@ async function resolveBuiltFile(outDir: string, file: string) { }; } -async function collectReport(repoDir: string) { +async function collectReport(repoDir: string): Promise { const outDir = path.join(repoDir, 'built/_frontend_vite_'); const manifestPath = path.join(outDir, 'manifest.json'); const manifest = JSON.parse(await fs.readFile(manifestPath, 'utf8')) as Manifest; - const byKey = new Map(); - const byFile = new Set(); + const chunksByFile = new Map(); + const comparableChunks = new Map(); + const chunksByManifestKey = new Map(); - for (const [key, chunk] of Object.entries(manifest)) { + for (const [manifestKey, chunk] of Object.entries(manifest)) { if (!chunk.file?.endsWith('.js')) continue; const builtFile = await resolveBuiltFile(outDir, chunk.file); - const size = await util.fileSize(builtFile.absolutePath); - const stableKey = stableChunkKey(key, chunk); - const displayName = chunk.src ?? chunk.name ?? key; - byKey.set(stableKey, { - key: stableKey, - displayName, - file: builtFile.relativePath, - size, - }); - byFile.add(builtFile.relativePath); + const comparisonKey = stableChunkKey(chunk); + let entry = chunksByFile.get(builtFile.relativePath); + if (entry == null) { + entry = { + comparisonKey, + displayName: chunk.src ?? chunk.name ?? manifestKey, + file: builtFile.relativePath, + manifestKeys: [manifestKey], + size: await util.fileSize(builtFile.absolutePath), + }; + chunksByFile.set(entry.file, entry); + } else if (entry.comparisonKey !== comparisonKey) { + throw new Error(`Conflicting identities for ${entry.file}`); + } else { + entry.manifestKeys.push(manifestKey); + } + chunksByManifestKey.set(manifestKey, entry); + if (comparisonKey != null) { + const existing = comparableChunks.get(comparisonKey); + if (existing != null && existing.file !== entry.file) { + throw new Error(`Duplicate stable chunk key "${comparisonKey}": ${existing.file}, ${entry.file}`); + } + comparableChunks.set(comparisonKey, entry); + } } const localeDir = path.join(outDir, locale); @@ -129,21 +155,29 @@ async function collectReport(repoDir: string) { for await (const fullPath of util.traverseDirectory(localeDir)) { if (!fullPath.endsWith('.js')) continue; const relativePath = util.normalizePath(path.relative(outDir, fullPath)); - if (byFile.has(relativePath)) continue; - const size = await util.fileSize(fullPath); - byKey.set(relativePath, { - key: relativePath, + if (chunksByFile.has(relativePath)) continue; + chunksByFile.set(relativePath, { + comparisonKey: null, displayName: relativePath, file: relativePath, - size, + manifestKeys: [], + size: await util.fileSize(fullPath), }); } } + const startupFiles = new Set(); + for (const manifestKey of collectStartupManifestKeys(manifest)) { + const entry = chunksByManifestKey.get(manifestKey); + if (entry != null) startupFiles.add(entry.file); + } + return { manifest, - chunks: Object.fromEntries(byKey), - startupKeys: [...collectStartupKeys(manifest)], + chunks: [...chunksByFile.values()], + comparableChunks: Object.fromEntries(comparableChunks), + chunksByManifestKey: Object.fromEntries(chunksByManifestKey), + startupFiles: [...startupFiles], }; } @@ -312,16 +346,17 @@ function renderVisualizerSummaryTable(before: ReturnType>, after: Awaited>) { - return keys.map((key) => { - const beforeEntry = before.chunks[key]; - const afterEntry = after.chunks[key]; +function getChunkComparisonRows(keys: string[], before: Record, after: Record) { + return keys.map(key => { + const beforeEntry = before[key]; + const afterEntry = after[key]; const beforeSize = beforeEntry?.size ?? 0; const afterSize = afterEntry?.size ?? 0; return { key, name: entryDisplayName(beforeEntry ?? afterEntry), - chunkFile: beforeEntry?.file ?? afterEntry?.file, + beforeFile: beforeEntry?.file, + afterFile: afterEntry?.file, beforeSize, afterSize, changeType: beforeEntry == null ? 'added' : afterEntry == null ? 'removed' : beforeSize !== afterSize ? 'updated' : 'unchanged', @@ -330,6 +365,36 @@ function getChunkComparisonRows(keys: string[], before: Awaited sum + chunk.size, 0); +} + +function generatedAggregate(before: FileEntry[], after: FileEntry[]): ChunkAggregate { + const beforeGenerated = before.filter(chunk => chunk.comparisonKey == null); + const afterGenerated = after.filter(chunk => chunk.comparisonKey == null); + return { + beforeSize: sumChunkSizes(beforeGenerated), + afterSize: sumChunkSizes(afterGenerated), + beforeCount: beforeGenerated.length, + afterCount: afterGenerated.length, + }; +} + +function comparableMap(chunks: FileEntry[]) { + const entries: [string, FileEntry][] = []; + for (const chunk of chunks) { + if (chunk.comparisonKey != null) entries.push([chunk.comparisonKey, chunk]); + } + return Object.fromEntries(entries); +} + function summarizeChunkChanges(rows: ReturnType) { return { updated: rows.filter((row) => row.changeType === 'updated').length, @@ -349,60 +414,75 @@ function compareChunkComparisonRows(a: ReturnType || a.name.localeCompare(b.name); } -function chunkMarkdownTable(rows: ReturnType, total?: { beforeSize: number; afterSize: number }) { - if (rows.length === 0) return '_No data_'; +function chunkMarkdownTable( + rows: ReturnType, + total?: { beforeSize: number; afterSize: number }, + generated?: ChunkAggregate, +) { + if (rows.length === 0 && total == null && generated == null) 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('\\%', '\\\\%')} |`); - lines.push('| | | | | |'); + 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 = row.beforeFile ?? row.afterFile ?? ''; if (row.changeType === 'added') { - lines.push(`|
\`${escapeCell(row.name)}\` \`${escapeCell(row.chunkFile)}\`
| ${util.formatBytes(row.beforeSize)} | ${util.formatBytes(row.afterSize)} | ${util.calcAndFormatDeltaBytes(row.beforeSize, row.afterSize, 1000)} | $\\color{orange}{\\text{( + )}}$ |`); + lines.push(`|
\`${escapeCell(row.name)}\` \`${escapeCell(chunkFile)}\`
| ${util.formatBytes(row.beforeSize)} | ${util.formatBytes(row.afterSize)} | ${util.calcAndFormatDeltaBytes(row.beforeSize, row.afterSize, 1000)} | $\\color{orange}{\\text{( + )}}$ |`); } else if (row.changeType === 'removed') { - lines.push(`|
\`${escapeCell(row.name)}\` \`${escapeCell(row.chunkFile)}\`
| ${util.formatBytes(row.beforeSize)} | ${util.formatBytes(row.afterSize)} | ${util.calcAndFormatDeltaBytes(row.beforeSize, row.afterSize, 1000)} | $\\color{green}{\\text{( - )}}$ |`); + lines.push(`|
\`${escapeCell(row.name)}\` \`${escapeCell(chunkFile)}\`
| ${util.formatBytes(row.beforeSize)} | ${util.formatBytes(row.afterSize)} | ${util.calcAndFormatDeltaBytes(row.beforeSize, row.afterSize, 1000)} | $\\color{green}{\\text{( - )}}$ |`); } else { - lines.push(`|
\`${escapeCell(row.name)}\` \`${escapeCell(row.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('\\%', '\\\\%')} |`); + 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)) { + lines.push(''); + lines.push(`_${generated.beforeCount} before / ${generated.afterCount} after generated chunks are grouped._`); + } return lines.join('\n'); } function renderFrontendChunkReport(before: Awaited>, after: Awaited>) { - const commonChunkKeys = Object.keys(before.chunks).filter((key) => after.chunks[key] != null); - const addedChunkKeys = Object.keys(after.chunks).filter((key) => before.chunks[key] == null); - const removedChunkKeys = Object.keys(before.chunks).filter((key) => after.chunks[key] == null); - const allChunkKeys = [ - ...commonChunkKeys, - ...addedChunkKeys, - ...removedChunkKeys, - ]; - //const comparisonRows = getChunkComparisonRows(commonChunkKeys, before, after); - const allComparisonRows = getChunkComparisonRows(allChunkKeys, before, after); + const beforeComparable = before.comparableChunks; + const afterComparable = after.comparableChunks; + const allChunkKeys = [...new Set([...Object.keys(beforeComparable), ...Object.keys(afterComparable)])]; + const allComparisonRows = getChunkComparisonRows(allChunkKeys, beforeComparable, afterComparable); const changedRows = allComparisonRows.filter((row) => row.changeType !== 'unchanged'); const diffSummary = summarizeChunkChanges(changedRows); const diffTotal = { - beforeSize: allComparisonRows.reduce((sum, row) => sum + row.beforeSize, 0), - afterSize: allComparisonRows.reduce((sum, row) => sum + row.afterSize, 0), + beforeSize: sumChunkSizes(before.chunks), + afterSize: sumChunkSizes(after.chunks), }; + const diffGenerated = generatedAggregate(before.chunks, after.chunks); const diffRows = changedRows.sort(compareChunkComparisonRows).slice(0, 30); // TODO: 実際に30を超えて切り捨てられたrowがあった場合はその旨をmarkdown内に表示するようにする - const startupKeys = new Set([ - ...before.startupKeys, - ...after.startupKeys, - ]); - const startupComparisonRows = getChunkComparisonRows([...startupKeys], before, after); + const beforeStartupFiles = new Set(before.startupFiles); + const afterStartupFiles = new Set(after.startupFiles); + const beforeStartupChunks = before.chunks.filter(chunk => beforeStartupFiles.has(chunk.file)); + const afterStartupChunks = after.chunks.filter(chunk => afterStartupFiles.has(chunk.file)); + const beforeStartupComparable = comparableMap(beforeStartupChunks); + const afterStartupComparable = comparableMap(afterStartupChunks); + const startupKeys = [...new Set([...Object.keys(beforeStartupComparable), ...Object.keys(afterStartupComparable)])]; + const startupComparisonRows = getChunkComparisonRows(startupKeys, beforeStartupComparable, afterStartupComparable); const startupRows = startupComparisonRows.sort(compareChunkComparisonRows); const startupSummary = summarizeChunkChanges(startupComparisonRows); const startupTotal = { - beforeSize: startupComparisonRows.reduce((sum, row) => sum + row.beforeSize, 0), - afterSize: startupComparisonRows.reduce((sum, row) => sum + row.afterSize, 0), + beforeSize: sumChunkSizes(beforeStartupChunks), + afterSize: sumChunkSizes(afterStartupChunks), }; + const startupGenerated = generatedAggregate(beforeStartupChunks, afterStartupChunks); //const largeRows = comparisonRows // .sort((a, b) => b.sortSize - a.sortSize || a.name.localeCompare(b.name)) @@ -412,14 +492,14 @@ function renderFrontendChunkReport(before: Awaited', `${formatChunkChangeSummary('Chunk size diff', diffSummary)}`, '', - chunkMarkdownTable(diffRows, diffTotal), + chunkMarkdownTable(diffRows, diffTotal, diffGenerated), '', '', '', '
', `${formatChunkChangeSummary('Startup chunk size', startupSummary)}`, '', - chunkMarkdownTable(startupRows, startupTotal), + chunkMarkdownTable(startupRows, startupTotal, startupGenerated), '', `_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 new file mode 100644 index 0000000000..51b71c075f --- /dev/null +++ b/.github/scripts/frontend-js-size.test.mts @@ -0,0 +1,85 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import assert from 'node:assert/strict'; +import { promises as fs } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import test, { type TestContext } from 'node:test'; +import * as util from './utility.mts'; + +type Manifest = Record; + +const repoDir = path.resolve(import.meta.dirname, '../..'); +const reportScript = path.join(repoDir, '.github/scripts/frontend-js-size.mts'); + +async function writeBuild(repo: string, manifest: Manifest, sizes: Record) { + const outDir = path.join(repo, 'built/_frontend_vite_/'); + await fs.mkdir(path.join(outDir, 'ja-JP'), { recursive: true }); + await fs.writeFile(path.join(outDir, 'manifest.json'), JSON.stringify(manifest)); + for (const [file, size] of Object.entries(sizes)) { + const localizedFile = file.replace(/^scripts\//, ''); + const outputFile = path.join(outDir, 'ja-JP', localizedFile); + await fs.mkdir(path.dirname(outputFile), { recursive: true }); + await fs.writeFile(outputFile, Buffer.alloc(size)); + } +} + +async function runReport(t: TestContext, before: { manifest: Manifest; sizes: Record }, after: { manifest: Manifest; sizes: Record }) { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'frontend-js-size-')); + t.after(() => fs.rm(root, { recursive: true, force: true })); + const beforeDir = path.join(root, 'before'); + const afterDir = path.join(root, 'after'); + const beforeStats = path.join(root, 'before-stats.json'); + const afterStats = path.join(root, 'after-stats.json'); + const reportFile = path.join(root, 'report.md'); + await writeBuild(beforeDir, before.manifest, before.sizes); + await writeBuild(afterDir, after.manifest, after.sizes); + await fs.writeFile(beforeStats, '{}'); + await fs.writeFile(afterStats, '{}'); + await util.run(process.execPath, [reportScript, beforeDir, afterDir, beforeStats, afterStats, reportFile]); + return fs.readFile(reportFile, 'utf8'); +} + +function fixture(suffix: string, generatedName: string, sizes: { entry: number; generatedA: number; generatedB: number; vue: number; i18n: number }) { + const files = { + entry: `scripts/entry-${suffix}.js`, + generatedA: `scripts/generated-a-${suffix}.js`, + generatedB: `scripts/generated-b-${suffix}.js`, + vue: `scripts/vue-${suffix}.js`, + i18n: `scripts/i18n-${suffix}.js`, + }; + return { + manifest: { + 'src/_boot_.ts': { file: files.entry, src: 'src/_boot_.ts', name: 'entry', isEntry: true, imports: ['_generatedA', '_generatedB', '_vue', '_i18n'] }, + _generatedA: { file: files.generatedA, name: generatedName }, + _generatedB: { file: files.generatedB, name: generatedName }, + _vue: { file: files.vue, name: 'vue' }, + _i18n: { file: files.i18n, name: 'i18n' }, + }, + sizes: Object.fromEntries(Object.entries(files).map(([key, file]) => [file, sizes[key as keyof typeof sizes]])), + }; +} + +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 }), + ); + + assert.match(report, /\| \(total\) \| 220 B \| 275 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>/); + assert.match(report, /`src\/_boot_\.ts`<\/summary>/); + assert.match(report, /`vue`<\/summary>/); +}); From afca066833c5844e24cfe3b6eb0d1c97d216facf Mon Sep 17 00:00:00 2001 From: syuilo <4439005+syuilo@users.noreply.github.com> Date: Wed, 15 Jul 2026 21:22:28 +0900 Subject: [PATCH 5/8] fix(dev): clarify frontend chunk comparisons --- .github/scripts/frontend-js-size.mts | 8 +++++++- .github/scripts/frontend-js-size.test.mts | 22 ++++++++++++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/.github/scripts/frontend-js-size.mts b/.github/scripts/frontend-js-size.mts index b4a61e659a..38c6fa07e9 100644 --- a/.github/scripts/frontend-js-size.mts +++ b/.github/scripts/frontend-js-size.mts @@ -414,6 +414,12 @@ function compareChunkComparisonRows(a: ReturnType || a.name.localeCompare(b.name); } +function chunkFileDisplay(row: ReturnType[number]) { + if (row.beforeFile == null) return row.afterFile ?? ''; + if (row.afterFile == null || row.beforeFile === row.afterFile) return row.beforeFile; + return `${row.beforeFile} → ${row.afterFile}`; +} + function chunkMarkdownTable( rows: ReturnType, total?: { beforeSize: number; afterSize: number }, @@ -437,7 +443,7 @@ function chunkMarkdownTable( if (hasSummaryRow && rows.length > 0) lines.push('| | | | | |'); for (const row of rows) { - const chunkFile = row.beforeFile ?? row.afterFile ?? ''; + const chunkFile = chunkFileDisplay(row); if (row.changeType === 'added') { lines.push(`|
\`${escapeCell(row.name)}\` \`${escapeCell(chunkFile)}\`
| ${util.formatBytes(row.beforeSize)} | ${util.formatBytes(row.afterSize)} | ${util.calcAndFormatDeltaBytes(row.beforeSize, row.afterSize, 1000)} | $\\color{orange}{\\text{( + )}}$ |`); } else if (row.changeType === 'removed') { diff --git a/.github/scripts/frontend-js-size.test.mts b/.github/scripts/frontend-js-size.test.mts index 51b71c075f..3688948e94 100644 --- a/.github/scripts/frontend-js-size.test.mts +++ b/.github/scripts/frontend-js-size.test.mts @@ -83,3 +83,25 @@ test('groups generated chunks while preserving full and startup totals', async t assert.match(report, /`src\/_boot_\.ts`<\/summary>/); assert.match(report, /`vue`<\/summary>/); }); + +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' }; + duplicateVue.sizes['scripts/vue-duplicate-before.js'] = 60; + + await assert.rejects( + runReport(t, duplicateVue, fixture('after', 'esm', { entry: 110, generatedA: 30, generatedB: 40, vue: 45, i18n: 50 })), + /Duplicate stable chunk key "named:vue".*vue-before\.js.*vue-duplicate-before\.js/, + ); +}); + +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 }), + ); + + assert.match(report, /`ja-JP\/entry-before\.js → ja-JP\/entry-after\.js`/); + assert.match(report, /`ja-JP\/vue-before\.js → ja-JP\/vue-after\.js`/); +}); From 11c6fb1567a31e3eadb81879fb1f735a9465c652 Mon Sep 17 00:00:00 2001 From: syuilo <4439005+syuilo@users.noreply.github.com> Date: Wed, 15 Jul 2026 21:29:01 +0900 Subject: [PATCH 6/8] test(dev): capture expected frontend report failures --- .github/scripts/frontend-js-size.test.mts | 26 ++++++++++++++++++----- 1 file changed, 21 insertions(+), 5 deletions(-) diff --git a/.github/scripts/frontend-js-size.test.mts b/.github/scripts/frontend-js-size.test.mts index 3688948e94..d89fa153ba 100644 --- a/.github/scripts/frontend-js-size.test.mts +++ b/.github/scripts/frontend-js-size.test.mts @@ -4,6 +4,7 @@ */ import assert from 'node:assert/strict'; +import { execFile } from 'node:child_process'; import { promises as fs } from 'node:fs'; import os from 'node:os'; import path from 'node:path'; @@ -33,7 +34,7 @@ async function writeBuild(repo: string, manifest: Manifest, sizes: Record }, after: { manifest: Manifest; sizes: Record }) { +async function runReport(t: TestContext, before: { manifest: Manifest; sizes: Record }, after: { manifest: Manifest; sizes: Record }, expectFailure = false) { const root = await fs.mkdtemp(path.join(os.tmpdir(), 'frontend-js-size-')); t.after(() => fs.rm(root, { recursive: true, force: true })); const beforeDir = path.join(root, 'before'); @@ -45,7 +46,19 @@ async function runReport(t: TestContext, before: { manifest: Manifest; sizes: Re await writeBuild(afterDir, after.manifest, after.sizes); await fs.writeFile(beforeStats, '{}'); await fs.writeFile(afterStats, '{}'); - await util.run(process.execPath, [reportScript, beforeDir, afterDir, beforeStats, afterStats, reportFile]); + const args = [reportScript, beforeDir, afterDir, beforeStats, afterStats, reportFile]; + if (expectFailure) { + return new Promise((resolve, reject) => { + execFile(process.execPath, args, (error, _stdout, stderr) => { + if (error == null) { + reject(new Error('Expected frontend report script to fail')); + } else { + resolve(stderr); + } + }); + }); + } + await util.run(process.execPath, args); return fs.readFile(reportFile, 'utf8'); } @@ -89,10 +102,13 @@ test('fails instead of overwriting duplicate stable chunk keys', async t => { duplicateVue.manifest._vueDuplicate = { file: 'scripts/vue-duplicate-before.js', name: 'vue' }; duplicateVue.sizes['scripts/vue-duplicate-before.js'] = 60; - await assert.rejects( - runReport(t, duplicateVue, fixture('after', 'esm', { entry: 110, generatedA: 30, generatedB: 40, vue: 45, i18n: 50 })), - /Duplicate stable chunk key "named:vue".*vue-before\.js.*vue-duplicate-before\.js/, + const diagnostic = await runReport( + t, + duplicateVue, + fixture('after', 'esm', { entry: 110, generatedA: 30, generatedB: 40, vue: 45, i18n: 50 }), + true, ); + assert.match(diagnostic, /Duplicate stable chunk key "named:vue".*vue-before\.js.*vue-duplicate-before\.js/); }); test('shows both filenames for an updated stable chunk', async t => { From 0ad72528da904e48a0da00556bfffead87c66b92 Mon Sep 17 00:00:00 2001 From: syuilo <4439005+syuilo@users.noreply.github.com> Date: Wed, 15 Jul 2026 21:34:13 +0900 Subject: [PATCH 7/8] test(dev): check frontend bundle report --- .github/workflows/lint.yml | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 63ae5c7397..d3c745aea6 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -18,6 +18,8 @@ on: - packages/misskey-reversi/** - packages/shared/eslint.config.js - scripts/check-dts*.mjs + - .github/scripts/frontend-js-size*.mts + - .github/scripts/utility.mts - .github/workflows/lint.yml - package.json pull_request: @@ -34,6 +36,8 @@ on: - packages/misskey-reversi/** - packages/shared/eslint.config.js - scripts/check-dts*.mjs + - .github/scripts/frontend-js-size*.mts + - .github/scripts/utility.mts - .github/workflows/lint.yml - package.json jobs: @@ -136,3 +140,12 @@ jobs: - run: pnpm i --frozen-lockfile - run: node --test scripts/check-dts.test.mjs - run: pnpm check-dts + + frontend-bundle-report-test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6.0.3 + - uses: actions/setup-node@v6.4.0 + with: + node-version-file: '.node-version' + - run: node --test .github/scripts/frontend-js-size.test.mts From ef852e6ab0351f4c9ed0d7e8c33e8cd9098536e2 Mon Sep 17 00:00:00 2001 From: syuilo <4439005+syuilo@users.noreply.github.com> Date: Wed, 15 Jul 2026 21:55:12 +0900 Subject: [PATCH 8/8] fix(dev): validate frontend startup manifest --- .github/scripts/frontend-js-size.mts | 11 +- .github/scripts/frontend-js-size.test.mts | 112 ++- .gitignore | 3 - ...-07-15-frontend-bundle-generated-chunks.md | 661 ------------------ 4 files changed, 118 insertions(+), 669 deletions(-) delete mode 100644 docs/superpowers/plans/2026-07-15-frontend-bundle-generated-chunks.md diff --git a/.github/scripts/frontend-js-size.mts b/.github/scripts/frontend-js-size.mts index 38c6fa07e9..04a92679c7 100644 --- a/.github/scripts/frontend-js-size.mts +++ b/.github/scripts/frontend-js-size.mts @@ -80,14 +80,17 @@ function stableChunkKey(chunk: Manifest[string]) { function collectStartupManifestKeys(manifest: Manifest) { const entryKey = findEntryKey(manifest); const keys = new Set(); - if (entryKey == null) return keys; + if (entryKey == null) throw new Error('Unable to find frontend startup entry in Vite manifest.'); - function visit(key: string) { + function visit(key: string, importedBy?: string) { if (keys.has(key)) return; const chunk = manifest[key]; - if (!chunk || !chunk.file?.endsWith('.js')) return; + const importContext = importedBy == null ? '' : ` imported by "${importedBy}"`; + if (chunk == null) throw new Error(`Startup manifest key "${key}"${importContext} is missing.`); + if (chunk.file == null || chunk.file.length === 0) throw new Error(`Startup manifest key "${key}"${importContext} has no output file.`); + if (!chunk.file.endsWith('.js')) throw new Error(`Startup manifest key "${key}"${importContext} resolves to non-JavaScript output "${chunk.file}".`); keys.add(key); - for (const importKey of chunk.imports ?? []) visit(importKey); + for (const importKey of chunk.imports ?? []) visit(importKey, key); } visit(entryKey); diff --git a/.github/scripts/frontend-js-size.test.mts b/.github/scripts/frontend-js-size.test.mts index d89fa153ba..347d21548c 100644 --- a/.github/scripts/frontend-js-size.test.mts +++ b/.github/scripts/frontend-js-size.test.mts @@ -12,7 +12,7 @@ import test, { type TestContext } from 'node:test'; import * as util from './utility.mts'; type Manifest = Record { assert.match(report, /`ja-JP\/entry-before\.js → ja-JP\/entry-after\.js`/); assert.match(report, /`ja-JP\/vue-before\.js → ja-JP\/vue-after\.js`/); }); + +test('fails descriptively when the startup entry is missing', async t => { + const before = fixture('before', 'dist', { entry: 100, generatedA: 10, generatedB: 20, vue: 40, i18n: 50 }); + delete before.manifest['src/_boot_.ts']; + delete before.sizes['scripts/entry-before.js']; + + const diagnostic = await runReport( + t, + before, + fixture('after', 'esm', { entry: 110, generatedA: 30, generatedB: 40, vue: 45, i18n: 50 }), + true, + ); + assert.match(diagnostic, /Unable to find frontend startup entry in Vite manifest/); +}); + +test('fails descriptively when a static import is missing from the manifest', async t => { + const before = fixture('before', 'dist', { entry: 100, generatedA: 10, generatedB: 20, vue: 40, i18n: 50 }); + before.manifest['src/_boot_.ts'].imports = ['_missing']; + + const diagnostic = await runReport( + t, + before, + fixture('after', 'esm', { entry: 110, generatedA: 30, generatedB: 40, vue: 45, i18n: 50 }), + true, + ); + assert.match(diagnostic, /Startup manifest key "_missing".*is missing/); +}); + +test('fails descriptively when a static import has no output file', async t => { + const before = fixture('before', 'dist', { entry: 100, generatedA: 10, generatedB: 20, vue: 40, i18n: 50 }); + before.manifest['src/_boot_.ts'].imports = ['_malformed']; + before.manifest._malformed = { name: 'malformed' }; + + const diagnostic = await runReport( + t, + before, + fixture('after', 'esm', { entry: 110, generatedA: 30, generatedB: 40, vue: 45, i18n: 50 }), + true, + ); + assert.match(diagnostic, /Startup manifest key "_malformed".*has no output file/); +}); + +test('fails descriptively when a static import resolves to a non-JavaScript output', async t => { + const before = fixture('before', 'dist', { entry: 100, generatedA: 10, generatedB: 20, vue: 40, i18n: 50 }); + before.manifest['src/_boot_.ts'].imports = ['_malformed']; + before.manifest._malformed = { file: 'assets/malformed.css', name: 'malformed' }; + + const diagnostic = await runReport( + t, + before, + fixture('after', 'esm', { entry: 110, generatedA: 30, generatedB: 40, vue: 45, i18n: 50 }), + true, + ); + assert.match(diagnostic, /Startup manifest key "_malformed".*non-JavaScript output "assets\/malformed\.css"/); +}); + +test('keeps source-backed additions and removals as individual rows', async t => { + const before = fixture('before', 'dist', { entry: 100, generatedA: 10, generatedB: 20, vue: 40, i18n: 50 }); + before.manifest._removed = { file: 'scripts/removed-before.js', src: 'src/removed.ts' }; + before.sizes['scripts/removed-before.js'] = 12; + const after = fixture('after', 'esm', { entry: 110, generatedA: 30, generatedB: 40, vue: 45, i18n: 50 }); + after.manifest._added = { file: 'scripts/added-after.js', src: 'src/added.ts' }; + after.sizes['scripts/added-after.js'] = 13; + + const report = await runReport(t, before, after); + + assert.match(report, /`src\/added\.ts`<\/summary> `ja-JP\/added-after\.js` <\/details> \| 0 B \| 13 B \|/); + assert.match(report, /`src\/removed\.ts`<\/summary> `ja-JP\/removed-before\.js` <\/details> \| 12 B \| 0 B \|/); +}); + +test('counts an unmanifested localized JavaScript file in totals and the generated aggregate', async t => { + const before = fixture('before', 'dist', { entry: 100, generatedA: 10, generatedB: 20, vue: 40, i18n: 50 }); + before.sizes['scripts/unmanifested-before.js'] = 15; + + const report = await runReport( + t, + before, + fixture('after', 'esm', { entry: 110, generatedA: 30, generatedB: 40, vue: 45, i18n: 50 }), + ); + + assert.match(report, /\| \(total\) \| 235 B \| 275 B \|/); + assert.match(report, /\| \(other generated chunks\) \| 45 B \| 70 B \|/); + assert.match(report, /_3 before \/ 2 after generated chunks are grouped\._/); +}); + +test('counts duplicate manifest entries for one physical output only once', async t => { + const before = fixture('before', 'dist', { entry: 100, generatedA: 10, generatedB: 20, vue: 40, i18n: 50 }); + before.manifest._generatedAlias = { file: 'scripts/generated-a-before.js', name: 'dist' }; + + const report = await runReport( + t, + before, + fixture('after', 'esm', { entry: 110, generatedA: 30, generatedB: 40, vue: 45, i18n: 50 }), + ); + + assert.match(report, /\| \(total\) \| 220 B \| 275 B \|/); + assert.match(report, /_2 before \/ 2 after generated chunks are grouped\._/); +}); + +test('does not count generated-aggregate-only changes as individual chunk changes', async t => { + const report = await runReport( + t, + fixture('before', 'dist', { entry: 100, generatedA: 10, generatedB: 20, vue: 40, i18n: 50 }), + fixture('after', 'esm', { entry: 100, generatedA: 30, generatedB: 40, vue: 40, i18n: 50 }), + ); + + assert.match(report, /Chunk size diff \(0 updated, 0 added, 0 removed\)<\/summary>/); + assert.match(report, /Startup chunk size \(0 updated, 0 added, 0 removed\)<\/summary>/); + assert.equal(report.match(/\| \(other generated chunks\) \| 30 B \| 70 B \|/g)?.length, 2); +}); diff --git a/.gitignore b/.gitignore index e1fdaa271a..e975691b93 100644 --- a/.gitignore +++ b/.gitignore @@ -83,6 +83,3 @@ vite.config.local-dev.ts.timestamp-* # Affinity *.af~lock~ - -# Local Git worktrees -/.worktrees/ diff --git a/docs/superpowers/plans/2026-07-15-frontend-bundle-generated-chunks.md b/docs/superpowers/plans/2026-07-15-frontend-bundle-generated-chunks.md deleted file mode 100644 index 6a58d5e322..0000000000 --- a/docs/superpowers/plans/2026-07-15-frontend-bundle-generated-chunks.md +++ /dev/null @@ -1,661 +0,0 @@ -# Frontend Bundle Generated Chunks 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:** Prevent generated `esm`/`dist`-style chunks from being compared individually while retaining every JavaScript output file in full and startup totals. - -**Architecture:** Keep physical chunk accounting separate from stable before/after identities. Source-backed chunks and the explicit `vue`/`i18n` code-splitting groups remain individually comparable; all other generated chunks are grouped into one aggregate row. Exercise the report generator through filesystem fixtures so tests cover manifest parsing, localized file resolution, startup traversal, and Markdown rendering together. - -**Tech Stack:** Node.js 26.4, TypeScript `.mts` scripts with native type stripping, Node test runner, Vite manifest JSON, GitHub Actions. - -## Global Constraints - -- Never compare unrelated generated chunks as the same chunk. -- Keep generated chunks out of the individual chunk-diff rows. -- Preserve the contribution of every physical JavaScript chunk in totals. -- Show the aggregate size change of generated chunks so unexplained bundle growth remains visible. -- Apply the same rules to the full chunk report and the startup chunk report. -- Continue comparing source-backed chunks and intentionally named chunks. -- The only stable generated-name allowlist entries are `vue` and `i18n`. -- Do not infer chunk identity from module-set similarity. -- Do not change the frontend code-splitting strategy or output filenames. -- Do not suppress generated chunks with a name denylist. -- Do not manually edit locale files other than `locales/ja-JP.yml`; this change does not require any locale edit. -- New `.mts` files must include the repository SPDX header. -- This developer-facing report change does not require a `CHANGELOG.md` entry. - -## File Structure - -- Create `.github/scripts/frontend-js-size.test.mts`: end-to-end fixtures for manifest collection and Markdown output. -- Modify `.github/scripts/frontend-js-size.mts`: physical chunk collection, stable identity classification, generated aggregation, startup accounting, duplicate-key validation, and filename rendering. -- Modify `.github/workflows/lint.yml`: run the focused Node test whenever the report script or its utility changes. -- Reference `docs/superpowers/specs/2026-07-15-frontend-bundle-generated-chunks-design.md`: approved behavior and non-goals; no implementation edit is required. - ---- - -### Task 1: Preserve all physical chunks and aggregate generated chunks - -**Files:** -- Create: `.github/scripts/frontend-js-size.test.mts` -- Modify: `.github/scripts/frontend-js-size.mts:39-134` -- Modify: `.github/scripts/frontend-js-size.mts:315-426` - -**Interfaces:** -- Consumes: Vite `manifest.json`, localized JavaScript files under `built/_frontend_vite_/ja-JP`, and the existing five CLI arguments of `frontend-js-size.mts`. -- Produces: `CollectedReport` with `chunks: FileEntry[]`, `comparableChunks: Record`, `chunksByManifestKey: Record`, and `startupFiles: string[]`; Markdown with `(total)` and `(other generated chunks)` rows. - -- [ ] **Step 1: Write the failing end-to-end test** - -Create `.github/scripts/frontend-js-size.test.mts` with this initial content: - -```ts -/* - * SPDX-FileCopyrightText: syuilo and misskey-project - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import assert from 'node:assert/strict'; -import { promises as fs } from 'node:fs'; -import os from 'node:os'; -import path from 'node:path'; -import test, { type TestContext } from 'node:test'; -import * as util from './utility.mts'; - -type Manifest = Record; - -const repoDir = path.resolve(import.meta.dirname, '../..'); -const reportScript = path.join(repoDir, '.github/scripts/frontend-js-size.mts'); - -async function writeBuild(repo: string, manifest: Manifest, sizes: Record) { - const outDir = path.join(repo, 'built/_frontend_vite_'); - await fs.mkdir(path.join(outDir, 'ja-JP'), { recursive: true }); - await fs.writeFile(path.join(outDir, 'manifest.json'), JSON.stringify(manifest)); - for (const [file, size] of Object.entries(sizes)) { - const localizedFile = file.replace(/^scripts\//, ''); - const outputFile = path.join(outDir, 'ja-JP', localizedFile); - await fs.mkdir(path.dirname(outputFile), { recursive: true }); - await fs.writeFile(outputFile, Buffer.alloc(size)); - } -} - -async function runReport(t: TestContext, before: { manifest: Manifest; sizes: Record }, after: { manifest: Manifest; sizes: Record }) { - const root = await fs.mkdtemp(path.join(os.tmpdir(), 'frontend-js-size-')); - t.after(() => fs.rm(root, { recursive: true, force: true })); - const beforeDir = path.join(root, 'before'); - const afterDir = path.join(root, 'after'); - const beforeStats = path.join(root, 'before-stats.json'); - const afterStats = path.join(root, 'after-stats.json'); - const reportFile = path.join(root, 'report.md'); - await writeBuild(beforeDir, before.manifest, before.sizes); - await writeBuild(afterDir, after.manifest, after.sizes); - await fs.writeFile(beforeStats, '{}'); - await fs.writeFile(afterStats, '{}'); - await util.run(process.execPath, [reportScript, beforeDir, afterDir, beforeStats, afterStats, reportFile]); - return fs.readFile(reportFile, 'utf8'); -} - -function fixture(suffix: string, generatedName: string, sizes: { entry: number; generatedA: number; generatedB: number; vue: number; i18n: number }) { - const files = { - entry: `scripts/entry-${suffix}.js`, - generatedA: `scripts/generated-a-${suffix}.js`, - generatedB: `scripts/generated-b-${suffix}.js`, - vue: `scripts/vue-${suffix}.js`, - i18n: `scripts/i18n-${suffix}.js`, - }; - return { - manifest: { - 'src/_boot_.ts': { file: files.entry, src: 'src/_boot_.ts', name: 'entry', isEntry: true, imports: ['_generatedA', '_generatedB', '_vue', '_i18n'] }, - _generatedA: { file: files.generatedA, name: generatedName }, - _generatedB: { file: files.generatedB, name: generatedName }, - _vue: { file: files.vue, name: 'vue' }, - _i18n: { file: files.i18n, name: 'i18n' }, - }, - sizes: Object.fromEntries(Object.entries(files).map(([key, file]) => [file, sizes[key as keyof typeof sizes]])), - }; -} - -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 }), - ); - - assert.match(report, /\| \(total\) \| 220 B \| 275 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>/); - assert.match(report, /`src\/_boot_\.ts`<\/summary>/); - assert.match(report, /`vue`<\/summary>/); -}); -``` - -- [ ] **Step 2: Run the test and verify the current implementation fails** - -Run: - -```bash -node --test .github/scripts/frontend-js-size.test.mts -``` - -Expected: FAIL because `(total)` omits one of the duplicate generated-name chunks, no `(other generated chunks)` row exists, and an individual `dist` or `esm` row is rendered. - -- [ ] **Step 3: Replace name-based identity with physical accounting and stable classification** - -In `.github/scripts/frontend-js-size.mts`, replace `FileEntry`, `stableChunkKey`, `collectStartupKeys`, and `collectReport` with these structures and functions while retaining the existing `resolveBuiltFile` function: - -```ts -const stableNamedChunks = new Set(['vue', 'i18n']); - -type FileEntry = { - comparisonKey: string | null; - displayName: string; - file: string; - manifestKeys: string[]; - size: number; -}; - -type CollectedReport = { - manifest: Manifest; - chunks: FileEntry[]; - comparableChunks: Record; - chunksByManifestKey: Record; - startupFiles: string[]; -}; - -function stableChunkKey(chunk: Manifest[string]) { - if (chunk.src != null) return `src:${util.normalizePath(chunk.src)}`; - if (chunk.name != null && stableNamedChunks.has(chunk.name)) return `named:${chunk.name}`; - return null; -} - -function collectStartupManifestKeys(manifest: Manifest) { - const entryKey = findEntryKey(manifest); - const keys = new Set(); - if (entryKey == null) return keys; - - function visit(key: string) { - if (keys.has(key)) return; - const chunk = manifest[key]; - if (!chunk || !chunk.file?.endsWith('.js')) return; - keys.add(key); - for (const importKey of chunk.imports ?? []) visit(importKey); - } - - visit(entryKey); - return keys; -} - -async function collectReport(repoDir: string): Promise { - const outDir = path.join(repoDir, 'built/_frontend_vite_'); - const manifestPath = path.join(outDir, 'manifest.json'); - const manifest = JSON.parse(await fs.readFile(manifestPath, 'utf8')) as Manifest; - const chunksByFile = new Map(); - const comparableChunks = new Map(); - const chunksByManifestKey = new Map(); - - for (const [manifestKey, chunk] of Object.entries(manifest)) { - if (!chunk.file?.endsWith('.js')) continue; - const builtFile = await resolveBuiltFile(outDir, chunk.file); - const comparisonKey = stableChunkKey(chunk); - let entry = chunksByFile.get(builtFile.relativePath); - if (entry == null) { - entry = { - comparisonKey, - displayName: chunk.src ?? chunk.name ?? manifestKey, - file: builtFile.relativePath, - manifestKeys: [manifestKey], - size: await util.fileSize(builtFile.absolutePath), - }; - chunksByFile.set(entry.file, entry); - } else if (entry.comparisonKey !== comparisonKey) { - throw new Error(`Conflicting identities for ${entry.file}`); - } else { - entry.manifestKeys.push(manifestKey); - } - chunksByManifestKey.set(manifestKey, entry); - if (comparisonKey != null) { - const existing = comparableChunks.get(comparisonKey); - if (existing != null && existing.file !== entry.file) { - throw new Error(`Duplicate stable chunk key "${comparisonKey}": ${existing.file}, ${entry.file}`); - } - comparableChunks.set(comparisonKey, entry); - } - } - - const localeDir = path.join(outDir, locale); - if (await util.fileExists(localeDir)) { - for await (const fullPath of util.traverseDirectory(localeDir)) { - if (!fullPath.endsWith('.js')) continue; - const relativePath = util.normalizePath(path.relative(outDir, fullPath)); - if (chunksByFile.has(relativePath)) continue; - chunksByFile.set(relativePath, { - comparisonKey: null, - displayName: relativePath, - file: relativePath, - manifestKeys: [], - size: await util.fileSize(fullPath), - }); - } - } - - const startupFiles = new Set(); - for (const manifestKey of collectStartupManifestKeys(manifest)) { - const entry = chunksByManifestKey.get(manifestKey); - if (entry != null) startupFiles.add(entry.file); - } - - return { - manifest, - chunks: [...chunksByFile.values()], - comparableChunks: Object.fromEntries(comparableChunks), - chunksByManifestKey: Object.fromEntries(chunksByManifestKey), - startupFiles: [...startupFiles], - }; -} -``` - -- [ ] **Step 4: Update comparison rows and table rendering** - -Change `getChunkComparisonRows` to consume comparable maps and retain both filenames: - -```ts -function getChunkComparisonRows(keys: string[], before: Record, after: Record) { - return keys.map(key => { - const beforeEntry = before[key]; - const afterEntry = after[key]; - const beforeSize = beforeEntry?.size ?? 0; - const afterSize = afterEntry?.size ?? 0; - return { - key, - name: entryDisplayName(beforeEntry ?? afterEntry), - beforeFile: beforeEntry?.file, - afterFile: afterEntry?.file, - beforeSize, - afterSize, - changeType: beforeEntry == null ? 'added' : afterEntry == null ? 'removed' : beforeSize !== afterSize ? 'updated' : 'unchanged', - sortSize: Math.max(beforeSize, afterSize), - }; - }); -} - -type ChunkAggregate = { - beforeSize: number; - afterSize: number; - beforeCount: number; - afterCount: number; -}; - -function sumChunkSizes(chunks: FileEntry[]) { - return chunks.reduce((sum, chunk) => sum + chunk.size, 0); -} - -function generatedAggregate(before: FileEntry[], after: FileEntry[]): ChunkAggregate { - const beforeGenerated = before.filter(chunk => chunk.comparisonKey == null); - const afterGenerated = after.filter(chunk => chunk.comparisonKey == null); - return { - beforeSize: sumChunkSizes(beforeGenerated), - afterSize: sumChunkSizes(afterGenerated), - beforeCount: beforeGenerated.length, - afterCount: afterGenerated.length, - }; -} - -function comparableMap(chunks: FileEntry[]) { - const entries: [string, FileEntry][] = []; - for (const chunk of chunks) { - if (chunk.comparisonKey != null) entries.push([chunk.comparisonKey, chunk]); - } - return Object.fromEntries(entries); -} -``` - -Replace `chunkMarkdownTable` with this version, which adds a third `generated` argument: - -```ts -function chunkMarkdownTable( - rows: ReturnType, - total?: { beforeSize: number; afterSize: number }, - generated?: ChunkAggregate, -) { - if (rows.length === 0 && total == null && generated == null) 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 = row.beforeFile ?? row.afterFile ?? ''; - if (row.changeType === 'added') { - lines.push(`|
\`${escapeCell(row.name)}\` \`${escapeCell(chunkFile)}\`
| ${util.formatBytes(row.beforeSize)} | ${util.formatBytes(row.afterSize)} | ${util.calcAndFormatDeltaBytes(row.beforeSize, row.afterSize, 1000)} | $\\color{orange}{\\text{( + )}}$ |`); - } else if (row.changeType === 'removed') { - lines.push(`|
\`${escapeCell(row.name)}\` \`${escapeCell(chunkFile)}\`
| ${util.formatBytes(row.beforeSize)} | ${util.formatBytes(row.afterSize)} | ${util.calcAndFormatDeltaBytes(row.beforeSize, row.afterSize, 1000)} | $\\color{green}{\\text{( - )}}$ |`); - } else { - 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)) { - lines.push(''); - lines.push(`_${generated.beforeCount} before / ${generated.afterCount} after generated chunks are grouped._`); - } - return lines.join('\n'); -} -``` - -Update `renderFrontendChunkReport` so the full report uses all physical chunks for totals, stable maps for rows, and unidentifiable chunks for the aggregate: - -```ts -const beforeComparable = before.comparableChunks; -const afterComparable = after.comparableChunks; -const allChunkKeys = [...new Set([...Object.keys(beforeComparable), ...Object.keys(afterComparable)])]; -const allComparisonRows = getChunkComparisonRows(allChunkKeys, beforeComparable, afterComparable); -const diffTotal = { - beforeSize: sumChunkSizes(before.chunks), - afterSize: sumChunkSizes(after.chunks), -}; -const diffGenerated = generatedAggregate(before.chunks, after.chunks); -``` - -For startup accounting, filter physical chunks by `startupFiles`, rebuild comparable maps from those filtered arrays, and calculate totals and aggregate from the filtered arrays: - -```ts -const beforeStartupFiles = new Set(before.startupFiles); -const afterStartupFiles = new Set(after.startupFiles); -const beforeStartupChunks = before.chunks.filter(chunk => beforeStartupFiles.has(chunk.file)); -const afterStartupChunks = after.chunks.filter(chunk => afterStartupFiles.has(chunk.file)); -const beforeStartupComparable = comparableMap(beforeStartupChunks); -const afterStartupComparable = comparableMap(afterStartupChunks); -const startupKeys = [...new Set([...Object.keys(beforeStartupComparable), ...Object.keys(afterStartupComparable)])]; -const startupComparisonRows = getChunkComparisonRows(startupKeys, beforeStartupComparable, afterStartupComparable); -const startupTotal = { - beforeSize: sumChunkSizes(beforeStartupChunks), - afterSize: sumChunkSizes(afterStartupChunks), -}; -const startupGenerated = generatedAggregate(beforeStartupChunks, afterStartupChunks); -``` - -Pass the aggregates with these exact calls: - -```ts -chunkMarkdownTable(diffRows, diffTotal, diffGenerated) -chunkMarkdownTable(startupRows, startupTotal, startupGenerated) -``` - -- [ ] **Step 5: Run the focused test and verify it passes** - -Run: - -```bash -node --test .github/scripts/frontend-js-size.test.mts -``` - -Expected: PASS with `1` test and `0` failures. The Markdown contains two aggregate rows because the fixture makes all chunks startup chunks as well as full-report chunks. - -- [ ] **Step 6: Commit the physical-accounting change** - -```bash -git add .github/scripts/frontend-js-size.mts .github/scripts/frontend-js-size.test.mts -git commit -m "fix(dev): aggregate generated frontend chunks" -``` - ---- - -### Task 2: Reject duplicate stable identities and render honest filenames - -**Files:** -- Modify: `.github/scripts/frontend-js-size.test.mts` -- Modify: `.github/scripts/frontend-js-size.mts:315-372` - -**Interfaces:** -- Consumes: the `Duplicate stable chunk key` validation and comparison rows produced in Task 1. -- Produces: a fatal diagnostic for duplicate `src`/allowlisted identities and a file label that renders `before → after` when hashes differ. - -- [ ] **Step 1: Add failing coverage for duplicate stable names and filename changes** - -Append these tests to `.github/scripts/frontend-js-size.test.mts`: - -```ts -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' }; - duplicateVue.sizes['scripts/vue-duplicate-before.js'] = 60; - - await assert.rejects( - runReport(t, duplicateVue, fixture('after', 'esm', { entry: 110, generatedA: 30, generatedB: 40, vue: 45, i18n: 50 })), - /Duplicate stable chunk key "named:vue".*vue-before\.js.*vue-duplicate-before\.js/, - ); -}); - -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 }), - ); - - assert.match(report, /`ja-JP\/entry-before\.js → ja-JP\/entry-after\.js`/); - assert.match(report, /`ja-JP\/vue-before\.js → ja-JP\/vue-after\.js`/); -}); -``` - -- [ ] **Step 2: Run the tests and confirm the filename test fails** - -Run: - -```bash -node --test .github/scripts/frontend-js-size.test.mts -``` - -Expected: the duplicate-key test passes using Task 1 validation, while `shows both filenames for an updated stable chunk` fails because the table still shows only one file. - -- [ ] **Step 3: Render both filenames without changing identity logic** - -Add this helper near `chunkMarkdownTable`: - -```ts -function chunkFileDisplay(row: ReturnType[number]) { - if (row.beforeFile == null) return row.afterFile ?? ''; - if (row.afterFile == null || row.beforeFile === row.afterFile) return row.beforeFile; - return `${row.beforeFile} → ${row.afterFile}`; -} -``` - -At the start of each `for (const row of rows)` iteration, compute: - -```ts -const chunkFile = chunkFileDisplay(row); -``` - -Replace every `${escapeCell(row.chunkFile)}` interpolation in the three row variants with `${escapeCell(chunkFile)}`. Do not display any representative filename on the aggregate row. - -- [ ] **Step 4: Run all focused tests** - -Run: - -```bash -node --test .github/scripts/frontend-js-size.test.mts -``` - -Expected: PASS with `3` tests and `0` failures. - -- [ ] **Step 5: Commit the validation and display behavior** - -```bash -git add .github/scripts/frontend-js-size.mts .github/scripts/frontend-js-size.test.mts -git commit -m "fix(dev): clarify frontend chunk comparisons" -``` - ---- - -### Task 3: Run the report regression test in CI - -**Files:** -- Modify: `.github/workflows/lint.yml:3-33` -- Modify: `.github/workflows/lint.yml:139` - -**Interfaces:** -- Consumes: `.github/scripts/frontend-js-size.test.mts` from Tasks 1 and 2. -- Produces: a `frontend-bundle-report-test` GitHub Actions job using the repository's `.node-version`. - -- [ ] **Step 1: Verify the focused test is not currently referenced by CI** - -Run: - -```bash -rg -n "frontend-js-size.test|frontend-bundle-report-test" .github/workflows -``` - -Expected: no matches. - -- [ ] **Step 2: Add report-script paths to both lint workflow filters** - -Under both `on.push.paths` and `on.pull_request.paths` in `.github/workflows/lint.yml`, add: - -```yaml - - .github/scripts/frontend-js-size*.mts - - .github/scripts/utility.mts -``` - -Keep `.github/workflows/lint.yml` itself in both filters. - -- [ ] **Step 3: Add a focused CI job** - -Append this job under `jobs` at the same level as `check-dts`: - -```yaml - frontend-bundle-report-test: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v6.0.3 - - uses: actions/setup-node@v6.4.0 - with: - node-version-file: '.node-version' - - run: node --test .github/scripts/frontend-js-size.test.mts -``` - -This job intentionally does not depend on `pnpm_install` because the test and report generator use only Node built-ins and the local `utility.mts` module. - -- [ ] **Step 4: Run the exact CI test command locally** - -Run: - -```bash -node --test .github/scripts/frontend-js-size.test.mts -``` - -Expected: PASS with `3` tests and `0` failures. - -- [ ] **Step 5: Confirm CI now references the test exactly once** - -Run: - -```bash -rg -n "node --test \.github/scripts/frontend-js-size\.test\.mts" .github/workflows/lint.yml -``` - -Expected: one match in the `frontend-bundle-report-test` job. - -- [ ] **Step 6: Commit the CI coverage** - -```bash -git add .github/workflows/lint.yml -git commit -m "test(dev): check frontend bundle report" -``` - ---- - -### Task 4: Complete Misskey pre-ship validation - -**Files:** -- Verify: `.github/scripts/frontend-js-size.mts` -- Verify: `.github/scripts/frontend-js-size.test.mts` -- Verify: `.github/workflows/lint.yml` -- Verify: `docs/superpowers/specs/2026-07-15-frontend-bundle-generated-chunks-design.md` - -**Interfaces:** -- Consumes: all implementation and CI changes from Tasks 1-3. -- Produces: fresh evidence that focused behavior, repository lint, SPDX, locale safety, and final diff checks pass. - -- [ ] **Step 1: Run the focused regression tests** - -Run: - -```bash -node --test .github/scripts/frontend-js-size.test.mts -``` - -Expected: PASS with `3` tests and `0` failures. - -- [ ] **Step 2: Run repository lint** - -Run: - -```bash -pnpm lint -``` - -Expected: exit code `0`. If an unrelated pre-existing failure occurs, record its exact command and output rather than claiming lint passed. - -- [ ] **Step 3: Check the new file's SPDX header** - -Run: - -```bash -rg -n "SPDX-FileCopyrightText: syuilo and misskey-project|SPDX-License-Identifier: AGPL-3.0-only" .github/scripts/frontend-js-size.test.mts -``` - -Expected: two matches, one for each required SPDX line. - -- [ ] **Step 4: Verify locale safety and unchanged migration/API surfaces** - -Run: - -```bash -git diff --name-only develop -- locales -git diff --name-only develop -- packages/backend/migration packages/backend/src packages/misskey-js/src/autogen -``` - -Expected: both commands produce no output. Therefore locale generation, migration checking, backend API review, and misskey-js regeneration are not applicable. - -- [ ] **Step 5: Verify the final diff and worktree** - -Run: - -```bash -git diff --check develop...HEAD -git status --short -git log --oneline develop..HEAD -``` - -Expected: `git diff --check` exits `0`, `git status --short` is empty, and the log contains the approved design commit plus the three implementation commits from this plan. - -- [ ] **Step 6: Review the final report behavior against the approved design** - -Confirm all of these from the focused test output and diff: - -```text -[x] Physical chunks are counted once by output path. -[x] Only src-backed, vue, and i18n chunks are individually compared. -[x] Generated chunks are aggregated in full and startup reports. -[x] Duplicate stable keys fail instead of overwriting. -[x] Updated stable rows show before and after filenames. -[x] No locale, migration, backend API, frontend Vue, or user-facing behavior changed. -[x] No CHANGELOG entry is needed. -```