scripts/sizebot/render-comment.js JAVASCRIPT 490 lines View on github.com → Search inside
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}

Code quality findings 39

Use strict equality (===) to prevent type coercion bugs
info correctness loose-equality
typeof value === 'string' &&
Be cautious with typeof; it has limitations (e.g., typeof null === 'object')
info correctness typeof-pitfall
typeof value === 'string' &&
Use strict equality (===) to prevent type coercion bugs
info correctness loose-equality
return value === null || (Number.isFinite(value) && value >= 0);
Use strict equality (===) to prevent type coercion bugs
info correctness loose-equality
return typeof value === 'string' && /^[0-9a-f]{7,40}$/.test(value);
Be cautious with typeof; it has limitations (e.g., typeof null === 'object')
info correctness typeof-pitfall
return typeof value === 'string' && /^[0-9a-f]{7,40}$/.test(value);
Use strict equality (===) to prevent type coercion bugs
info correctness loose-equality
return kilobyteFormatter.format((bytes === null ? 0 : bytes) / 1000);
Use strict equality (===) to prevent type coercion bugs
info correctness loose-equality
if (baseSize === null) {
Use strict equality (===) to prevent type coercion bugs
info correctness loose-equality
if (headSize === null) {
Use strict equality (===) to prevent type coercion bugs
info correctness loose-equality
if (decimal === Infinity) {
Use strict equality (===) to prevent type coercion bugs
info correctness loose-equality
if (decimal === -1) {
Use strict equality (===) to prevent type coercion bugs
info correctness loose-equality
if (raw === null || typeof raw !== 'object') {
Be cautious with typeof; it has limitations (e.g., typeof null === 'object')
info correctness typeof-pitfall
if (raw === null || typeof raw !== 'object') {
Use strict equality (===) to prevent type coercion bugs
info correctness loose-equality
if (raw.status === 'base-artifacts-unavailable') {
Use strict equality (===) to prevent type coercion bugs
info correctness loose-equality
if (artifact === null || typeof artifact !== 'object') {
Be cautious with typeof; it has limitations (e.g., typeof null === 'object')
info correctness typeof-pitfall
if (artifact === null || typeof artifact !== 'object') {
Use strict equality (===) to prevent type coercion bugs
info correctness loose-equality
if (artifact.baseSize === null && artifact.headSize === null) {
Use strict equality (===) to prevent type coercion bugs
info correctness loose-equality
if (result === undefined) {
Use strict equality (===) to prevent type coercion bugs
info correctness loose-equality
result.change === Infinity ||
Use strict equality (===) to prevent type coercion bugs
info correctness loose-equality
result.change === -1) &&
Use strict equality (===) to prevent type coercion bugs
info correctness loose-equality
result.change === Infinity ||
Use strict equality (===) to prevent type coercion bugs
info correctness loose-equality
result.change === -1
Use strict equality (===) to prevent type coercion bugs
info correctness loose-equality
if (runConclusion === 'action_required') {
Use strict equality (===) to prevent type coercion bugs
info correctness loose-equality
if (runConclusion !== 'success') {
Use strict equality (===) to prevent type coercion bugs
info correctness loose-equality
if (validated.reason === 'unsupported-version') {
Use strict equality (===) to prevent type coercion bugs
info correctness loose-equality
if (validated.results.status === 'base-artifacts-unavailable') {
Use strict equality (===) to prevent type coercion bugs
info correctness loose-equality
if (reportHead !== null && reportHead !== prHeadSha) {
Use strict equality (===) to prevent type coercion bugs
info correctness loose-equality
if (action === 'requested') {
Use strict equality (===) to prevent type coercion bugs
info correctness loose-equality
} else if (action === 'requested' && runStatus === 'waiting') {
Use strict equality (===) to prevent type coercion bugs
info correctness loose-equality
if (lines.length === 0) {
Use strict equality (===) to prevent type coercion bugs
info correctness loose-equality
if (context.action === 'requested') {
Use strict equality (===) to prevent type coercion bugs
info correctness loose-equality
context.existingReportHead !== null &&
Use strict equality (===) to prevent type coercion bugs
info correctness loose-equality
context.existingReport !== null
Use strict equality (===) to prevent type coercion bugs
info correctness loose-equality
const footerSha = reportHead === null ? context.runHeadSha : reportHead;
Use strict equality (===) to prevent type coercion bugs
info correctness loose-equality
reportHead === null ? 'none' : reportHead
Use strict equality (===) to prevent type coercion bugs
info correctness loose-equality
${notice === '' ? '' : `> [!WARNING]\n${notice}\n`}${NOTICE_END}
Use strict equality (===) to prevent type coercion bugs
info correctness loose-equality
if (start === -1 || end === -1 || end < start) {
Use strict equality (===) to prevent type coercion bugs
info correctness loose-equality
return report === '' ? null : report;
Use strict equality (===) to prevent type coercion bugs
info correctness loose-equality
if (match === null || match[1] === 'none') {
Use strict equality (===) to prevent type coercion bugs
info correctness loose-equality
if (require.main === module) {

Get this view in your editor

Same data, no extra tab — call code_get_file + code_get_findings over MCP from Claude/Cursor/Copilot.