scripts/swimlanes-size.ts
Rama develop · réplica de lectura del repositorio original.
/* eslint-disable no-console */
import { build, type Metafile } from 'esbuild';
import { basename, relative, resolve } from 'node:path';
import { gzipSync } from 'node:zlib';
import { mkdir, readdir, readFile, rm, stat, writeFile } from 'node:fs/promises';
import { packageOptions } from '../.build/common.js';
import { defaultOptions, getBuildConfig } from '../.esbuild/util.js';
const REPORT_DIR = '.tmp/swimlanes-size';
const OUTPUT_DIR = `${REPORT_DIR}/full-mermaid`;
const META_PATH = `${REPORT_DIR}/mermaid-esm-min.meta.json`;
const JSON_PATH = `${REPORT_DIR}/swimlanes-js-size.json`;
const MARKDOWN_PATH = `${REPORT_DIR}/swimlanes-js-size.md`;
const BASELINE_PATH = `${REPORT_DIR}/swimlanes-js-size.baseline.json`;
const SWIMLANE_INPUT_ROOT = 'src/rendering-util/layout-algorithms/swimlanes/';
const SWIMLANE_FS_ROOT = 'packages/mermaid/src/rendering-util/layout-algorithms/swimlanes';
interface CliOptions {
baselinePath: string;
compare: boolean;
failOnGrowth: boolean;
writeBaseline: boolean;
}
interface FileSizeRow {
file: string;
fsPath: string;
sourceBytes: number;
minifiedBytesInOutput: number;
}
interface ChunkSummary {
path: string;
bytes: number;
gzipBytes: number;
}
interface SwimlaneSizeSummary {
generatedAt: string;
entry: string;
metafile: string;
report: string;
chunk: ChunkSummary;
swimlaneOwnedSourceBytes: number;
swimlaneOwnedMinifiedBytes: number;
productionReachableRuntimeFiles: number;
nonTestRuntimeFiles: number;
nonTestRuntimeFilesNotReachable: string[];
files: FileSizeRow[];
}
interface MetricDelta {
label: string;
baseline: number;
current: number;
delta: number;
}
interface FileDelta {
file: string;
baselineMinifiedBytes: number;
currentMinifiedBytes: number;
deltaMinifiedBytes: number;
}
interface SizeComparison {
baselinePath: string;
baselineGeneratedAt: string;
metrics: MetricDelta[];
files: FileDelta[];
}
const formatBytes = (bytes: number): string => {
if (bytes === 0) {
return '0 B';
}
return `${(bytes / 1024).toFixed(1)} KiB (${bytes} B)`;
};
const formatSignedBytes = (bytes: number): string => {
const sign = bytes > 0 ? '+' : '';
return `${sign}${formatBytes(bytes)}`;
};
const parseArgs = (argv: string[]): CliOptions => {
const options: CliOptions = {
baselinePath: BASELINE_PATH,
compare: false,
failOnGrowth: false,
writeBaseline: false,
};
for (let i = 0; i < argv.length; i++) {
const arg = argv[i];
if (arg === '--baseline') {
const baselinePath = argv[++i];
if (!baselinePath) {
throw new Error('--baseline requires a path.');
}
options.baselinePath = baselinePath;
continue;
}
if (arg === '--compare') {
options.compare = true;
continue;
}
if (arg === '--fail-on-growth') {
options.failOnGrowth = true;
options.compare = true;
continue;
}
if (arg === '--write-baseline') {
options.writeBaseline = true;
continue;
}
if (arg === '--help') {
console.log(`Usage: pnpm exec tsx scripts/swimlanes-size.ts [options]
Options:
--write-baseline Save the current summary as ${BASELINE_PATH}
--compare Compare current summary to the saved baseline
--fail-on-growth Exit non-zero when chunk/gzip/owned minified bytes grow
--baseline <path> Read/write a custom baseline path
`);
process.exit(0);
}
throw new Error(`Unknown argument: ${arg}`);
}
return options;
};
const isRuntimeSwimlaneInput = (inputPath: string): boolean =>
inputPath.startsWith(SWIMLANE_INPUT_ROOT) &&
!inputPath.includes('/__tests__/') &&
!inputPath.endsWith('.spec.ts');
const listRuntimeTsFiles = async (dir: string): Promise<string[]> => {
const entries = await readdir(dir);
const files = await Promise.all(
entries.map(async (name) => {
const path = `${dir}/${name}`;
const fileStat = await stat(path);
if (fileStat.isDirectory()) {
return name === '__tests__' ? [] : await listRuntimeTsFiles(path);
}
if (path.endsWith('.ts') && !path.endsWith('.spec.ts')) {
return [path];
}
return [];
})
);
return files.flat();
};
const buildMermaidMetafile = async (): Promise<Metafile> => {
await rm(OUTPUT_DIR, { recursive: true, force: true });
await mkdir(REPORT_DIR, { recursive: true });
const buildConfig = getBuildConfig({
...defaultOptions,
options: packageOptions.mermaid,
minify: true,
metafile: true,
outdir: `../../${OUTPUT_DIR}`,
sourcemap: false,
});
buildConfig.logLevel = 'warning';
const result = await build(buildConfig);
if (!result.metafile) {
throw new Error('Expected esbuild to return a metafile.');
}
await writeFile(META_PATH, JSON.stringify(result.metafile));
return result.metafile;
};
const readOutputBytes = async (outputPath: string): Promise<Buffer> => {
// esbuild output paths are relative to packages/mermaid because getBuildConfig
// sets absWorkingDir there.
return await readFile(resolve('packages/mermaid', outputPath));
};
const summarizeMetafile = async (metafile: Metafile): Promise<SwimlaneSizeSummary> => {
const rowsByFile = new Map<string, FileSizeRow>();
for (const output of Object.values(metafile.outputs)) {
for (const [inputPath, contribution] of Object.entries(output.inputs ?? {})) {
if (!isRuntimeSwimlaneInput(inputPath)) {
continue;
}
const row = rowsByFile.get(inputPath) ?? {
file: inputPath.slice(SWIMLANE_INPUT_ROOT.length),
fsPath: `packages/mermaid/${inputPath}`,
sourceBytes: metafile.inputs[inputPath]?.bytes ?? 0,
minifiedBytesInOutput: 0,
};
row.minifiedBytesInOutput += contribution.bytesInOutput ?? 0;
rowsByFile.set(inputPath, row);
}
}
const files = [...rowsByFile.values()].sort(
(a, b) => b.minifiedBytesInOutput - a.minifiedBytesInOutput
);
const reachablePaths = new Set(files.map((row) => row.fsPath));
const runtimeFiles = (await listRuntimeTsFiles(SWIMLANE_FS_ROOT)).sort();
const nonTestRuntimeFilesNotReachable = runtimeFiles.filter((path) => !reachablePaths.has(path));
const swimlaneChunkEntry = Object.entries(metafile.outputs).find(
([path]) => basename(path).startsWith('swimlanes-') && path.endsWith('.mjs')
);
if (!swimlaneChunkEntry) {
throw new Error('Could not find the emitted swimlanes chunk in the esbuild metafile.');
}
const [chunkPath, chunkOutput] = swimlaneChunkEntry;
const chunkBytes = await readOutputBytes(chunkPath);
return {
generatedAt: new Date().toISOString(),
entry: 'packages/mermaid/src/mermaid.ts',
metafile: META_PATH,
report: MARKDOWN_PATH,
chunk: {
path: chunkPath,
bytes: chunkOutput.bytes,
gzipBytes: gzipSync(chunkBytes, { level: 9 }).length,
},
swimlaneOwnedSourceBytes: files.reduce((total, row) => total + row.sourceBytes, 0),
swimlaneOwnedMinifiedBytes: files.reduce((total, row) => total + row.minifiedBytesInOutput, 0),
productionReachableRuntimeFiles: files.length,
nonTestRuntimeFiles: runtimeFiles.length,
nonTestRuntimeFilesNotReachable,
files,
};
};
const renderMarkdown = (summary: SwimlaneSizeSummary): string => {
const unreachable =
summary.nonTestRuntimeFilesNotReachable.length === 0
? 'none'
: summary.nonTestRuntimeFilesNotReachable
.map((path) => `\`${relative(SWIMLANE_FS_ROOT, path)}\``)
.join(', ');
const rows = summary.files
.map(
(row) =>
`| \`${row.file}\` | ${formatBytes(row.sourceBytes)} | ${formatBytes(
row.minifiedBytesInOutput
)} |`
)
.join('\n');
return `# Swimlanes JS Size
Generated: ${summary.generatedAt}
## Method
Builds the real Mermaid ESM minified entry with the repo esbuild config into \`${OUTPUT_DIR}\`, with metafile output at \`${META_PATH}\`. The table counts only production-reachable non-test inputs under \`${SWIMLANE_FS_ROOT}/\`.
\`bytesInOutput\` is esbuild's minified byte attribution per source file. Gzip is only reported for the emitted chunk as a whole.
## Summary
- Swimlane emitted chunk: \`${summary.chunk.path}\`
- Swimlane chunk size: ${formatBytes(summary.chunk.bytes)}
- Swimlane chunk gzip size: ${formatBytes(summary.chunk.gzipBytes)}
- Swimlane-owned minified bytes in chunk: ${formatBytes(summary.swimlaneOwnedMinifiedBytes)}
- Swimlane-owned source bytes: ${formatBytes(summary.swimlaneOwnedSourceBytes)}
- Production-reachable non-test swimlane files: ${summary.productionReachableRuntimeFiles} / ${
summary.nonTestRuntimeFiles
}
- Non-test swimlane files not reachable from production entry: ${unreachable}
## Files
| File | Source bytes | Minified bytes in output |
| --- | ---: | ---: |
${rows}
`;
};
const compareSummaries = (
current: SwimlaneSizeSummary,
baseline: SwimlaneSizeSummary,
baselinePath: string
): SizeComparison => {
const baselineFiles = new Map(baseline.files.map((row) => [row.file, row]));
const currentFiles = new Map(current.files.map((row) => [row.file, row]));
const fileNames = [...new Set([...baselineFiles.keys(), ...currentFiles.keys()])].sort();
return {
baselinePath,
baselineGeneratedAt: baseline.generatedAt,
metrics: [
{
label: 'Swimlane chunk',
baseline: baseline.chunk.bytes,
current: current.chunk.bytes,
delta: current.chunk.bytes - baseline.chunk.bytes,
},
{
label: 'Swimlane chunk gzip',
baseline: baseline.chunk.gzipBytes,
current: current.chunk.gzipBytes,
delta: current.chunk.gzipBytes - baseline.chunk.gzipBytes,
},
{
label: 'Swimlane-owned minified bytes',
baseline: baseline.swimlaneOwnedMinifiedBytes,
current: current.swimlaneOwnedMinifiedBytes,
delta: current.swimlaneOwnedMinifiedBytes - baseline.swimlaneOwnedMinifiedBytes,
},
{
label: 'Swimlane-owned source bytes',
baseline: baseline.swimlaneOwnedSourceBytes,
current: current.swimlaneOwnedSourceBytes,
delta: current.swimlaneOwnedSourceBytes - baseline.swimlaneOwnedSourceBytes,
},
],
files: fileNames.map((file) => {
const baselineMinifiedBytes = baselineFiles.get(file)?.minifiedBytesInOutput ?? 0;
const currentMinifiedBytes = currentFiles.get(file)?.minifiedBytesInOutput ?? 0;
return {
file,
baselineMinifiedBytes,
currentMinifiedBytes,
deltaMinifiedBytes: currentMinifiedBytes - baselineMinifiedBytes,
};
}),
};
};
const renderComparisonMarkdown = (comparison: SizeComparison): string => {
const metricRows = comparison.metrics
.map(
(metric) =>
`| ${metric.label} | ${formatBytes(metric.baseline)} | ${formatBytes(
metric.current
)} | ${formatSignedBytes(metric.delta)} |`
)
.join('\n');
const renderFileRows = (rows: FileDelta[]): string =>
rows.length === 0
? '_No file-level changes._'
: [
'| File | Baseline minified | Current minified | Delta |',
'| --- | ---: | ---: | ---: |',
...rows.map(
(row) =>
`| \`${row.file}\` | ${formatBytes(row.baselineMinifiedBytes)} | ${formatBytes(
row.currentMinifiedBytes
)} | ${formatSignedBytes(row.deltaMinifiedBytes)} |`
),
].join('\n');
const reductions = comparison.files
.filter((row) => row.deltaMinifiedBytes < 0)
.sort((a, b) => a.deltaMinifiedBytes - b.deltaMinifiedBytes)
.slice(0, 10);
const growth = comparison.files
.filter((row) => row.deltaMinifiedBytes > 0)
.sort((a, b) => b.deltaMinifiedBytes - a.deltaMinifiedBytes)
.slice(0, 10);
return `## Comparison
Baseline: \`${comparison.baselinePath}\`
Baseline generated: ${comparison.baselineGeneratedAt}
| Metric | Baseline | Current | Delta |
| --- | ---: | ---: | ---: |
${metricRows}
### Largest File Reductions
${renderFileRows(reductions)}
### Largest File Growth
${renderFileRows(growth)}
`;
};
const printConsoleSummary = (summary: SwimlaneSizeSummary, comparison?: SizeComparison): void => {
console.log('Swimlanes JS size');
console.log(` Chunk: ${formatBytes(summary.chunk.bytes)}`);
console.log(` Chunk gzip: ${formatBytes(summary.chunk.gzipBytes)}`);
console.log(
` Swimlane-owned minified bytes: ${formatBytes(summary.swimlaneOwnedMinifiedBytes)}`
);
console.log(
` Reachable runtime files: ${summary.productionReachableRuntimeFiles}/${summary.nonTestRuntimeFiles}`
);
if (summary.nonTestRuntimeFilesNotReachable.length > 0) {
console.log(
` Not reachable: ${summary.nonTestRuntimeFilesNotReachable
.map((path) => relative(SWIMLANE_FS_ROOT, path))
.join(', ')}`
);
}
console.log('\nTop contributors:');
for (const row of summary.files.slice(0, 10)) {
console.log(` ${formatBytes(row.minifiedBytesInOutput).padStart(20)} ${row.file}`);
}
console.log(`\nWrote ${MARKDOWN_PATH}`);
console.log(`Wrote ${JSON_PATH}`);
if (comparison) {
console.log(`\nCompared with ${comparison.baselinePath}`);
for (const metric of comparison.metrics) {
console.log(
` ${metric.label}: ${formatBytes(metric.current)} (${formatSignedBytes(metric.delta)})`
);
}
}
};
const readBaseline = async (path: string): Promise<SwimlaneSizeSummary> => {
try {
return JSON.parse(await readFile(path, 'utf8')) as SwimlaneSizeSummary;
} catch (error) {
throw new Error(
`Could not read swimlane size baseline at ${path}. Run with --write-baseline first.`,
{
cause: error,
}
);
}
};
const assertNoGrowth = (comparison: SizeComparison): void => {
const growthMetrics = comparison.metrics
.filter((metric) => metric.label !== 'Swimlane-owned source bytes')
.filter((metric) => metric.delta > 0);
if (growthMetrics.length === 0) {
return;
}
const details = growthMetrics
.map((metric) => `${metric.label} grew by ${formatSignedBytes(metric.delta)}`)
.join('; ');
throw new Error(`Swimlane size regression: ${details}`);
};
const main = async (): Promise<void> => {
const options = parseArgs(process.argv.slice(2));
const metafile = await buildMermaidMetafile();
const summary = await summarizeMetafile(metafile);
const comparison = options.compare
? compareSummaries(summary, await readBaseline(options.baselinePath), options.baselinePath)
: undefined;
await writeFile(JSON_PATH, JSON.stringify(summary, null, 2) + '\n');
await writeFile(
MARKDOWN_PATH,
comparison
? `${renderMarkdown(summary)}\n${renderComparisonMarkdown(comparison)}`
: renderMarkdown(summary)
);
if (options.writeBaseline) {
await writeFile(options.baselinePath, JSON.stringify(summary, null, 2) + '\n');
}
printConsoleSummary(summary, comparison);
if (options.writeBaseline) {
console.log(`Wrote baseline ${options.baselinePath}`);
}
if (options.failOnGrowth && comparison) {
assertNoGrowth(comparison);
}
};
void main().catch((error) => {
console.error(error);
process.exit(1);
});