Use strict equality (===) to prevent type coercion bugs
typeof value === 'string' &&
1/**2 * Copyright (c) Meta Platforms, Inc. and affiliates.3 *4 * This source code is licensed under the MIT license found in the5 * LICENSE file in the root directory of this source tree.6 */78'use strict';910/* eslint-disable no-for-of-loops/no-for-of-loops */1112// Turns `sizebot-results.json` into the body of the sizebot pull request13// comment. Runs from a checkout of the default branch, never from the pull14// request branch, so the thresholds, the critical bundle list and the table15// itself cannot be influenced by the pull request being measured. Everything it16// reads out of the results file is therefore treated as untrusted input.17//18// Reads `sizebot-context.json` (written by the resolve step) and, when the build19// produced one, `sizebot-results.json`. Writes `sizebot-comment.md`, plus20// `sizebot-message.md` when the report is too large to fit in a comment and21// `sizebot-problem.txt` when the build configuration no longer matches this22// file's expectations.2324const {existsSync, readFileSync, writeFileSync} = require('fs');2526// Results shapes this file knows how to read. `compare-sizes.js` on the pull27// request branch may be older or newer than this list.28const SUPPORTED_VERSIONS = new Set([1]);29const SUPPORTED_STATUSES = new Set(['ok', 'base-artifacts-unavailable']);3031const CRITICAL_THRESHOLD = 0.02;32const SIGNIFICANCE_THRESHOLD = 0.002;33const CRITICAL_ARTIFACT_PATHS = new Set([34 // We always report changes to these bundles, even if the change is35 // insignificant or non-existent.36 'oss-stable/react-dom/cjs/react-dom.production.js',37 'oss-stable/react-dom/cjs/react-dom-client.production.js',38 'oss-experimental/react-dom/cjs/react-dom.production.js',39 'oss-experimental/react-dom/cjs/react-dom-client.production.js',40 'facebook-www/ReactDOM-prod.classic.js',41 'facebook-www/ReactDOM-prod.modern.js',42]);4344// GitHub comments are limited to 65536 characters.45const MAX_COMMENT_LENGTH = 65536;4647// Both the notice and the report are delimited so each can be rewritten without48// disturbing the other, and so a report can be read back out of a comment49// verbatim when a newer build supersedes it. Relying on "everything after the50// notice" instead would swallow the footer and append a second one every time.51const MARKER_PREFIX = '<!-- sizebot-comment';52const NOTICE_START = '<!-- sizebot-notice-start -->';53const NOTICE_END = '<!-- sizebot-notice-end -->';54const REPORT_START = '<!-- sizebot-report-start -->';55const REPORT_END = '<!-- sizebot-report-end -->';5657const CONTEXT_PATH = 'sizebot-context.json';58const RESULTS_PATH = 'sizebot-results.json';59const COMMENT_PATH = 'sizebot-comment.md';60const MESSAGE_PATH = 'sizebot-message.md';61const PROBLEM_PATH = 'sizebot-problem.txt';6263// Build artifact paths end up inside markdown link text and inside a URL, so64// anything that could break out of either is rejected rather than escaped.65const SAFE_ARTIFACT_PATH = /^[A-Za-z0-9_@./+-]+$/;6667function isSafeArtifactPath(value) {68 return (69 typeof value === 'string' &&70 value.length > 0 &&71 value.length < 512 &&72 SAFE_ARTIFACT_PATH.test(value) &&73 !value.includes('..') &&74 !value.startsWith('/')75 );76}7778function isSize(value) {79 return value === null || (Number.isFinite(value) && value >= 0);80}8182function isSha(value) {83 return typeof value === 'string' && /^[0-9a-f]{7,40}$/.test(value);84}8586const kilobyteFormatter = new Intl.NumberFormat('en', {87 style: 'unit',88 unit: 'kilobyte',89 minimumFractionDigits: 2,90 maximumFractionDigits: 2,91});9293function kbs(bytes) {94 // An artifact that exists on only one side has no size on the other. The95 // report has always shown that as 0.00 kB rather than an empty cell.96 return kilobyteFormatter.format((bytes === null ? 0 : bytes) / 1000);97}9899const percentFormatter = new Intl.NumberFormat('en', {100 style: 'percent',101 signDisplay: 'exceptZero',102 minimumFractionDigits: 2,103 maximumFractionDigits: 2,104});105106function ratio(baseSize, headSize) {107 if (baseSize === null) {108 return Infinity;109 }110 if (headSize === null) {111 return -1;112 }113 return (headSize - baseSize) / baseSize;114}115116function change(decimal) {117 if (decimal === Infinity) {118 return 'New file';119 }120 if (decimal === -1) {121 return 'Deleted';122 }123 // Compare the magnitude, not the signed value. Testing `decimal < 0.0001`124 // reported every size decrease as unchanged, which is why `signDisplay:125 // 'exceptZero'` above never had a negative number to render.126 if (Math.abs(decimal) < 0.0001) {127 return '=';128 }129 return percentFormatter.format(decimal);130}131132const header = `| Name | +/- | Base | Current | +/- gzip | Base gzip | Current gzip |133| ---- | --- | ---- | ------- | -------- | --------- | ------------ |`;134135function row(result, baseSha, headSha) {136 const diffViewUrl = `https://react-builds.vercel.app/commits/${headSha}/files/${result.path}?compare=${baseSha}`;137 const rowArr = [138 `| [${result.path}](${diffViewUrl})`,139 `**${change(result.change)}**`,140 `${kbs(result.baseSize)}`,141 `${kbs(result.headSize)}`,142 `${change(result.changeGzip)}`,143 `${kbs(result.baseSizeGzip)}`,144 `${kbs(result.headSizeGzip)}`,145 ];146 return rowArr.join(' | ');147}148149function validateResults(raw) {150 if (raw === null || typeof raw !== 'object') {151 return {ok: false, reason: 'malformed'};152 }153 // Checked before anything else so a shape this file cannot read produces a154 // clear message instead of a misrendered table.155 if (!SUPPORTED_VERSIONS.has(raw.version)) {156 return {ok: false, reason: 'unsupported-version'};157 }158 if (!SUPPORTED_STATUSES.has(raw.status)) {159 return {ok: false, reason: 'malformed'};160 }161 if (raw.status === 'base-artifacts-unavailable') {162 return {ok: true, results: {status: raw.status}};163 }164 if (!isSha(raw.baseSha) || !isSha(raw.headSha)) {165 return {ok: false, reason: 'malformed'};166 }167 if (!Array.isArray(raw.artifacts)) {168 return {ok: false, reason: 'malformed'};169 }170 for (const artifact of raw.artifacts) {171 if (artifact === null || typeof artifact !== 'object') {172 return {ok: false, reason: 'malformed'};173 }174 if (!isSafeArtifactPath(artifact.path)) {175 return {ok: false, reason: 'malformed'};176 }177 if (178 !isSize(artifact.baseSize) ||179 !isSize(artifact.baseSizeGzip) ||180 !isSize(artifact.headSize) ||181 !isSize(artifact.headSizeGzip)182 ) {183 return {ok: false, reason: 'malformed'};184 }185 if (artifact.baseSize === null && artifact.headSize === null) {186 return {ok: false, reason: 'malformed'};187 }188 }189 return {ok: true, results: raw};190}191192function renderTable(results) {193 const {baseSha, headSha} = results;194195 const resultsMap = new Map();196 for (const artifact of results.artifacts) {197 resultsMap.set(artifact.path, {198 ...artifact,199 change: ratio(artifact.baseSize, artifact.headSize),200 changeGzip: ratio(artifact.baseSizeGzip, artifact.headSizeGzip),201 });202 }203204 const sorted = Array.from(resultsMap.values());205 sorted.sort((a, b) => b.change - a.change);206207 const criticalResults = [];208 const missingCriticalPaths = [];209 for (const artifactPath of CRITICAL_ARTIFACT_PATHS) {210 const result = resultsMap.get(artifactPath);211 if (result === undefined) {212 missingCriticalPaths.push(artifactPath);213 continue;214 }215 criticalResults.push(row(result, baseSha, headSha));216 }217218 const significantResults = [];219 for (const result of sorted) {220 // If result exceeds critical threshold, add to top section.221 if (222 (Math.abs(result.change) > CRITICAL_THRESHOLD ||223 // New file224 result.change === Infinity ||225 // Deleted file226 result.change === -1) &&227 // Skip critical artifacts. We added those earlier, in a fixed order.228 !CRITICAL_ARTIFACT_PATHS.has(result.path)229 ) {230 criticalResults.push(row(result, baseSha, headSha));231 }232233 // Do the same for results that exceed the significant threshold. These234 // will go into the bottom, collapsed section. Intentionally including235 // critical artifacts in this section, too.236 if (237 Math.abs(result.change) > SIGNIFICANCE_THRESHOLD ||238 result.change === Infinity ||239 result.change === -1240 ) {241 significantResults.push(row(result, baseSha, headSha));242 }243 }244245 const markdown = `Comparing: ${baseSha}...${headSha}246247## Critical size changes248249Includes critical production bundles, as well as any change greater than ${250 CRITICAL_THRESHOLD * 100251 }%:252253${header}254${criticalResults.join('\n')}255256## Significant size changes257258Includes any change greater than ${SIGNIFICANCE_THRESHOLD * 100}%:259260${261 significantResults.length > 0262 ? `<details>263<summary>Expand to show</summary>264265${header}266${significantResults.join('\n')}267</details>`268 : '(No significant changes)'269}`;270271 return {markdown, missingCriticalPaths};272}273274function renderCompletedReport(context) {275 const {runConclusion, runUrl, devtoolsOnly} = context;276277 // The common outcome for a first-time contributor's pull request: the run is278 // created but held until a maintainer approves it.279 if (runConclusion === 'action_required') {280 return {281 markdown: `[The build for this commit](${runUrl}) needs maintainer approval before it can run, so there is no size report yet.`,282 missingCriticalPaths: [],283 };284 }285286 if (runConclusion !== 'success') {287 return {288 markdown: `The build for this commit did not complete, so there is no size report. See [the workflow run](${runUrl}) for details.`,289 missingCriticalPaths: [],290 };291 }292293 if (devtoolsOnly) {294 return {295 markdown:296 'No size report: this pull request only touches `packages/react-devtools`, which does not affect production bundle size.',297 missingCriticalPaths: [],298 };299 }300301 if (!existsSync(RESULTS_PATH)) {302 return {303 markdown: `The build succeeded but produced no size results, so there is no size report. See [the workflow run](${runUrl}) for details.`,304 missingCriticalPaths: [],305 };306 }307308 let raw;309 try {310 raw = JSON.parse(readFileSync(RESULTS_PATH, 'utf8'));311 } catch {312 raw = null;313 }314315 const validated = validateResults(raw);316 if (!validated.ok) {317 if (validated.reason === 'unsupported-version') {318 return {319 markdown:320 'This pull request produced a size report in a format this repository no longer reads. ' +321 'Merge the latest changes from the `main` branch to pick up the current one.',322 missingCriticalPaths: [],323 };324 }325 return {326 markdown: `The size results for this commit could not be read, so there is no size report. See [the workflow run](${runUrl}) for details.`,327 missingCriticalPaths: [],328 };329 }330331 if (validated.results.status === 'base-artifacts-unavailable') {332 return {333 markdown:334 "Failed to read build artifacts. It's possible a build configuration has changed upstream. " +335 'Try pulling the latest changes from the `main` branch.',336 missingCriticalPaths: [],337 };338 }339340 return renderTable(validated.results);341}342343function renderNotice(context, reportHead) {344 const {action, prHeadSha, runHeadSha, runStatus, runUrl} = context;345 const lines = [];346347 // One rule covers both the case where an older run's results arrive after the348 // head moved, and the case where a new build supersedes a report already on349 // display: the report simply is not about the pull request's current head.350 if (reportHead !== null && reportHead !== prHeadSha) {351 lines.push(352 `These sizes are for ${reportHead}, which is no longer the head of this pull request.`353 );354 if (action === 'requested') {355 lines.push(`A build for ${runHeadSha} is in progress.`);356 }357 } else if (action === 'requested' && runStatus === 'waiting') {358 lines.push(359 `[The build for this commit](${runUrl}) is waiting for maintainer approval before it can run.`360 );361 }362363 if (lines.length === 0) {364 return '';365 }366 return lines.map(line => `> ${line}`).join('\n> \n');367}368369function renderBody(context) {370 let reportHead;371 let report;372 let missingCriticalPaths = [];373374 if (context.action === 'requested') {375 // Only a comment that names the commit it describes holds real numbers. A376 // previous placeholder has body text too, but carrying that forward would377 // pin the comment to a stale run link instead of refreshing it.378 if (379 context.existingReportHead !== null &&380 context.existingReport !== null381 ) {382 // Keep the numbers from the previous build visible. The notice below383 // explains that they describe an older commit.384 reportHead = context.existingReportHead;385 report = context.existingReport;386 } else {387 reportHead = null;388 report = `A size report will appear here when [the build](${context.runUrl}) finishes.`;389 }390 } else {391 reportHead = context.runHeadSha;392 const rendered = renderCompletedReport(context);393 report = rendered.markdown;394 missingCriticalPaths = rendered.missingCriticalPaths;395 }396397 if (missingCriticalPaths.length > 0) {398 report =399 '> [!CAUTION]\n' +400 '> These critical bundles are missing from the build. If that was an intentional\n' +401 '> change to the build configuration, update `CRITICAL_ARTIFACT_PATHS` in\n' +402 '> `scripts/sizebot/render-comment.js`:\n' +403 missingCriticalPaths.map(p => `> - \`${p}\``).join('\n') +404 '\n\n' +405 report;406 }407408 const notice = renderNotice(context, reportHead);409 const footerSha = reportHead === null ? context.runHeadSha : reportHead;410411 function assemble(reportRegion) {412 return `${MARKER_PREFIX} report-head=${413 reportHead === null ? 'none' : reportHead414 } -->415${NOTICE_START}416${notice === '' ? '' : `> [!WARNING]\n${notice}\n`}${NOTICE_END}417${REPORT_START}418${reportRegion}419${REPORT_END}420421<sub>Generated by sizebot against ${footerSha}</sub>422`;423 }424425 return {426 body: assemble(report),427 report,428 assemble,429 reportHead,430 missingCriticalPaths,431 };432}433434// Reads the report region back out of a comment, so a completed report can be435// carried forward when a new build is requested for a newer commit.436function extractReport(body) {437 const start = body.indexOf(REPORT_START);438 const end = body.indexOf(REPORT_END);439 if (start === -1 || end === -1 || end < start) {440 return null;441 }442 const report = body.slice(start + REPORT_START.length, end).trim();443 return report === '' ? null : report;444}445446function parseReportHead(body) {447 const match =448 /<!-- sizebot-comment report-head=([0-9a-f]{7,40}|none) -->/.exec(body);449 if (match === null || match[1] === 'none') {450 return null;451 }452 return match[1];453}454455function main() {456 const context = JSON.parse(readFileSync(CONTEXT_PATH, 'utf8'));457 const {body, report, assemble, missingCriticalPaths} = renderBody(context);458459 let comment = body;460 if (body.length > MAX_COMMENT_LENGTH) {461 // The link resolves because the artifact is uploaded to this same run,462 // before the comment is posted.463 writeFileSync(MESSAGE_PATH, report + '\n');464 comment = assemble(465 `The size diff is too large to display in a single comment. [This workflow run](${context.commentRunUrl}) contains an artifact called \`sizebot-message.md\` with the full report.`466 );467 }468 writeFileSync(COMMENT_PATH, comment);469470 if (missingCriticalPaths.length > 0) {471 writeFileSync(472 PROBLEM_PATH,473 `Missing expected bundles:\n${missingCriticalPaths.join('\n')}\n`474 );475 }476477 process.stdout.write(comment);478}479480module.exports = {481 MARKER_PREFIX,482 extractReport,483 parseReportHead,484 renderBody,485};486487if (require.main === module) {488 main();489}
Same data, no extra tab — call code_get_file + code_get_findings over MCP from Claude/Cursor/Copilot.