|
| 1 | +/** |
| 2 | + * Auto-bumper for .size-limit.js. |
| 3 | + * |
| 4 | + * - Reads `yarn size-limit --json` output |
| 5 | + * - For each entry, computes a new limit of roundUpToKB(currentSize + 5000) |
| 6 | + * and applies it whenever the displayed value would change |
| 7 | + * - Rewrites .size-limit.js as plain text (NEVER require()d — the file contains |
| 8 | + * user-defined webpack/esbuild config functions that we don't want executing) |
| 9 | + * |
| 10 | + * Exit codes: 0 = wrote changes, 2 = no-op, 1 = error. |
| 11 | + */ |
| 12 | + |
| 13 | +import { execFile } from 'node:child_process'; |
| 14 | +import { readFile, rename, writeFile } from 'node:fs/promises'; |
| 15 | +import path from 'node:path'; |
| 16 | +import { fileURLToPath } from 'node:url'; |
| 17 | +import { promisify } from 'node:util'; |
| 18 | + |
| 19 | +const execFileAsync = promisify(execFile); |
| 20 | + |
| 21 | +const REPO_ROOT = path.resolve(fileURLToPath(import.meta.url), '..', '..'); |
| 22 | +const SIZE_LIMIT_FILE = path.join(REPO_ROOT, '.size-limit.js'); |
| 23 | + |
| 24 | +export const HEADROOM_BYTES = 5000; |
| 25 | +export const BYTES_PER_KB = 1000; |
| 26 | +export const BYTES_PER_KIB = 1024; |
| 27 | + |
| 28 | +/** |
| 29 | + * Compute the new size-limit in bytes for an entry: currentSize + 5KB, |
| 30 | + * rounded up to the next full KB. Always returns a number — the no-op |
| 31 | + * check is done downstream by comparing the displayed (KB/KiB-rounded) |
| 32 | + * value against the existing one. |
| 33 | + * |
| 34 | + * @param {number} currentBytes - measured size in bytes |
| 35 | + * @returns {number} new limit in bytes, rounded up to the next KB |
| 36 | + */ |
| 37 | +export function computeNewLimit(currentBytes) { |
| 38 | + const target = currentBytes + HEADROOM_BYTES; |
| 39 | + return Math.ceil(target / BYTES_PER_KB) * BYTES_PER_KB; |
| 40 | +} |
| 41 | + |
| 42 | +/** |
| 43 | + * Parse and strict-validate the JSON output from `yarn size-limit --json`. |
| 44 | + * |
| 45 | + * @param {string} raw - JSON string |
| 46 | + * @returns {Array<{ name: string, size: number, sizeLimit: number }>} |
| 47 | + * @throws {TypeError | SyntaxError} on malformed input |
| 48 | + */ |
| 49 | +export function parseSizeLimitOutput(raw) { |
| 50 | + const data = JSON.parse(raw); |
| 51 | + if (!Array.isArray(data)) { |
| 52 | + throw new TypeError(`size-limit output: expected array, got ${typeof data}`); |
| 53 | + } |
| 54 | + return data.map((entry, i) => { |
| 55 | + if (!entry || typeof entry !== 'object') { |
| 56 | + throw new TypeError(`size-limit entry [${i}]: expected object`); |
| 57 | + } |
| 58 | + if (typeof entry.name !== 'string' || entry.name.length === 0) { |
| 59 | + throw new TypeError(`size-limit entry [${i}]: 'name' must be a non-empty string`); |
| 60 | + } |
| 61 | + if (typeof entry.size !== 'number' || !Number.isFinite(entry.size)) { |
| 62 | + throw new TypeError(`size-limit entry [${i}] (${entry.name}): 'size' must be a finite number`); |
| 63 | + } |
| 64 | + if (typeof entry.sizeLimit !== 'number' || !Number.isFinite(entry.sizeLimit)) { |
| 65 | + throw new TypeError(`size-limit entry [${i}] (${entry.name}): 'sizeLimit' must be a finite number`); |
| 66 | + } |
| 67 | + return { name: entry.name, size: entry.size, sizeLimit: entry.sizeLimit }; |
| 68 | + }); |
| 69 | +} |
| 70 | + |
| 71 | +/** |
| 72 | + * Escape a string for safe inclusion in a markdown table cell. |
| 73 | + * Replaces newlines with spaces, escapes pipes and backticks. |
| 74 | + * |
| 75 | + * @param {unknown} value |
| 76 | + * @returns {string} |
| 77 | + */ |
| 78 | +export function sanitizeMarkdownCell(value) { |
| 79 | + return String(value) |
| 80 | + .replace(/\r\n|\r|\n/g, ' ') |
| 81 | + .replace(/[|`]/g, m => `\\${m}`); |
| 82 | +} |
| 83 | + |
| 84 | +/** |
| 85 | + * Escape a string for literal use inside a RegExp. |
| 86 | + */ |
| 87 | +function reEscape(s) { |
| 88 | + return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); |
| 89 | +} |
| 90 | + |
| 91 | +/** |
| 92 | + * Inspect the source for the current limit string of a given entry. |
| 93 | + * Returns null if no entry with that name is found. |
| 94 | + * |
| 95 | + * @param {string} src |
| 96 | + * @param {string} name |
| 97 | + * @returns {{ value: number, unit: 'KB' | 'KiB', raw: string } | null} |
| 98 | + */ |
| 99 | +export function extractCurrentLimit(src, name) { |
| 100 | + const namePattern = `name:\\s*'${reEscape(name)}'`; |
| 101 | + const limitPattern = `limit:\\s*'(\\d+(?:\\.\\d+)?)\\s*(KB|KiB)'`; |
| 102 | + const re = new RegExp(`${namePattern}[^]*?${limitPattern}`); |
| 103 | + const m = re.exec(src); |
| 104 | + if (!m) return null; |
| 105 | + return { value: Number(m[1]), unit: /** @type {'KB' | 'KiB'} */ (m[2]), raw: `${m[1]} ${m[2]}` }; |
| 106 | +} |
| 107 | + |
| 108 | +/** |
| 109 | + * Convert a numeric byte value into a whole-unit display value matching the |
| 110 | + * entry's existing unit. KB uses 1000, KiB uses 1024. |
| 111 | + * |
| 112 | + * @param {number} newBytes |
| 113 | + * @param {'KB' | 'KiB'} unit |
| 114 | + * @returns {number} |
| 115 | + */ |
| 116 | +function bytesToDisplay(newBytes, unit) { |
| 117 | + const divisor = unit === 'KiB' ? BYTES_PER_KIB : BYTES_PER_KB; |
| 118 | + return Math.ceil(newBytes / divisor); |
| 119 | +} |
| 120 | + |
| 121 | +/** |
| 122 | + * Rewrite `.size-limit.js` source to apply a list of limit updates. |
| 123 | + * Operates on plain text — never executes the source. For each change, |
| 124 | + * locates the entry by exact `name:` match and rewrites the next `limit:` |
| 125 | + * line in that window. |
| 126 | + * |
| 127 | + * @param {string} src - contents of .size-limit.js |
| 128 | + * @param {Array<{ name: string, newLimitKb: number, unit: 'KB' | 'KiB' }>} changes |
| 129 | + * @returns {string} updated source |
| 130 | + * @throws {Error} if any change's name doesn't match exactly one entry |
| 131 | + */ |
| 132 | +export function rewriteSizeLimitFile(src, changes) { |
| 133 | + let out = src; |
| 134 | + for (const { name, newLimitKb, unit } of changes) { |
| 135 | + const namePattern = `name:\\s*'${reEscape(name)}'`; |
| 136 | + const limitPattern = `limit:\\s*'(\\d+(?:\\.\\d+)?)\\s*(KB|KiB)'`; |
| 137 | + const re = new RegExp(`(${namePattern}[^]*?)${limitPattern}`); |
| 138 | + |
| 139 | + let matchCount = 0; |
| 140 | + const replaced = out.replace(re, (_full, prefix) => { |
| 141 | + matchCount++; |
| 142 | + return `${prefix}limit: '${newLimitKb} ${unit}'`; |
| 143 | + }); |
| 144 | + |
| 145 | + if (matchCount === 0) { |
| 146 | + throw new Error(`rewriteSizeLimitFile: no entry matched for name='${name}'`); |
| 147 | + } |
| 148 | + out = replaced; |
| 149 | + } |
| 150 | + return out; |
| 151 | +} |
| 152 | + |
| 153 | +/** |
| 154 | + * Render a markdown summary of size-limit changes for the PR body. |
| 155 | + * |
| 156 | + * @param {Array<{ name: string, oldLimit: string, newLimit: string, delta: number, unit: 'KB' | 'KiB' }>} changes |
| 157 | + * @returns {string} |
| 158 | + */ |
| 159 | +export function renderSummary(changes) { |
| 160 | + const header = '## Size limit auto-bump\n'; |
| 161 | + if (changes.length === 0) { |
| 162 | + return `${header}\nAll size limits already provide ≥5 KB headroom. No changes needed.\n`; |
| 163 | + } |
| 164 | + const lines = [header, '| Entry | Old limit | New limit | Δ |', '| --- | --- | --- | --- |']; |
| 165 | + for (const c of changes) { |
| 166 | + const sign = c.delta >= 0 ? '+' : ''; |
| 167 | + const delta = `${sign}${c.delta} ${c.unit}`; |
| 168 | + lines.push(`| ${sanitizeMarkdownCell(c.name)} | ${c.oldLimit} | ${c.newLimit} | ${delta} |`); |
| 169 | + } |
| 170 | + return `${lines.join('\n')}\n`; |
| 171 | +} |
| 172 | + |
| 173 | +// CLI entrypoint |
| 174 | +async function main() { |
| 175 | + // 1. Run size-limit. Capture JSON. execFile (no shell). |
| 176 | + let raw; |
| 177 | + try { |
| 178 | + // `--silent` suppresses yarn's `yarn run v…` header and `Done in …` footer, |
| 179 | + // which would otherwise break JSON.parse on the captured stdout. |
| 180 | + const { stdout } = await execFileAsync('yarn', ['--silent', 'size-limit', '--json'], { |
| 181 | + cwd: REPO_ROOT, |
| 182 | + maxBuffer: 16 * 1024 * 1024, |
| 183 | + }); |
| 184 | + raw = stdout; |
| 185 | + } catch (err) { |
| 186 | + // size-limit exits non-zero when entries fail their existing limit. We still want the JSON. |
| 187 | + if (err && typeof err === 'object' && 'stdout' in err && err.stdout) { |
| 188 | + raw = /** @type {string} */ (err.stdout); |
| 189 | + } else { |
| 190 | + throw err; |
| 191 | + } |
| 192 | + } |
| 193 | + |
| 194 | + const measurements = parseSizeLimitOutput(raw); |
| 195 | + |
| 196 | + // 2. Read .size-limit.js as text. NEVER require() it. |
| 197 | + const src = await readFile(SIZE_LIMIT_FILE, 'utf8'); |
| 198 | + |
| 199 | + // 3. Compute changes. |
| 200 | + const changes = []; |
| 201 | + const summaryRows = []; |
| 202 | + for (const m of measurements) { |
| 203 | + const newBytes = computeNewLimit(m.size); |
| 204 | + |
| 205 | + const cur = extractCurrentLimit(src, m.name); |
| 206 | + if (!cur) { |
| 207 | + throw new Error(`size-limit reported entry '${m.name}' but it was not found in .size-limit.js`); |
| 208 | + } |
| 209 | + |
| 210 | + const displayValue = bytesToDisplay(newBytes, cur.unit); |
| 211 | + const newLimitStr = `${displayValue} ${cur.unit}`; |
| 212 | + |
| 213 | + if (newLimitStr === cur.raw) { |
| 214 | + // After unit conversion the displayed value didn't move. Skip — avoids |
| 215 | + // no-op edits caused by KiB rounding. |
| 216 | + continue; |
| 217 | + } |
| 218 | + |
| 219 | + changes.push({ name: m.name, newLimitKb: displayValue, unit: cur.unit }); |
| 220 | + summaryRows.push({ |
| 221 | + name: m.name, |
| 222 | + oldLimit: cur.raw, |
| 223 | + newLimit: newLimitStr, |
| 224 | + delta: displayValue - cur.value, |
| 225 | + unit: cur.unit, |
| 226 | + }); |
| 227 | + } |
| 228 | + |
| 229 | + // 4. Print summary regardless (workflow captures stdout). |
| 230 | + process.stdout.write(renderSummary(summaryRows)); |
| 231 | + |
| 232 | + if (changes.length === 0) { |
| 233 | + process.exit(2); |
| 234 | + } |
| 235 | + |
| 236 | + // 5. Atomic write: temp file + rename. |
| 237 | + const updated = rewriteSizeLimitFile(src, changes); |
| 238 | + const tmpPath = `${SIZE_LIMIT_FILE}.tmp`; |
| 239 | + await writeFile(tmpPath, updated, 'utf8'); |
| 240 | + await rename(tmpPath, SIZE_LIMIT_FILE); |
| 241 | + |
| 242 | + process.exit(0); |
| 243 | +} |
| 244 | + |
| 245 | +const isMain = process.argv[1] && fileURLToPath(import.meta.url) === path.resolve(process.argv[1]); |
| 246 | +if (isMain) { |
| 247 | + main().catch(err => { |
| 248 | + // oxlint-disable-next-line no-console |
| 249 | + console.error(err.stack || err.message || err); |
| 250 | + process.exit(1); |
| 251 | + }); |
| 252 | +} |
0 commit comments