| 1 | /** |
| 2 | * Copyright (c) Meta Platforms, Inc. and affiliates. |
| 3 | * |
| 4 | * This source code is licensed under the MIT license found in the |
| 5 | * LICENSE file in the root directory of this source tree. |
| 6 | */ |
| 7 | |
| 8 | /** |
| 9 | * Compare TS and Rust React Compiler output on external production files. |
| 10 | * |
| 11 | * Given a configuration module and a source root directory, compiles files |
| 12 | * using the exact plugin options from the configuration, then compares code |
| 13 | * output and error events between the TS and Rust compilers. Files with |
| 14 | * 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 that |
| 21 | * exports getForgetConfiguration and config) |
| 22 | * <source-root> Root directory from which the config's source prefixes |
| 23 | * are resolved |
| 24 | * |
| 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 config |
| 29 | * --dry-run Compare only, skip fixture creation |
| 30 | * --no-color Disable ANSI color codes |
| 31 | */ |
| 32 | |
| 33 | import * as babel from '@babel/core'; |
| 34 | import hermesParserPlugin from 'babel-plugin-syntax-hermes-parser'; |
| 35 | import {execSync} from 'child_process'; |
| 36 | import fs from 'fs'; |
| 37 | import path from 'path'; |
| 38 | import prettier from 'prettier'; |
| 39 | |
| 40 | const REPO_ROOT = path.resolve(__dirname, '../..'); |
| 41 | const FIXTURE_DIR = path.join( |
| 42 | REPO_ROOT, |
| 43 | 'compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/internal', |
| 44 | ); |
| 45 | |
| 46 | // --- Parse flags and positional args --- |
| 47 | const rawArgs = process.argv.slice(2); |
| 48 | const noColor = rawArgs.includes('--no-color') || !!process.env.NO_COLOR; |
| 49 | const dryRun = rawArgs.includes('--dry-run'); |
| 50 | const limitIdx = rawArgs.indexOf('--limit'); |
| 51 | const limitArg = limitIdx >= 0 ? parseInt(rawArgs[limitIdx + 1], 10) : 0; |
| 52 | const patternIdx = rawArgs.indexOf('--pattern'); |
| 53 | const patternArg = patternIdx >= 0 ? rawArgs[patternIdx + 1] : null; |
| 54 | const projectIdx = rawArgs.indexOf('--project'); |
| 55 | const projectArg = projectIdx >= 0 ? rawArgs[projectIdx + 1] : null; |
| 56 | |
| 57 | // Collect flag indices to exclude from positional args |
| 58 | const flagValueIndices = new Set<number>(); |
| 59 | if (limitIdx >= 0) flagValueIndices.add(limitIdx + 1); |
| 60 | if (patternIdx >= 0) flagValueIndices.add(patternIdx + 1); |
| 61 | if (projectIdx >= 0) flagValueIndices.add(projectIdx + 1); |
| 62 | const positional = rawArgs.filter( |
| 63 | (a, i) => !a.startsWith('--') && !flagValueIndices.has(i), |
| 64 | ); |
| 65 | |
| 66 | if (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 | } |
| 85 | |
| 86 | const configPath = path.resolve(positional[0]); |
| 87 | const sourceRoot = path.resolve(positional[1]); |
| 88 | |
| 89 | // --- ANSI colors --- |
| 90 | const RED = noColor ? '' : '\x1b[0;31m'; |
| 91 | const GREEN = noColor ? '' : '\x1b[0;32m'; |
| 92 | const YELLOW = noColor ? '' : '\x1b[0;33m'; |
| 93 | const BOLD = noColor ? '' : '\x1b[1m'; |
| 94 | const DIM = noColor ? '' : '\x1b[2m'; |
| 95 | const RESET = noColor ? '' : '\x1b[0m'; |
| 96 | |
| 97 | // --- Load config --- |
| 98 | if (!fs.existsSync(configPath)) { |
| 99 | console.error(`${RED}ERROR: Could not find config at ${configPath}${RESET}`); |
| 100 | process.exit(1); |
| 101 | } |
| 102 | if (!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 | } |
| 108 | const forgetConfig = require(configPath); |
| 109 | const {getForgetConfiguration, config: rawConfig} = forgetConfig; |
| 110 | |
| 111 | // --- Build native module --- |
| 112 | const NATIVE_DIR = path.join( |
| 113 | REPO_ROOT, |
| 114 | 'compiler/packages/babel-plugin-react-compiler-rust/native', |
| 115 | ); |
| 116 | const NATIVE_NODE_PATH = path.join(NATIVE_DIR, 'index.node'); |
| 117 | |
| 118 | console.log('Building Rust native module...'); |
| 119 | try { |
| 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 | } |
| 129 | |
| 130 | const TARGET_DIR = path.join(REPO_ROOT, 'compiler/target/debug'); |
| 131 | const 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'); |
| 136 | |
| 137 | if (!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 | } |
| 143 | fs.copyFileSync(dylib, NATIVE_NODE_PATH); |
| 144 | |
| 145 | // --- Load plugins --- |
| 146 | const tsPlugin = require('../packages/babel-plugin-react-compiler/src').default; |
| 147 | const rustPlugin = |
| 148 | require('../packages/babel-plugin-react-compiler-rust/src').default; |
| 149 | |
| 150 | // --- Types --- |
| 151 | interface 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 | } |
| 164 | |
| 165 | interface CompileResult { |
| 166 | code: string | null; |
| 167 | events: LoggerEvent[]; |
| 168 | error: string | null; |
| 169 | } |
| 170 | |
| 171 | interface FileEntry { |
| 172 | filePath: string; |
| 173 | projectName: string; |
| 174 | projectConfig: Record<string, unknown>; |
| 175 | } |
| 176 | |
| 177 | // --- File discovery --- |
| 178 | // Additional directory exclusions beyond what the config handles |
| 179 | const EXTRA_EXCLUDES = [ |
| 180 | '__server_snapshot_tests__', |
| 181 | '__e2e_tests__', |
| 182 | '__perf_tests__', |
| 183 | '__integration_tests__', |
| 184 | ]; |
| 185 | |
| 186 | function 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 | } |
| 203 | |
| 204 | function discoverFiles(): FileEntry[] { |
| 205 | console.log('Discovering files...'); |
| 206 | const results: FileEntry[] = []; |
| 207 | const seen = new Set<string>(); |
| 208 | |
| 209 | for (const [prefix, projectName] of Object.entries( |
| 210 | rawConfig.sources as Record<string, string>, |
| 211 | )) { |
| 212 | if (projectArg && projectName !== projectArg) continue; |
| 213 | |
| 214 | // Resolve prefix to absolute path relative to source root |
| 215 | const absPrefix = path.join(sourceRoot, prefix); |
| 216 | if (!fs.existsSync(absPrefix)) continue; |
| 217 | |
| 218 | const stat = fs.statSync(absPrefix); |
| 219 | if (!stat.isDirectory()) continue; |
| 220 | |
| 221 | walkDir(absPrefix, filePath => { |
| 222 | if (seen.has(filePath)) return; |
| 223 | seen.add(filePath); |
| 224 | |
| 225 | if (patternArg && !filePath.includes(patternArg)) return; |
| 226 | |
| 227 | const config = getForgetConfiguration(filePath); |
| 228 | if (config == null || !config.enable) return; |
| 229 | |
| 230 | results.push({filePath, projectName, projectConfig: config}); |
| 231 | }); |
| 232 | |
| 233 | if (limitArg > 0 && results.length >= limitArg) break; |
| 234 | } |
| 235 | |
| 236 | if (limitArg > 0) { |
| 237 | return results.slice(0, limitArg); |
| 238 | } |
| 239 | return results; |
| 240 | } |
| 241 | |
| 242 | // --- Build plugin options from production config --- |
| 243 | function 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.target |
| 254 | : projectConfig.target, |
| 255 | gating: null, |
| 256 | flowSuppressions: projectConfig.flowSuppressions, |
| 257 | enableReanimatedCheck: false, |
| 258 | logger, |
| 259 | sources: null, |
| 260 | }; |
| 261 | } |
| 262 | |
| 263 | // --- Compile a file --- |
| 264 | function 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[] = []; |
| 271 | |
| 272 | const logger = { |
| 273 | logEvent(_filename: string | null, event: LoggerEvent): void { |
| 274 | events.push(event); |
| 275 | }, |
| 276 | }; |
| 277 | |
| 278 | const opts = {...pluginOptions, logger}; |
| 279 | const plugin = mode === 'ts' ? tsPlugin : rustPlugin; |
| 280 | |
| 281 | 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 | } |
| 295 | |
| 296 | // --- Format events for comparison --- |
| 297 | function formatEvents(events: LoggerEvent[]): string { |
| 298 | return events |
| 299 | .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 | } |
| 322 | |
| 323 | // --- Simple diff --- |
| 324 | function 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}`); |
| 330 | |
| 331 | 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 | } |
| 346 | |
| 347 | // --- Format code with prettier --- |
| 348 | async function formatCode(code: string): Promise<string> { |
| 349 | return prettier.format(code, {semi: true, parser: 'flow'}); |
| 350 | } |
| 351 | |
| 352 | // --- Generate pragma line for fixture --- |
| 353 | function generatePragma( |
| 354 | projectConfig: Record<string, unknown>, |
| 355 | isFlow: boolean, |
| 356 | ): string { |
| 357 | const parts: string[] = []; |
| 358 | |
| 359 | // The snap tool checks the first line for @flow to determine parser |
| 360 | if (isFlow) { |
| 361 | parts.push('@flow'); |
| 362 | } |
| 363 | |
| 364 | // compilationMode |
| 365 | const mode = projectConfig.compilationMode as string; |
| 366 | if (mode && mode !== 'all') { |
| 367 | parts.push(`@compilationMode:"${mode}"`); |
| 368 | } |
| 369 | |
| 370 | // Environment options |
| 371 | const env = (projectConfig.environment ?? {}) as Record<string, unknown>; |
| 372 | |
| 373 | 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 > 0 |
| 388 | ) { |
| 389 | parts.push( |
| 390 | `@validateNoCapitalizedCalls:${JSON.stringify(env.validateNoCapitalizedCalls)}`, |
| 391 | ); |
| 392 | } |
| 393 | |
| 394 | // target for experimental projects |
| 395 | if (projectConfig.compilerVersion === 'experimental') { |
| 396 | parts.push('@target:"donotuse_meta_internal"'); |
| 397 | } |
| 398 | |
| 399 | return parts.length > 0 ? '// ' + parts.join(' ') : ''; |
| 400 | } |
| 401 | |
| 402 | // --- Create fixture file and baseline .expect.md --- |
| 403 | async 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}); |
| 411 | |
| 412 | const basename = path.basename(filePath); |
| 413 | const ext = path.extname(basename); |
| 414 | const stem = basename.slice(0, -ext.length); |
| 415 | let fixtureName = `${projectName}--${basename}`; |
| 416 | |
| 417 | // Deduplicate |
| 418 | let counter = 0; |
| 419 | while (fs.existsSync(path.join(FIXTURE_DIR, fixtureName))) { |
| 420 | counter++; |
| 421 | fixtureName = `${projectName}--${stem}-${counter}${ext}`; |
| 422 | } |
| 423 | |
| 424 | 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); |
| 428 | |
| 429 | const content = pragma ? `${pragma}\n${source}` : source; |
| 430 | |
| 431 | const fixturePath = path.join(FIXTURE_DIR, fixtureName); |
| 432 | fs.writeFileSync(fixturePath, content, 'utf8'); |
| 433 | |
| 434 | // Generate .expect.md baseline from TS compiler output. |
| 435 | // This bypasses yarn snap -u (which fails due to sprout evaluator |
| 436 | // 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 | } |
| 446 | |
| 447 | 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 += ` `; |
| 458 | |
| 459 | const expectPath = fixturePath.replace(/\.[^.]+$/, '.expect.md'); |
| 460 | fs.writeFileSync(expectPath, expectMd, 'utf8'); |
| 461 | |
| 462 | return fixturePath; |
| 463 | } |
| 464 | |
| 465 | // --- 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 | } |
| 472 | |
| 473 | console.log( |
| 474 | `\nComparing ${BOLD}${files.length}${RESET} files (TS vs Rust)...\n`, |
| 475 | ); |
| 476 | |
| 477 | // 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 {} |
| 501 | |
| 502 | process.on('exit', code => { |
| 503 | if (code === 139 || code === 134 || code === 11) { |
| 504 | // SIGSEGV or SIGABRT — append the culprit to skip list |
| 505 | 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 | }); |
| 520 | |
| 521 | 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[] = []; |
| 536 | |
| 537 | for (const {filePath, projectName, projectConfig} of files) { |
| 538 | if (skipSet.has(filePath)) continue; |
| 539 | processed++; |
| 540 | |
| 541 | // Write current file for crash identification |
| 542 | fs.writeFileSync(crashLogPath, filePath, 'utf8'); |
| 543 | |
| 544 | // Progress |
| 545 | 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 | ); |
| 549 | |
| 550 | const pluginOpts = makePluginOptions(projectConfig, { |
| 551 | logEvent() {}, |
| 552 | }); |
| 553 | |
| 554 | let tsResult: CompileResult; |
| 555 | let rustResult: CompileResult; |
| 556 | try { |
| 557 | tsResult = compileFile('ts', filePath, pluginOpts); |
| 558 | } catch (e) { |
| 559 | // Unexpected crash in TS compiler |
| 560 | 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 compiler |
| 570 | rustResult = { |
| 571 | code: null, |
| 572 | events: [], |
| 573 | error: `Rust crash: ${e instanceof Error ? e.message : String(e)}`, |
| 574 | }; |
| 575 | } |
| 576 | |
| 577 | // Compare code |
| 578 | 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 raw |
| 589 | if ((tsResult.code ?? '') !== (rustResult.code ?? '')) { |
| 590 | hasCodeDiff = true; |
| 591 | codeDiffDetail = unifiedDiff( |
| 592 | tsResult.code ?? '', |
| 593 | rustResult.code ?? '', |
| 594 | ); |
| 595 | } |
| 596 | } |
| 597 | |
| 598 | // Compare error events |
| 599 | 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 | } |
| 607 | |
| 608 | // Track errors |
| 609 | if (tsResult.error && rustResult.error) { |
| 610 | bothErrored++; |
| 611 | } |
| 612 | |
| 613 | 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 | } |
| 627 | |
| 628 | // Clear progress line |
| 629 | process.stdout.write('\r' + ' '.repeat(80) + '\r'); |
| 630 | |
| 631 | // --- 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); |
| 636 | |
| 637 | 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 | } |
| 657 | |
| 658 | // --- Create fixtures --- |
| 659 | if (!dryRun && diffFiles.length > 0) { |
| 660 | console.log(`\n${BOLD}--- Creating fixtures ---${RESET}\n`); |
| 661 | |
| 662 | // Only create fixtures for files with code diffs (not event-only diffs) |
| 663 | const codeDiffFiles = diffFiles.filter(f => f.codeDiff != null); |
| 664 | |
| 665 | 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 | } |
| 677 | |
| 678 | if (createdFixtures.length > 0) { |
| 679 | console.log( |
| 680 | `\nCreated ${BOLD}${createdFixtures.length}${RESET} fixtures in internal/`, |
| 681 | ); |
| 682 | |
| 683 | // --- Verification --- |
| 684 | console.log(`\n${BOLD}--- Verifying fixtures ---${RESET}\n`); |
| 685 | |
| 686 | // 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 fixtures |
| 699 | console.log( |
| 700 | `${GREEN}yarn snap --rust failed as expected — fixtures reproduce differences.${RESET}`, |
| 701 | ); |
| 702 | } |
| 703 | |
| 704 | console.log( |
| 705 | `\n${RED}${BOLD}DO NOT COMMIT${RESET}${RED} the fixture files in internal/${RESET}`, |
| 706 | ); |
| 707 | } |
| 708 | } |
| 709 | |
| 710 | // --- 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 | } |
| 722 | |
| 723 | process.exit(codeDiffs > 0 ? 1 : 0); |
| 724 | })(); |