#!/usr/bin/env bun /** * React Compiler Compliance Check * * Checks how React components and hooks fare under the OXC React Compiler (oxc-transform-react), * which every build lane uses. Two modes: * - `check ` -- check specific files, report per-file status * - `check-changed` -- check files changed in a PR, enforce: * 1. New files with components/hooks must compile * 2. Modified files must not regress (compiled on main -> must compile on PR) * 3. Modified files must not lose caching (a call cached on main -> must stay cached on PR) */ import CLI from 'expensify-common/CLI'; import fs from 'fs'; import path from 'path'; // eslint-disable-next-line import/extensions import type {CompilationResult, CompilerError} from '../config/reactCompiler/checkWithOxc.mjs'; import type {LostCacheGuard} from './utils/findLostCacheGuards'; // The checker is ESM (.mjs); Bun resolves it with the extension. // eslint-disable-next-line import/extensions import checkReactCompilerCompliance, {shouldSkipReactCompiler} from '../config/reactCompiler/checkWithOxc.mjs'; import FileUtils from './utils/FileUtils'; import findLostCacheGuards from './utils/findLostCacheGuards'; import Git from './utils/Git'; import {error as logError, errorDetail as logErrorDetail, info as logInfo, success as logSuccess, warn as logWarn} from './utils/Logger'; const FILE_EXTENSIONS = ['.ts', '.tsx']; const IS_CI = process.env.CI === 'true'; function formatErrorLocation(filename: string, error: CompilerError): string { if (error.loc) { return `${error.loc.start.line}:${error.loc.start.column}`; } return filename; } function printErrors(filename: string, errors: CompilerError[]): void { if (IS_CI) { console.log(`::group::${filename} (${errors.length} error${errors.length === 1 ? '' : 's'})`); } for (const error of errors) { const location = formatErrorLocation(filename, error); logErrorDetail(`${location}: ${error.reason}`); } if (IS_CI) { console.log('::endgroup::'); } } function printLostCacheGuards(filename: string, lostGuards: LostCacheGuard[]): void { if (IS_CI) { console.log(`::group::${filename} (${lostGuards.length} lost cache guard${lostGuards.length === 1 ? '' : 's'})`); } for (const {functionName, call} of lostGuards) { logErrorDetail(call === undefined ? `${functionName}: no longer compiled` : `${functionName}: ${call} now runs on every render`); } if (IS_CI) { console.log('::endgroup::'); } } /** * Check specific files and report per-file status. */ function checkFiles(inputs: string[], verbose: boolean): boolean { const files = FileUtils.resolveFilePaths(inputs, FILE_EXTENSIONS); if (files.length === 0) { logWarn(`No ${FILE_EXTENSIONS.join('/')} files found matching the provided paths.`); return true; } let hasFailure = false; for (const file of files) { const source = fs.readFileSync(file, 'utf8'); const result = checkReactCompilerCompliance(source, file); if (result.status === 'failed') { logError(`FAILED ${file}`); printErrors(file, result.errors); hasFailure = true; } else if (verbose) { if (result.status === 'compiled') { logSuccess(`COMPILED ${file}`); } else { logInfo(`SKIPPED ${file} (no components or hooks)`); } } } return !hasFailure; } /** * Resolve the source of a file on the base branch, returning undefined if it did not exist. */ function getMainSource(ref: string, mainPath: string): string | undefined { try { return Git.show(ref, mainPath); } catch { return undefined; } } /** * Check files changed in a PR for React Compiler compliance. * Rule 1: New files with components/hooks must compile. * Rule 2: Modified files must not regress (compiled on main -> must compile on PR). * Rule 3: Modified files must not lose caching (a call cached on main -> must stay cached on PR). */ async function checkChangedFiles(remote: string, verbose: boolean): Promise { const mainBaseCommitHash = await Git.getMainBranchCommitHash(remote); const changedFiles = await Git.getChangedFilesWithStatus(mainBaseCommitHash, undefined, true, FILE_EXTENSIONS); const reactFiles = changedFiles.filter((f) => FILE_EXTENSIONS.some((ext) => f.filename.endsWith(ext)) && f.status !== 'removed'); if (reactFiles.length === 0) { logSuccess('No React files changed, skipping check.'); return true; } logInfo(`Checking ${reactFiles.length} changed React files...`); const failures: Array<{file: string; reason: string}> = []; for (const {filename, status, previousFilename} of reactFiles) { const absolutePath = path.resolve(filename); if (!fs.existsSync(absolutePath)) { continue; } const source = fs.readFileSync(absolutePath, 'utf8'); const branchResult = checkReactCompilerCompliance(source, absolutePath); if (branchResult.status !== 'failed') { const mainSource = status === 'added' || shouldSkipReactCompiler(filename) ? undefined : getMainSource(mainBaseCommitHash, previousFilename ?? filename); const lostGuards = mainSource === undefined ? [] : findLostCacheGuards(mainSource, source, absolutePath, previousFilename ?? absolutePath); if (lostGuards.length > 0) { failures.push({file: filename, reason: 'Calls that the React Compiler cached on main run on every render on this branch'}); logError(`FAILED ${filename} (lost caching: cached on main)`); printLostCacheGuards(filename, lostGuards); } else if (verbose) { logSuccess(`OK ${filename} (${branchResult.status})`); } continue; } if (status === 'added') { failures.push({file: filename, reason: 'New file fails to compile with the React Compiler'}); logError(`FAILED ${filename} (new file must compile)`); printErrors(filename, branchResult.errors); continue; } // Modified/renamed: only a regression from the base branch counts. Use the resolved base commit hash // (which honors GITHUB_BASE_REF) rather than a hardcoded `main`, so PRs targeting any base branch work. const mainPath = previousFilename ?? filename; const mainSource = getMainSource(mainBaseCommitHash, mainPath); const mainResult: CompilationResult | undefined = mainSource !== undefined ? checkReactCompilerCompliance(mainSource, mainPath) : undefined; if (mainResult?.status === 'compiled') { failures.push({file: filename, reason: 'File compiled on main but fails to compile on this branch (regression)'}); logError(`FAILED ${filename} (regression: compiled on main)`); printErrors(filename, branchResult.errors); } else if (verbose) { logWarn(`WARNING ${filename} (fails to compile, but also failed on main)`); } } console.log(); if (failures.length > 0) { logError(`React Compiler compliance check failed with ${failures.length} error(s).`); console.log(); logInfo('See contributingGuides/REACT_COMPILER.md for help fixing these errors.'); return false; } logSuccess('React Compiler compliance check passed!'); return true; } const CLI_COMMANDS = ['check', 'check-changed'] as const; async function main() { const cli = new CLI({ positionalArgs: [ { name: 'command', description: 'Command to run (check or check-changed)', default: 'check', parse: (val) => { if (!(CLI_COMMANDS as readonly string[]).includes(val)) { throw new Error(`Invalid command. Must be one of: ${CLI_COMMANDS.join(', ')}`); } return val; }, }, { name: 'files', description: 'File paths, directories, or glob patterns to check (only for "check" command)', variadic: true, default: [], }, ], namedArgs: { remote: { description: 'Git remote name (default: origin in CI, none locally)', required: false, }, }, flags: { verbose: { description: 'Show detailed output including skipped files', }, }, }); const {command} = cli.positionalArgs; const files = cli.positionalArgs.files as string[]; const {remote} = cli.namedArgs; const {verbose} = cli.flags; let passed = false; switch (command) { case 'check': if (files.length === 0) { logError('No paths specified. Usage: npm run react-compiler-compliance-check check '); process.exit(1); } passed = checkFiles(files, verbose); break; case 'check-changed': passed = await checkChangedFiles(remote ?? 'origin', verbose); break; default: logError(`Unknown command: ${String(command)}`); process.exit(1); } process.exit(passed ? 0 : 1); } if (require.main === module) { main().catch((error: unknown) => { logError('Unexpected error:', error); process.exit(1); }); } export {checkReactCompilerCompliance}; export type {CompilationResult, CompilerError};