From 56fd5d174d67469c85df4b8ab350b2a08634487d Mon Sep 17 00:00:00 2001 From: bymyself Date: Sat, 18 Oct 2025 00:59:37 -0700 Subject: [PATCH] format ci size report comment --- scripts/bundle-categories.js | 75 +++-- scripts/size-report.js | 564 +++++++++++++++++++++++++++++------ 2 files changed, 537 insertions(+), 102 deletions(-) diff --git a/scripts/bundle-categories.js b/scripts/bundle-categories.js index c6fc928e00..a0848888ae 100644 --- a/scripts/bundle-categories.js +++ b/scripts/bundle-categories.js @@ -18,44 +18,81 @@ export const BUNDLE_CATEGORIES = [ { name: 'App Entry Points', - description: 'Main application bundles', - patterns: [/^index-.*\.js$/], + description: 'Main entry bundles and manifests', + patterns: [/^index-.*\.js$/i, /^manifest-.*\.js$/i], order: 1 }, { - name: 'Core Views', - description: 'Major application views and screens', - patterns: [/GraphView-.*\.js$/, /UserSelectView-.*\.js$/], + name: 'Graph Workspace', + description: 'Graph editor runtime, canvas, workflow orchestration', + patterns: [ + /Graph(View|State)?-.*\.js$/i, + /(Canvas|Workflow|History|NodeGraph|Compositor)-.*\.js$/i + ], order: 2 }, { - name: 'UI Panels', - description: 'Settings and configuration panels', - patterns: [/.*Panel-.*\.js$/], + name: 'Views & Navigation', + description: 'Top-level views, pages, and routed surfaces', + patterns: [/.*(View|Page|Layout|Screen|Route)-.*\.js$/i], order: 3 }, { - name: 'UI Components', - description: 'Reusable UI components', - patterns: [/Avatar-.*\.js$/, /Badge-.*\.js$/], + name: 'Panels & Settings', + description: 'Configuration panels, inspectors, and settings screens', + patterns: [/.*(Panel|Settings|Config|Preferences|Manager)-.*\.js$/i], order: 4 }, { - name: 'Services', - description: 'Business logic and services', - patterns: [/.*Service-.*\.js$/, /.*Store-.*\.js$/], + name: 'User & Accounts', + description: 'Authentication, profile, and account management bundles', + patterns: [ + /.*((User(Panel|Select|Auth|Account|Profile|Settings|Preferences|Manager|List|Menu|Modal))|Account|Auth|Profile|Login|Signup|Password).*-.+\.js$/i + ], order: 5 }, { - name: 'Utilities', - description: 'Helper functions and utilities', - patterns: [/.*[Uu]til.*\.js$/], + name: 'Editors & Dialogs', + description: 'Modals, dialogs, drawers, and in-app editors', + patterns: [/.*(Modal|Dialog|Drawer|Editor)-.*\.js$/i], order: 6 }, + { + name: 'UI Components', + description: 'Reusable component library chunks', + patterns: [ + /.*(Button|Avatar|Badge|Dropdown|Tabs|Table|List|Card|Form|Input|Toggle|Menu|Toolbar|Sidebar)-.*\.js$/i, + /.*\.vue_vue_type_script_setup_true_lang-.*\.js$/i + ], + order: 7 + }, + { + name: 'Data & Services', + description: 'Stores, services, APIs, and repositories', + patterns: [/.*(Service|Store|Api|Repository)-.*\.js$/i], + order: 8 + }, + { + name: 'Utilities & Hooks', + description: 'Helpers, composables, and utility bundles', + patterns: [ + /.*(Util|Utils|Helper|Composable|Hook)-.*\.js$/i, + /use[A-Z].*\.js$/ + ], + order: 9 + }, + { + name: 'Vendor & Third-Party', + description: 'External libraries and shared vendor chunks', + patterns: [ + /^(chunk|vendor|prime|three|lodash|chart|firebase|yjs|axios|uuid)-.*\.js$/i + ], + order: 10 + }, { name: 'Other', - description: 'Uncategorized bundles', - patterns: [/.*/], // Catch-all pattern + description: 'Bundles that do not match a named category', + patterns: [/.*/], order: 99 } ] diff --git a/scripts/size-report.js b/scripts/size-report.js index 14dd5d95db..e92d53ee37 100644 --- a/scripts/size-report.js +++ b/scripts/size-report.js @@ -7,6 +7,13 @@ import prettyBytes from 'pretty-bytes' import { getCategoryMetadata } from './bundle-categories.js' +/** + * @typedef {Object} SizeMetrics + * @property {number} size + * @property {number} gzip + * @property {number} brotli + */ + /** * @typedef {Object} SizeResult * @property {number} size @@ -18,15 +25,52 @@ import { getCategoryMetadata } from './bundle-categories.js' * @typedef {SizeResult & { file: string, category?: string }} BundleResult */ +/** + * @typedef {'added' | 'removed' | 'increased' | 'decreased' | 'unchanged'} BundleStatus + */ + +/** + * @typedef {Object} BundleDiff + * @property {string} fileName + * @property {BundleResult | undefined} curr + * @property {BundleResult | undefined} prev + * @property {SizeMetrics} diff + * @property {BundleStatus} status + */ + +/** + * @typedef {Object} CountSummary + * @property {number} added + * @property {number} removed + * @property {number} increased + * @property {number} decreased + * @property {number} unchanged + */ + +/** + * @typedef {Object} CategoryReport + * @property {string} name + * @property {string | undefined} description + * @property {number} order + * @property {{ current: SizeMetrics, baseline: SizeMetrics, diff: SizeMetrics }} metrics + * @property {CountSummary} counts + * @property {BundleDiff[]} bundles + */ + +/** + * @typedef {Object} BundleReport + * @property {CategoryReport[]} categories + * @property {{ currentBundles: number, baselineBundles: number, metrics: { current: SizeMetrics, baseline: SizeMetrics, diff: SizeMetrics }, counts: CountSummary }} overall + * @property {boolean} hasBaseline + */ + const currDir = path.resolve('temp/size') const prevDir = path.resolve('temp/size-prev') -let output = '## Bundle Size Report\n\n' -const sizeHeaders = ['Size', 'Gzip', 'Brotli'] run() /** - * Main function to generate the size report + * Main entry for generating the size report */ async function run() { if (!existsSync(currDir)) { @@ -35,27 +79,41 @@ async function run() { process.exit(1) } - await renderFiles() + const report = await buildBundleReport() + const output = renderReport(report) process.stdout.write(output) } /** - * Renders file sizes and diffs between current and previous versions + * Build bundle comparison data from current and baseline artifacts + * @returns {Promise} */ -async function renderFiles() { +async function buildBundleReport() { /** * @param {string[]} files * @returns {string[]} */ const filterFiles = (files) => files.filter((file) => file.endsWith('.json')) - const curr = filterFiles(await readdir(currDir)) - const prev = existsSync(prevDir) ? filterFiles(await readdir(prevDir)) : [] - const fileList = new Set([...curr, ...prev]) + const currFiles = filterFiles(await readdir(currDir)) + const baselineFiles = existsSync(prevDir) + ? filterFiles(await readdir(prevDir)) + : [] + const fileList = new Set([...currFiles, ...baselineFiles]) - // Group bundles by category - /** @type {Map>} */ - const bundlesByCategory = new Map() + /** @type {Map} */ + const categories = new Map() + + const overall = { + currentBundles: 0, + baselineBundles: 0, + metrics: { + current: createMetrics(), + baseline: createMetrics(), + diff: createMetrics() + }, + counts: createCounts() + } for (const file of fileList) { const currPath = path.resolve(currDir, file) @@ -63,100 +121,440 @@ async function renderFiles() { const curr = await importJSON(currPath) const prev = await importJSON(prevPath) - const fileName = curr?.file || prev?.file || '' - const category = curr?.category || prev?.category || 'Other' + const fileName = curr?.file || prev?.file + if (!fileName) continue + + const categoryName = curr?.category || prev?.category || 'Other' + const category = ensureCategoryEntry(categories, categoryName) - if (!bundlesByCategory.has(category)) { - bundlesByCategory.set(category, []) + const currMetrics = toMetrics(curr) + const baselineMetrics = toMetrics(prev) + const diffMetrics = subtractMetrics(currMetrics, baselineMetrics) + const status = getStatus(curr, prev, diffMetrics.size) + + if (curr) { + overall.currentBundles++ + } + if (prev) { + overall.baselineBundles++ } - // @ts-expect-error - get is valid - bundlesByCategory.get(category).push({ fileName, curr, prev }) + addMetrics(overall.metrics.current, currMetrics) + addMetrics(overall.metrics.baseline, baselineMetrics) + addMetrics(overall.metrics.diff, diffMetrics) + incrementStatus(overall.counts, status) + + addMetrics(category.metrics.current, currMetrics) + addMetrics(category.metrics.baseline, baselineMetrics) + addMetrics(category.metrics.diff, diffMetrics) + incrementStatus(category.counts, status) + + category.bundles.push({ + fileName, + curr, + prev, + diff: diffMetrics, + status + }) } - // Sort categories by their order - const sortedCategories = Array.from(bundlesByCategory.keys()).sort((a, b) => { - const metaA = getCategoryMetadata(a) - const metaB = getCategoryMetadata(b) - return (metaA?.order ?? 99) - (metaB?.order ?? 99) - }) + const sortedCategories = Array.from(categories.values()).sort( + (a, b) => a.order - b.order + ) - let totalSize = 0 - let totalCount = 0 + return { + categories: sortedCategories, + overall, + hasBaseline: baselineFiles.length > 0 + } +} - // Render each category - for (const category of sortedCategories) { - const bundles = bundlesByCategory.get(category) || [] - if (bundles.length === 0) continue +/** + * Render the complete report in markdown + * @param {BundleReport} report + * @returns {string} + */ +function renderReport(report) { + const parts = ['## Bundle Size Report\n'] + + parts.push(renderSummary(report)) - const categoryMeta = getCategoryMetadata(category) - output += `### ${category}\n\n` - if (categoryMeta?.description) { - output += `_${categoryMeta.description}_\n\n` + if (report.categories.length > 0) { + const glance = renderCategoryGlance(report) + if (glance) { + parts.push('\n' + glance) } + parts.push('\n' + renderCategoryDetails(report)) + } - const rows = [] - let categorySize = 0 - - for (const { fileName, curr, prev } of bundles) { - if (!curr) { - // File was deleted - rows.push([`~~${fileName}~~`]) - } else { - rows.push([ - fileName, - `${prettyBytes(curr.size)}${getDiff(curr.size, prev?.size)}`, - `${prettyBytes(curr.gzip)}${getDiff(curr.gzip, prev?.gzip)}`, - `${prettyBytes(curr.brotli)}${getDiff(curr.brotli, prev?.brotli)}` - ]) - categorySize += curr.size - totalSize += curr.size - totalCount++ - } + return ( + parts + .join('\n') + .replace(/\n{3,}/g, '\n\n') + .trimEnd() + '\n' + ) +} + +/** + * Render overall summary bullets + * @param {BundleReport} report + * @returns {string} + */ +function renderSummary(report) { + const { overall, hasBaseline } = report + const lines = ['**Summary**'] + + const rawLineParts = [ + `- Raw size: ${prettyBytes(overall.metrics.current.size)}` + ] + if (hasBaseline) { + rawLineParts.push(`baseline ${prettyBytes(overall.metrics.baseline.size)}`) + rawLineParts.push(`— ${formatDiffIndicator(overall.metrics.diff.size)}`) + } + lines.push(rawLineParts.join(' ')) + + const gzipLineParts = [`- Gzip: ${prettyBytes(overall.metrics.current.gzip)}`] + if (hasBaseline) { + gzipLineParts.push(`baseline ${prettyBytes(overall.metrics.baseline.gzip)}`) + gzipLineParts.push(`— ${formatDiffIndicator(overall.metrics.diff.gzip)}`) + } + lines.push(gzipLineParts.join(' ')) + + const brotliLineParts = [ + `- Brotli: ${prettyBytes(overall.metrics.current.brotli)}` + ] + if (hasBaseline) { + brotliLineParts.push( + `baseline ${prettyBytes(overall.metrics.baseline.brotli)}` + ) + brotliLineParts.push( + `— ${formatDiffIndicator(overall.metrics.diff.brotli)}` + ) + } + lines.push(brotliLineParts.join(' ')) + + const bundleStats = [`${overall.currentBundles} current`] + if (hasBaseline) { + bundleStats.push(`${overall.baselineBundles} baseline`) + } + + const statusParts = [] + if (overall.counts.added) statusParts.push(`${overall.counts.added} added`) + if (overall.counts.removed) + statusParts.push(`${overall.counts.removed} removed`) + if (overall.counts.increased) + statusParts.push(`${overall.counts.increased} grew`) + if (overall.counts.decreased) + statusParts.push(`${overall.counts.decreased} shrank`) + + let bundlesLine = `- Bundles: ${bundleStats.join(' • ')}` + if (statusParts.length > 0) { + bundlesLine += ` • ${statusParts.join(' / ')}` + } + lines.push(bundlesLine) + + if (!hasBaseline) { + lines.push( + '_Baseline artifact not found; showing current bundle sizes only._' + ) + } + + return lines.join('\n') +} + +/** + * Render a compact category glance line + * @param {BundleReport} report + * @returns {string} + */ +function renderCategoryGlance(report) { + const { categories, hasBaseline } = report + const relevant = categories.filter( + (category) => + category.metrics.current.size > 0 || + (hasBaseline && category.metrics.baseline.size > 0) + ) + + if (relevant.length === 0) return '' + + const sorted = relevant.slice().sort((a, b) => { + if (hasBaseline) { + return ( + Math.abs(b.metrics.diff.size) - Math.abs(a.metrics.diff.size) || + b.metrics.current.size - a.metrics.current.size + ) } + return b.metrics.current.size - a.metrics.current.size + }) + + const limit = 6 + const trimmed = sorted.slice(0, limit) + const parts = trimmed.map((category) => { + const currentStr = prettyBytes(category.metrics.current.size) + if (hasBaseline) { + return `${category.name} ${formatDiffIndicator(category.metrics.diff.size)} (${currentStr})` + } + return `${category.name} ${currentStr}` + }) + + if (sorted.length > limit) { + parts.push(`+ ${sorted.length - limit} more`) + } + + return `**Category Glance**\n${parts.join(' · ')}` +} + +/** + * Render per-category detail tables wrapped in collapsible sections + * @param {BundleReport} report + * @returns {string} + */ +function renderCategoryDetails(report) { + const lines = ['
', 'Per-category breakdown', ''] + + for (const category of report.categories) { + lines.push(renderCategoryBlock(category, report.hasBaseline)) + } - // Sort rows by file name within category - rows.sort((a, b) => { - const fileA = a[0].replace(/~~/g, '') - const fileB = b[0].replace(/~~/g, '') - return fileA.localeCompare(fileB) + lines.push('
') + return lines.join('\n') +} + +/** + * Render a single category block with its table + * @param {CategoryReport} category + * @param {boolean} hasBaseline + * @returns {string} + */ +function renderCategoryBlock(category, hasBaseline) { + const lines = ['
'] + const currentStr = prettyBytes(category.metrics.current.size) + const summaryParts = [`${category.name} — ${currentStr}`] + + if (hasBaseline) { + summaryParts.push( + ` (baseline ${prettyBytes(category.metrics.baseline.size)}) • ${formatDiffIndicator(category.metrics.diff.size)}` + ) + } + + summaryParts.push('') + lines.push(summaryParts.join('')) + + if (category.description) { + lines.push(`_${category.description}_`) + } + + if (category.bundles.length === 0) { + lines.push('No bundles matched this category.\n') + lines.push('
\n') + return lines.join('\n') + } + + const headers = hasBaseline + ? ['File', 'Before', 'After', 'Δ Raw', 'Δ Gzip', 'Δ Brotli'] + : ['File', 'Size', 'Gzip', 'Brotli'] + + const rows = category.bundles + .slice() + .sort((a, b) => { + const diffMagnitude = Math.abs(b.diff.size) - Math.abs(a.diff.size) + if (diffMagnitude !== 0) return diffMagnitude + return a.fileName.localeCompare(b.fileName) }) + .map((bundle) => { + if (hasBaseline) { + return [ + formatFileLabel(bundle), + formatSize(bundle.prev?.size), + formatSize(bundle.curr?.size), + formatDiffIndicator(bundle.diff.size), + formatDiffIndicator(bundle.diff.gzip), + formatDiffIndicator(bundle.diff.brotli) + ] + } - output += markdownTable([['File', ...sizeHeaders], ...rows]) - output += `\n\n**Category Total:** ${prettyBytes(categorySize)}\n\n` + return [ + formatFileLabel(bundle), + formatSize(bundle.curr?.size), + formatSize(bundle.curr?.gzip), + formatSize(bundle.curr?.brotli) + ] + }) + + lines.push(markdownTable([headers, ...rows])) + + const statusParts = [] + if (category.counts.added) statusParts.push(`${category.counts.added} added`) + if (category.counts.removed) + statusParts.push(`${category.counts.removed} removed`) + if (category.counts.increased) + statusParts.push(`${category.counts.increased} grew`) + if (category.counts.decreased) + statusParts.push(`${category.counts.decreased} shrank`) + + if (statusParts.length > 0) { + lines.push(`\n_Status:_ ${statusParts.join(' / ')}`) } - // Add overall summary - if (totalCount > 0) { - output += '---\n\n' - output += `**Overall Total Size:** ${prettyBytes(totalSize)}\n` - output += `**Total Bundle Count:** ${totalCount}\n` + lines.push('\n') + return lines.join('\n') +} + +/** + * Ensure a category entry exists in the map + * @param {Map} categories + * @param {string} categoryName + * @returns {CategoryReport} + */ +function ensureCategoryEntry(categories, categoryName) { + if (!categories.has(categoryName)) { + const meta = getCategoryMetadata(categoryName) + categories.set(categoryName, { + name: categoryName, + description: meta?.description, + order: meta?.order ?? 99, + metrics: { + current: createMetrics(), + baseline: createMetrics(), + diff: createMetrics() + }, + counts: createCounts(), + bundles: [] + }) } + // @ts-expect-error - ensured by check above + return categories.get(categoryName) } /** - * Imports JSON data from a specified path - * - * @template T - * @param {string} filePath - Path to the JSON file - * @returns {Promise} The JSON content or undefined if the file does not exist + * Convert bundle result to metrics + * @param {BundleResult | undefined} bundle + * @returns {SizeMetrics} */ -async function importJSON(filePath) { - if (!existsSync(filePath)) return undefined - return (await import(filePath, { with: { type: 'json' } })).default +function toMetrics(bundle) { + if (!bundle) return createMetrics() + return { + size: bundle.size, + gzip: bundle.gzip, + brotli: bundle.brotli + } +} + +/** + * Create an empty metrics object + * @returns {SizeMetrics} + */ +function createMetrics() { + return { size: 0, gzip: 0, brotli: 0 } +} + +/** + * Add source metrics into target metrics + * @param {SizeMetrics} target + * @param {SizeMetrics} source + */ +function addMetrics(target, source) { + target.size += source.size + target.gzip += source.gzip + target.brotli += source.brotli +} + +/** + * Subtract baseline metrics from current metrics + * @param {SizeMetrics} current + * @param {SizeMetrics} baseline + * @returns {SizeMetrics} + */ +function subtractMetrics(current, baseline) { + return { + size: current.size - baseline.size, + gzip: current.gzip - baseline.gzip, + brotli: current.brotli - baseline.brotli + } +} + +/** + * Create an empty counts object + * @returns {CountSummary} + */ +function createCounts() { + return { added: 0, removed: 0, increased: 0, decreased: 0, unchanged: 0 } } /** - * Calculates the difference between the current and previous sizes - * - * @param {number} curr - The current size - * @param {number} [prev] - The previous size - * @returns {string} The difference in pretty format + * Increment status counters + * @param {CountSummary} counts + * @param {BundleStatus} status */ -function getDiff(curr, prev) { - if (prev === undefined) return '' - const diff = curr - prev - if (diff === 0) return '' - const sign = diff > 0 ? '+' : '' - return ` (**${sign}${prettyBytes(diff)}**)` +function incrementStatus(counts, status) { + counts[status] += 1 +} + +/** + * Determine bundle status for reporting + * @param {BundleResult | undefined} curr + * @param {BundleResult | undefined} prev + * @param {number} sizeDiff + * @returns {BundleStatus} + */ +function getStatus(curr, prev, sizeDiff) { + if (curr && prev) { + if (sizeDiff > 0) return 'increased' + if (sizeDiff < 0) return 'decreased' + return 'unchanged' + } + if (curr && !prev) return 'added' + if (!curr && prev) return 'removed' + return 'unchanged' +} + +/** + * Format file label with status hints + * @param {BundleDiff} bundle + * @returns {string} + */ +function formatFileLabel(bundle) { + if (bundle.status === 'added') { + return `**${bundle.fileName}** _(new)_` + } + if (bundle.status === 'removed') { + return `~~${bundle.fileName}~~ _(removed)_` + } + return bundle.fileName +} + +/** + * Format size for table output + * @param {number | undefined} value + * @returns {string} + */ +function formatSize(value) { + if (value === undefined) return '—' + return prettyBytes(value) +} + +/** + * Format a diff with an indicator emoji + * @param {number} diff + * @returns {string} + */ +function formatDiffIndicator(diff) { + if (diff > 0) { + return `:red_circle: +${prettyBytes(diff)}` + } + if (diff < 0) { + return `:green_circle: -${prettyBytes(Math.abs(diff))}` + } + return ':white_circle: 0 B' +} + +/** + * Import JSON data if it exists + * @template T + * @param {string} filePath + * @returns {Promise} + */ +async function importJSON(filePath) { + if (!existsSync(filePath)) return undefined + return (await import(filePath, { with: { type: 'json' } })).default }