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.