compiler/scripts/test-internal-files.ts TYPESCRIPT 725 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/**9 * Compare TS and Rust React Compiler output on external production files.10 *11 * Given a configuration module and a source root directory, compiles files12 * using the exact plugin options from the configuration, then compares code13 * output and error events between the TS and Rust compilers. Files with14 * differences can be copied as test fixtures for further investigation.15 *16 * Usage:17 *   npx tsx compiler/scripts/test-internal-files.ts <config-path> <source-root> [flags]18 *19 * Arguments:20 *   <config-path>    Path to the compiler configuration module (JS file that21 *                    exports getForgetConfiguration and config)22 *   <source-root>    Root directory from which the config's source prefixes23 *                    are resolved24 *25 * Flags:26 *   --limit N        Max files to process (default: 0 = all)27 *   --pattern PAT    Filter file paths (substring match)28 *   --project NAME   Filter by project name from config29 *   --dry-run        Compare only, skip fixture creation30 *   --no-color       Disable ANSI color codes31 */3233import * as babel from '@babel/core';34import hermesParserPlugin from 'babel-plugin-syntax-hermes-parser';35import {execSync} from 'child_process';36import fs from 'fs';37import path from 'path';38import prettier from 'prettier';3940const REPO_ROOT = path.resolve(__dirname, '../..');41const FIXTURE_DIR = path.join(42  REPO_ROOT,43  'compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/internal',44);4546// --- Parse flags and positional args ---47const rawArgs = process.argv.slice(2);48const noColor = rawArgs.includes('--no-color') || !!process.env.NO_COLOR;49const dryRun = rawArgs.includes('--dry-run');50const limitIdx = rawArgs.indexOf('--limit');51const limitArg = limitIdx >= 0 ? parseInt(rawArgs[limitIdx + 1], 10) : 0;52const patternIdx = rawArgs.indexOf('--pattern');53const patternArg = patternIdx >= 0 ? rawArgs[patternIdx + 1] : null;54const projectIdx = rawArgs.indexOf('--project');55const projectArg = projectIdx >= 0 ? rawArgs[projectIdx + 1] : null;5657// Collect flag indices to exclude from positional args58const flagValueIndices = new Set<number>();59if (limitIdx >= 0) flagValueIndices.add(limitIdx + 1);60if (patternIdx >= 0) flagValueIndices.add(patternIdx + 1);61if (projectIdx >= 0) flagValueIndices.add(projectIdx + 1);62const positional = rawArgs.filter(63  (a, i) => !a.startsWith('--') && !flagValueIndices.has(i),64);6566if (positional.length < 2) {67  console.error(68    'Usage: npx tsx compiler/scripts/test-internal-files.ts <config-path> <source-root> [flags]',69  );70  console.error('');71  console.error('Arguments:');72  console.error('  <config-path>    Path to the compiler configuration module');73  console.error(74    '  <source-root>    Root directory for resolving config source prefixes',75  );76  console.error('');77  console.error('Flags:');78  console.error('  --limit N        Max files to process');79  console.error('  --pattern PAT    Filter file paths (substring match)');80  console.error('  --project NAME   Filter by project name from config');81  console.error('  --dry-run        Compare only, skip fixture creation');82  console.error('  --no-color       Disable ANSI color codes');83  process.exit(1);84}8586const configPath = path.resolve(positional[0]);87const sourceRoot = path.resolve(positional[1]);8889// --- ANSI colors ---90const RED = noColor ? '' : '\x1b[0;31m';91const GREEN = noColor ? '' : '\x1b[0;32m';92const YELLOW = noColor ? '' : '\x1b[0;33m';93const BOLD = noColor ? '' : '\x1b[1m';94const DIM = noColor ? '' : '\x1b[2m';95const RESET = noColor ? '' : '\x1b[0m';9697// --- Load config ---98if (!fs.existsSync(configPath)) {99  console.error(`${RED}ERROR: Could not find config at ${configPath}${RESET}`);100  process.exit(1);101}102if (!fs.existsSync(sourceRoot) || !fs.statSync(sourceRoot).isDirectory()) {103  console.error(104    `${RED}ERROR: Source root is not a valid directory: ${sourceRoot}${RESET}`,105  );106  process.exit(1);107}108const forgetConfig = require(configPath);109const {getForgetConfiguration, config: rawConfig} = forgetConfig;110111// --- Build native module ---112const NATIVE_DIR = path.join(113  REPO_ROOT,114  'compiler/packages/babel-plugin-react-compiler-rust/native',115);116const NATIVE_NODE_PATH = path.join(NATIVE_DIR, 'index.node');117118console.log('Building Rust native module...');119try {120  execSync('~/.cargo/bin/cargo build -p react_compiler_napi', {121    cwd: path.join(REPO_ROOT, 'compiler/crates'),122    stdio: 'inherit',123    shell: true,124  });125} catch {126  console.error(`${RED}ERROR: Failed to build Rust native module.${RESET}`);127  process.exit(1);128}129130const TARGET_DIR = path.join(REPO_ROOT, 'compiler/target/debug');131const dylib = fs.existsSync(132  path.join(TARGET_DIR, 'libreact_compiler_napi.dylib'),133)134  ? path.join(TARGET_DIR, 'libreact_compiler_napi.dylib')135  : path.join(TARGET_DIR, 'libreact_compiler_napi.so');136137if (!fs.existsSync(dylib)) {138  console.error(139    `${RED}ERROR: Could not find built native module in ${TARGET_DIR}${RESET}`,140  );141  process.exit(1);142}143fs.copyFileSync(dylib, NATIVE_NODE_PATH);144145// --- Load plugins ---146const tsPlugin = require('../packages/babel-plugin-react-compiler/src').default;147const rustPlugin =148  require('../packages/babel-plugin-react-compiler-rust/src').default;149150// --- Types ---151interface LoggerEvent {152  kind: string;153  fnName?: string | null;154  fnLoc?: unknown;155  reason?: string;156  detail?: {157    reason?: string;158    severity?: string;159    category?: string;160    description?: string;161  };162  data?: string;163}164165interface CompileResult {166  code: string | null;167  events: LoggerEvent[];168  error: string | null;169}170171interface FileEntry {172  filePath: string;173  projectName: string;174  projectConfig: Record<string, unknown>;175}176177// --- File discovery ---178// Additional directory exclusions beyond what the config handles179const EXTRA_EXCLUDES = [180  '__server_snapshot_tests__',181  '__e2e_tests__',182  '__perf_tests__',183  '__integration_tests__',184];185186function walkDir(dir: string, callback: (filePath: string) => void): void {187  let entries: fs.Dirent[];188  try {189    entries = fs.readdirSync(dir, {withFileTypes: true});190  } catch {191    return;192  }193  for (const entry of entries) {194    if (EXTRA_EXCLUDES.includes(entry.name)) continue;195    const fullPath = path.join(dir, entry.name);196    if (entry.isDirectory()) {197      walkDir(fullPath, callback);198    } else if (/\.(js|jsx)$/.test(entry.name)) {199      callback(fullPath);200    }201  }202}203204function discoverFiles(): FileEntry[] {205  console.log('Discovering files...');206  const results: FileEntry[] = [];207  const seen = new Set<string>();208209  for (const [prefix, projectName] of Object.entries(210    rawConfig.sources as Record<string, string>,211  )) {212    if (projectArg && projectName !== projectArg) continue;213214    // Resolve prefix to absolute path relative to source root215    const absPrefix = path.join(sourceRoot, prefix);216    if (!fs.existsSync(absPrefix)) continue;217218    const stat = fs.statSync(absPrefix);219    if (!stat.isDirectory()) continue;220221    walkDir(absPrefix, filePath => {222      if (seen.has(filePath)) return;223      seen.add(filePath);224225      if (patternArg && !filePath.includes(patternArg)) return;226227      const config = getForgetConfiguration(filePath);228      if (config == null || !config.enable) return;229230      results.push({filePath, projectName, projectConfig: config});231    });232233    if (limitArg > 0 && results.length >= limitArg) break;234  }235236  if (limitArg > 0) {237    return results.slice(0, limitArg);238  }239  return results;240}241242// --- Build plugin options from production config ---243function makePluginOptions(244  projectConfig: Record<string, unknown>,245  logger: {logEvent: (filename: string | null, event: LoggerEvent) => void},246): Record<string, unknown> {247  return {248    compilationMode: projectConfig.compilationMode,249    panicThreshold: 'none',250    environment: projectConfig.environment,251    target:252      projectConfig.compilerVersion === 'experimental'253        ? projectConfig.target254        : projectConfig.target,255    gating: null,256    flowSuppressions: projectConfig.flowSuppressions,257    enableReanimatedCheck: false,258    logger,259    sources: null,260  };261}262263// --- Compile a file ---264function compileFile(265  mode: 'ts' | 'rust',266  filePath: string,267  pluginOptions: Record<string, unknown>,268): CompileResult {269  const source = fs.readFileSync(filePath, 'utf8');270  const events: LoggerEvent[] = [];271272  const logger = {273    logEvent(_filename: string | null, event: LoggerEvent): void {274      events.push(event);275    },276  };277278  const opts = {...pluginOptions, logger};279  const plugin = mode === 'ts' ? tsPlugin : rustPlugin;280281  try {282    const result = babel.transformSync(source, {283      filename: filePath,284      sourceType: 'module',285      plugins: [hermesParserPlugin, [plugin, opts]],286      configFile: false,287      babelrc: false,288    });289    return {code: result?.code ?? null, events, error: null};290  } catch (e) {291    const msg = e instanceof Error ? e.message : String(e);292    return {code: null, events, error: msg};293  }294}295296// --- Format events for comparison ---297function formatEvents(events: LoggerEvent[]): string {298  return events299    .filter(e =>300      [301        'CompileError',302        'CompileSkip',303        'CompileSuccess',304        'PipelineError',305      ].includes(e.kind),306    )307    .map(e => {308      if (e.kind === 'CompileSuccess') {309        return `[CompileSuccess] ${e.fnName ?? '(anonymous)'}`;310      }311      if (e.kind === 'CompileError') {312        const d = e.detail;313        return `[CompileError] ${d?.reason ?? '(no reason)'} (${d?.severity ?? ''}, ${d?.category ?? ''})`;314      }315      if (e.kind === 'CompileSkip') {316        return `[CompileSkip] ${e.reason ?? '(no reason)'}`;317      }318      return `[${e.kind}] ${e.data ?? ''}`;319    })320    .join('\n');321}322323// --- Simple diff ---324function unifiedDiff(expected: string, actual: string): string {325  const expectedLines = expected.split('\n');326  const actualLines = actual.split('\n');327  const lines: string[] = [];328  lines.push(`${RED}--- TS${RESET}`);329  lines.push(`${GREEN}+++ Rust${RESET}`);330331  const maxLen = Math.max(expectedLines.length, actualLines.length);332  let contextStart = -1;333  for (let i = 0; i < maxLen; i++) {334    const eLine = i < expectedLines.length ? expectedLines[i] : undefined;335    const aLine = i < actualLines.length ? actualLines[i] : undefined;336    if (eLine === aLine) continue;337    if (contextStart !== i) {338      lines.push(`${YELLOW}@@ line ${i + 1} @@${RESET}`);339    }340    contextStart = i + 1;341    if (eLine !== undefined) lines.push(`${RED}-${eLine}${RESET}`);342    if (aLine !== undefined) lines.push(`${GREEN}+${aLine}${RESET}`);343  }344  return lines.join('\n');345}346347// --- Format code with prettier ---348async function formatCode(code: string): Promise<string> {349  return prettier.format(code, {semi: true, parser: 'flow'});350}351352// --- Generate pragma line for fixture ---353function generatePragma(354  projectConfig: Record<string, unknown>,355  isFlow: boolean,356): string {357  const parts: string[] = [];358359  // The snap tool checks the first line for @flow to determine parser360  if (isFlow) {361    parts.push('@flow');362  }363364  // compilationMode365  const mode = projectConfig.compilationMode as string;366  if (mode && mode !== 'all') {367    parts.push(`@compilationMode:"${mode}"`);368  }369370  // Environment options371  const env = (projectConfig.environment ?? {}) as Record<string, unknown>;372373  if (env.enableAssumeHooksFollowRulesOfReact === true) {374    parts.push('@enableAssumeHooksFollowRulesOfReact');375  }376  if (env.enableTransitivelyFreezeFunctionExpressions === true) {377    parts.push('@enableTransitivelyFreezeFunctionExpressions');378  }379  if (env.validatePreserveExistingMemoizationGuarantees === true) {380    parts.push('@validatePreserveExistingMemoizationGuarantees');381  }382  if (env.enableFunctionOutlining === false) {383    parts.push('@enableFunctionOutlining:false');384  }385  if (386    Array.isArray(env.validateNoCapitalizedCalls) &&387    env.validateNoCapitalizedCalls.length > 0388  ) {389    parts.push(390      `@validateNoCapitalizedCalls:${JSON.stringify(env.validateNoCapitalizedCalls)}`,391    );392  }393394  // target for experimental projects395  if (projectConfig.compilerVersion === 'experimental') {396    parts.push('@target:"donotuse_meta_internal"');397  }398399  return parts.length > 0 ? '// ' + parts.join(' ') : '';400}401402// --- Create fixture file and baseline .expect.md ---403async function createFixture(404  filePath: string,405  projectName: string,406  projectConfig: Record<string, unknown>,407  tsCode: string | null,408  tsError: string | null,409): Promise<string> {410  fs.mkdirSync(FIXTURE_DIR, {recursive: true});411412  const basename = path.basename(filePath);413  const ext = path.extname(basename);414  const stem = basename.slice(0, -ext.length);415  let fixtureName = `${projectName}--${basename}`;416417  // Deduplicate418  let counter = 0;419  while (fs.existsSync(path.join(FIXTURE_DIR, fixtureName))) {420    counter++;421    fixtureName = `${projectName}--${stem}-${counter}${ext}`;422  }423424  const source = fs.readFileSync(filePath, 'utf8');425  const headerBlock = source.substring(0, source.indexOf('*/') + 2 || 200);426  const isFlow = headerBlock.includes('@flow');427  const pragma = generatePragma(projectConfig, isFlow);428429  const content = pragma ? `${pragma}\n${source}` : source;430431  const fixturePath = path.join(FIXTURE_DIR, fixtureName);432  fs.writeFileSync(fixturePath, content, 'utf8');433434  // Generate .expect.md baseline from TS compiler output.435  // This bypasses yarn snap -u (which fails due to sprout evaluator436  // not being able to resolve internal module imports).437  // Format matches snap's writeOutputToString in reporter.ts.438  let formattedCode: string | null = null;439  if (tsCode != null) {440    try {441      formattedCode = await formatCode(tsCode);442    } catch {443      formattedCode = tsCode;444    }445  }446447  let expectMd = `\n## Input\n\n\`\`\`javascript\n${content}\n\`\`\`\n`;448  if (formattedCode != null) {449    expectMd += `\n## Code\n\n\`\`\`javascript\n${formattedCode}\`\`\`\n`;450  } else {451    expectMd += '\n';452  }453  if (tsError != null) {454    const cleanError = tsError.replace(/^\/.*?:\s/, '');455    expectMd += `\n## Error\n\n\`\`\`\n${cleanError}\n\`\`\`\n          \n`;456  }457  expectMd += `      `;458459  const expectPath = fixturePath.replace(/\.[^.]+$/, '.expect.md');460  fs.writeFileSync(expectPath, expectMd, 'utf8');461462  return fixturePath;463}464465// --- Main ---466(async () => {467  const files = discoverFiles();468  if (files.length === 0) {469    console.error('No files found matching filters.');470    process.exit(1);471  }472473  console.log(474    `\nComparing ${BOLD}${files.length}${RESET} files (TS vs Rust)...\n`,475  );476477  // Crash recovery: maintain a skip list of files that segfault the native module.478  // On crash, the current file is appended to the skip list.479  // On subsequent runs, those files are skipped automatically.480  const crashLogPath = path.join(481    REPO_ROOT,482    'compiler/.test-internal-files-current',483  );484  const skipListPath = path.join(485    REPO_ROOT,486    'compiler/.test-internal-skip-list',487  );488  const skipSet = new Set<string>();489  try {490    const skipData = fs.readFileSync(skipListPath, 'utf8');491    for (const line of skipData.split('\n')) {492      const trimmed = line.trim();493      if (trimmed) skipSet.add(trimmed);494    }495    if (skipSet.size > 0) {496      console.log(497        `${DIM}Skipping ${skipSet.size} previously-crashing files${RESET}`,498      );499    }500  } catch {}501502  process.on('exit', code => {503    if (code === 139 || code === 134 || code === 11) {504      // SIGSEGV or SIGABRT — append the culprit to skip list505      try {506        const crashFile = fs.readFileSync(crashLogPath, 'utf8').trim();507        fs.appendFileSync(skipListPath, crashFile + '\n', 'utf8');508        console.error(509          `\n${RED}CRASH (signal ${code}) on file: ${crashFile}${RESET}`,510        );511        console.error(512          `${YELLOW}File added to skip list. Re-run to continue.${RESET}`,513        );514      } catch {}515    }516    try {517      fs.unlinkSync(crashLogPath);518    } catch {}519  });520521  let processed = 0;522  let codeDiffs = 0;523  let eventDiffs = 0;524  let bothErrored = 0;525  let crashes = 0;526  const diffFiles: Array<{527    filePath: string;528    projectName: string;529    projectConfig: Record<string, unknown>;530    codeDiff: string | null;531    eventDiff: string | null;532    tsCode: string | null;533    tsError: string | null;534  }> = [];535  const createdFixtures: string[] = [];536537  for (const {filePath, projectName, projectConfig} of files) {538    if (skipSet.has(filePath)) continue;539    processed++;540541    // Write current file for crash identification542    fs.writeFileSync(crashLogPath, filePath, 'utf8');543544    // Progress545    const relPath = filePath.replace(sourceRoot + '/', '');546    process.stdout.write(547      `\r${DIM}[${processed}/${files.length}] ${codeDiffs} code diffs, ${eventDiffs} event diffs, ${crashes} crashes${RESET}  `,548    );549550    const pluginOpts = makePluginOptions(projectConfig, {551      logEvent() {},552    });553554    let tsResult: CompileResult;555    let rustResult: CompileResult;556    try {557      tsResult = compileFile('ts', filePath, pluginOpts);558    } catch (e) {559      // Unexpected crash in TS compiler560      tsResult = {561        code: null,562        events: [],563        error: `TS crash: ${e instanceof Error ? e.message : String(e)}`,564      };565    }566    try {567      rustResult = compileFile('rust', filePath, pluginOpts);568    } catch (e) {569      // Unexpected crash in Rust compiler570      rustResult = {571        code: null,572        events: [],573        error: `Rust crash: ${e instanceof Error ? e.message : String(e)}`,574      };575    }576577    // Compare code578    let hasCodeDiff = false;579    let codeDiffDetail: string | null = null;580    try {581      const tsCode = await formatCode(tsResult.code ?? '');582      const rustCode = await formatCode(rustResult.code ?? '');583      if (tsCode !== rustCode) {584        hasCodeDiff = true;585        codeDiffDetail = unifiedDiff(tsCode, rustCode);586      }587    } catch {588      // Prettier failed — compare raw589      if ((tsResult.code ?? '') !== (rustResult.code ?? '')) {590        hasCodeDiff = true;591        codeDiffDetail = unifiedDiff(592          tsResult.code ?? '',593          rustResult.code ?? '',594        );595      }596    }597598    // Compare error events599    const tsEvents = formatEvents(tsResult.events);600    const rustEvents = formatEvents(rustResult.events);601    let hasEventDiff = false;602    let eventDiffDetail: string | null = null;603    if (tsEvents !== rustEvents) {604      hasEventDiff = true;605      eventDiffDetail = unifiedDiff(tsEvents, rustEvents);606    }607608    // Track errors609    if (tsResult.error && rustResult.error) {610      bothErrored++;611    }612613    if (hasCodeDiff || hasEventDiff) {614      if (hasCodeDiff) codeDiffs++;615      if (hasEventDiff) eventDiffs++;616      diffFiles.push({617        filePath,618        projectName,619        projectConfig,620        codeDiff: codeDiffDetail,621        eventDiff: eventDiffDetail,622        tsCode: tsResult.code,623        tsError: tsResult.error,624      });625    }626  }627628  // Clear progress line629  process.stdout.write('\r' + ' '.repeat(80) + '\r');630631  // --- Report diffs ---632  if (diffFiles.length > 0) {633    console.log(`\n${BOLD}--- Differences ---${RESET}\n`);634    const showLimit = 50;635    const toShow = diffFiles.slice(0, showLimit);636637    for (const entry of toShow) {638      const relPath = entry.filePath.replace(sourceRoot + '/', '');639      console.log(640        `${RED}DIFF${RESET} ${relPath} ${DIM}(${entry.projectName})${RESET}`,641      );642      if (entry.codeDiff) {643        console.log(entry.codeDiff);644      }645      if (entry.eventDiff) {646        console.log(`${YELLOW}Event diff:${RESET}`);647        console.log(entry.eventDiff);648      }649      console.log('');650    }651    if (diffFiles.length > showLimit) {652      console.log(653        `${DIM}(showing first ${showLimit} of ${diffFiles.length} diffs)${RESET}`,654      );655    }656  }657658  // --- Create fixtures ---659  if (!dryRun && diffFiles.length > 0) {660    console.log(`\n${BOLD}--- Creating fixtures ---${RESET}\n`);661662    // Only create fixtures for files with code diffs (not event-only diffs)663    const codeDiffFiles = diffFiles.filter(f => f.codeDiff != null);664665    for (const entry of codeDiffFiles) {666      const fixturePath = await createFixture(667        entry.filePath,668        entry.projectName,669        entry.projectConfig,670        entry.tsCode,671        entry.tsError,672      );673      createdFixtures.push(fixturePath);674      const fixtureName = path.basename(fixturePath);675      console.log(`  ${GREEN}+${RESET} ${fixtureName}`);676    }677678    if (createdFixtures.length > 0) {679      console.log(680        `\nCreated ${BOLD}${createdFixtures.length}${RESET} fixtures in internal/`,681      );682683      // --- Verification ---684      console.log(`\n${BOLD}--- Verifying fixtures ---${RESET}\n`);685686      // Verify with Rust compiler (baselines already written as .expect.md)687      console.log('\nVerifying with yarn snap --rust ...');688      try {689        execSync("yarn snap --rust -p 'internal/*'", {690          cwd: path.join(REPO_ROOT, 'compiler'),691          stdio: 'inherit',692        });693        // If snap --rust passes, all fixtures matched (unexpected)694        console.log(695          `${YELLOW}WARNING: yarn snap --rust passed — fixtures may not reproduce differences.${RESET}`,696        );697      } catch {698        // Expected: snap --rust should fail for differing fixtures699        console.log(700          `${GREEN}yarn snap --rust failed as expected — fixtures reproduce differences.${RESET}`,701        );702      }703704      console.log(705        `\n${RED}${BOLD}DO NOT COMMIT${RESET}${RED} the fixture files in internal/${RESET}`,706      );707    }708  }709710  // --- Summary ---711  console.log(`\n${BOLD}--- Summary ---${RESET}`);712  console.log(`Processed: ${processed}`);713  console.log(`Code diffs: ${codeDiffs}`);714  console.log(715    `Event-only diffs: ${eventDiffs - codeDiffs > 0 ? eventDiffs - codeDiffs : 0}`,716  );717  console.log(`Both errored: ${bothErrored}`);718  if (crashes > 0) console.log(`Crashes (skipped): ${crashes}`);719  if (createdFixtures.length > 0) {720    console.log(`Fixtures created: ${createdFixtures.length}`);721  }722723  process.exit(codeDiffs > 0 ? 1 : 0);724})();

Findings

✓ No findings reported for this file.

Get this view in your editor

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