Ctrl K
ComunidadGuías…

scripts/react-compiler-compliance-check.ts

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
#!/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 <files...>` -- 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<boolean> {
    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 <files|dirs|globs...>');
                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};