main
ts 844 lines 27.9 KB
Raw
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 * Unified Babel plugin-based test script for comparing TS and Rust compilers.
10 *
11 * Runs both compilers through their real Babel plugins, captures debug log
12 * entries via the logger API, and diffs output for a specific pass.
13 *
14 * Usage: npx tsx compiler/scripts/test-rust-port.ts [<pass>] [<fixtures-path>] [flags]
15 *
16 * Flags:
17 * --no-color Disable ANSI color codes (also respects NO_COLOR env var)
18 * --json Output a single JSON object to stdout (machine-readable)
19 * --failures Print only failing fixture paths, one per line
20 * --limit N Max failures to display with diffs (default: 50, 0 = all)
21 * --mode MODE Compilation mode (default: use implementation default)
22 */
23
24 import * as babel from '@babel/core';
25 import hermesParserPlugin from 'babel-plugin-syntax-hermes-parser';
26 import {execSync} from 'child_process';
27 import fs from 'fs';
28 import path from 'path';
29 import prettier from 'prettier';
30
31 import {parseConfigPragmaForTests} from '../packages/babel-plugin-react-compiler/src/Utils/TestUtils';
32 import {printDebugHIR} from '../packages/babel-plugin-react-compiler/src/HIR/DebugPrintHIR';
33 import {printDebugReactiveFunction} from '../packages/babel-plugin-react-compiler/src/HIR/DebugPrintReactiveFunction';
34 import type {CompilerPipelineValue} from '../packages/babel-plugin-react-compiler/src/Entrypoint/Pipeline';
35
36 const REPO_ROOT = path.resolve(__dirname, '../..');
37
38 // --- Parse flags ---
39 const rawArgs = process.argv.slice(2);
40 const noColor = rawArgs.includes('--no-color') || !!process.env.NO_COLOR;
41 const jsonMode = rawArgs.includes('--json');
42 const failuresMode = rawArgs.includes('--failures');
43 const limitIdx = rawArgs.indexOf('--limit');
44 const limitArg = limitIdx >= 0 ? parseInt(rawArgs[limitIdx + 1], 10) : 50;
45 const modeIdx = rawArgs.indexOf('--mode');
46 const compilationModeArg: string | null =
47 modeIdx >= 0 ? rawArgs[modeIdx + 1] : null;
48
49 // Extract positional args (strip flags and flag values)
50 const flagValueIndices = new Set<number>();
51 if (limitIdx >= 0) flagValueIndices.add(limitIdx + 1);
52 if (modeIdx >= 0) flagValueIndices.add(modeIdx + 1);
53 const positional = rawArgs.filter(
54 (a, i) => !a.startsWith('--') && !flagValueIndices.has(i),
55 );
56
57 // --- ANSI colors ---
58 const useColor = !noColor && !jsonMode && !failuresMode;
59 const RED = useColor ? '\x1b[0;31m' : '';
60 const GREEN = useColor ? '\x1b[0;32m' : '';
61 const YELLOW = useColor ? '\x1b[0;33m' : '';
62 const BOLD = useColor ? '\x1b[1m' : '';
63 const DIM = useColor ? '\x1b[2m' : '';
64 const RESET = useColor ? '\x1b[0m' : '';
65
66 // --- Ordered pass list (derived from pipeline.rs DebugLogEntry calls) ---
67 function derivePassOrder(): string[] {
68 const pipelinePath = path.join(
69 REPO_ROOT,
70 'compiler/crates/react_compiler/src/entrypoint/pipeline.rs',
71 );
72 const content = fs.readFileSync(pipelinePath, 'utf8');
73 const matches = [...content.matchAll(/DebugLogEntry::new\("([^"]+)"/g)];
74 return matches.map(m => m[1]);
75 }
76
77 const PASS_ORDER = derivePassOrder();
78
79 // --- Detect last ported pass from pipeline.rs ---
80 function detectLastPortedPass(): string {
81 if (PASS_ORDER.length === 0) {
82 throw new Error('No ported passes found in pipeline.rs');
83 }
84 return PASS_ORDER[PASS_ORDER.length - 1];
85 }
86
87 // --- Parse args ---
88 const [passArgRaw, fixturesPathArg] = positional;
89
90 let passArg: string;
91 if (passArgRaw) {
92 passArg = passArgRaw;
93 } else {
94 passArg = detectLastPortedPass();
95 if (!jsonMode && !failuresMode) {
96 console.log(
97 `No pass argument given, auto-detected last ported pass: ${BOLD}${passArg}${RESET}`,
98 );
99 }
100 }
101 const DEFAULT_FIXTURES_DIR = path.join(
102 REPO_ROOT,
103 'compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler',
104 );
105
106 const fixturesPath = fixturesPathArg
107 ? path.resolve(fixturesPathArg)
108 : DEFAULT_FIXTURES_DIR;
109
110 // --- Build native module ---
111 const NATIVE_DIR = path.join(
112 REPO_ROOT,
113 'compiler/packages/babel-plugin-react-compiler-rust/native',
114 );
115 const NATIVE_NODE_PATH = path.join(NATIVE_DIR, 'index.node');
116
117 if (!jsonMode && !failuresMode) {
118 console.log('Building Rust native module...');
119 }
120 try {
121 execSync('~/.cargo/bin/cargo build -p react_compiler_napi', {
122 cwd: path.join(REPO_ROOT, 'compiler/crates'),
123 stdio:
124 jsonMode || failuresMode ? ['inherit', 'pipe', 'inherit'] : 'inherit',
125 shell: true,
126 });
127 } catch {
128 console.error(`${RED}ERROR: Failed to build Rust native module.${RESET}`);
129 process.exit(1);
130 }
131
132 // Copy the built dylib as index.node (Node requires .node extension for native addons)
133 const TARGET_DIR = path.join(REPO_ROOT, 'compiler/target/debug');
134 const dylib = fs.existsSync(
135 path.join(TARGET_DIR, 'libreact_compiler_napi.dylib'),
136 )
137 ? path.join(TARGET_DIR, 'libreact_compiler_napi.dylib')
138 : path.join(TARGET_DIR, 'libreact_compiler_napi.so');
139
140 if (!fs.existsSync(dylib)) {
141 console.error(
142 `${RED}ERROR: Could not find built native module in ${TARGET_DIR}${RESET}`,
143 );
144 process.exit(1);
145 }
146 fs.copyFileSync(dylib, NATIVE_NODE_PATH);
147
148 // --- Load plugins ---
149 const tsPlugin = require('../packages/babel-plugin-react-compiler/src').default;
150 const rustPlugin =
151 require('../packages/babel-plugin-react-compiler-rust/src').default;
152
153 // --- Types ---
154 interface LogEntry {
155 kind: 'entry';
156 name: string;
157 value: string;
158 }
159
160 interface LogEvent {
161 kind: 'event';
162 eventKind: string;
163 fnName: string | null;
164 detail: string;
165 }
166
167 type LogItem = LogEntry | LogEvent;
168
169 interface CompileOutput {
170 log: LogItem[];
171 code: string | null;
172 error: string | null;
173 }
174
175 type CompileMode = 'ts' | 'rust';
176
177 // --- Discover fixtures ---
178 function discoverFixtures(rootPath: string): string[] {
179 const stat = fs.statSync(rootPath);
180 if (stat.isFile()) {
181 return [rootPath];
182 }
183
184 const results: string[] = [];
185 function walk(dir: string): void {
186 for (const entry of fs.readdirSync(dir, {withFileTypes: true})) {
187 const fullPath = path.join(dir, entry.name);
188 if (entry.isDirectory()) {
189 walk(fullPath);
190 } else if (
191 /\.(js|jsx|ts|tsx)$/.test(entry.name) &&
192 !entry.name.endsWith('.expect.md')
193 ) {
194 results.push(fullPath);
195 }
196 }
197 }
198 walk(rootPath);
199 results.sort();
200 return results;
201 }
202
203 // --- Format a source location for comparison ---
204 function formatLoc(loc: unknown): string {
205 if (loc == null) return '(generated)';
206 if (typeof loc === 'symbol') return '(generated)';
207 const l = loc as Record<string, unknown>;
208 const start = l.start as Record<string, unknown> | undefined;
209 const end = l.end as Record<string, unknown> | undefined;
210 if (start && end) {
211 return `${start.line}:${start.column}-${end.line}:${end.column}`;
212 }
213 return String(loc);
214 }
215
216 // --- Compile a fixture through a Babel plugin and capture debug entries ---
217 function compileFixture(mode: CompileMode, fixturePath: string): CompileOutput {
218 const source = fs.readFileSync(fixturePath, 'utf8');
219 const firstLine = source.substring(0, source.indexOf('\n'));
220
221 // Parse pragma config
222 const pragmaOpts = parseConfigPragmaForTests(firstLine, {
223 compilationMode: 'all',
224 });
225
226 // Capture debug entries and logger events in order, stopping after the target pass
227 const log: LogItem[] = [];
228 let reachedTarget = false;
229
230 const logger = {
231 logEvent(_filename: string | null, event: Record<string, unknown>): void {
232 if (reachedTarget) return;
233 const kind = event.kind as string;
234 if (
235 kind === 'CompileError' ||
236 kind === 'CompileSkip' ||
237 // Skip CompileUnexpectedThrow: this is a TS-only artifact logged when a pass
238 // throws instead of recording errors. The Rust port uses Result-based error
239 // propagation, so this event is never emitted and should be excluded from comparison.
240 // kind === 'CompileUnexpectedThrow' ||
241 kind === 'PipelineError'
242 ) {
243 const fnName = (event.fnName as string | null) ?? null;
244 let detail: string;
245 if (kind === 'CompileError') {
246 const d = event.detail as Record<string, unknown> | undefined;
247 if (d) {
248 const lines = [
249 `reason: ${d.reason ?? '(none)'}`,
250 `severity: ${d.severity ?? '(none)'}`,
251 `category: ${d.category ?? '(none)'}`,
252 ];
253 if (d.description) {
254 lines.push(`description: ${d.description}`);
255 }
256 // CompilerDiagnostic stores details in this.options.details (no getter),
257 // while Rust JSON has details as a direct field. Check both paths.
258 const opts = (d as Record<string, unknown>).options as
259 | Record<string, unknown>
260 | undefined;
261 const details = (opts?.details ?? d.details) as
262 | Array<Record<string, unknown>>
263 | undefined;
264 if (details && details.length > 0) {
265 for (const item of details) {
266 if (item.kind === 'error') {
267 lines.push(
268 ` error: ${formatLoc(item.loc)}${
269 item.message ? ': ' + item.message : ''
270 }`,
271 );
272 } else if (item.kind === 'hint') {
273 lines.push(` hint: ${item.message ?? ''}`);
274 }
275 }
276 }
277 // Legacy CompilerErrorDetail has loc directly
278 if (d.loc && !details) {
279 lines.push(`loc: ${formatLoc(d.loc)}`);
280 }
281 detail = lines.join('\n ');
282 } else {
283 detail = '(no detail)';
284 }
285 } else if (kind === 'CompileSkip') {
286 detail = (event.reason as string) ?? '(no reason)';
287 } else {
288 detail = (event.data as string) ?? '(no data)';
289 }
290 log.push({kind: 'event', eventKind: kind, fnName, detail});
291 }
292 },
293 debugLogIRs(entry: CompilerPipelineValue): void {
294 if (reachedTarget) return;
295 if (entry.name === 'EnvironmentConfig') return;
296 if (entry.kind === 'hir') {
297 // TS pipeline emits HIR objects — convert to debug string
298 log.push({
299 kind: 'entry',
300 name: entry.name,
301 value: printDebugHIR(entry.value),
302 });
303 } else if (entry.kind === 'debug') {
304 // Rust pipeline (and TS EnvironmentConfig) emits pre-formatted strings
305 log.push({
306 kind: 'entry',
307 name: entry.name,
308 value: entry.value,
309 });
310 } else if (entry.kind === 'reactive') {
311 log.push({
312 kind: 'entry',
313 name: entry.name,
314 value: printDebugReactiveFunction(entry.value),
315 });
316 } else if (entry.kind === 'ast' && entry.name === passArg) {
317 throw new Error(
318 `TODO: test-rust-port does not yet support '${entry.kind}' log entries ` +
319 `(pass "${entry.name}"). Extend the debugLogIRs handler to support this kind.`,
320 );
321 }
322 if (entry.name === passArg) {
323 reachedTarget = true;
324 }
325 },
326 };
327
328 // Determine parser plugins — scan the leading comment block for pragmas
329 // since @flow is typically on line 3-4 inside a doc comment, not the first line.
330 const headerBlock = source.substring(0, source.indexOf('*/') + 2 || 200);
331 const isFlow = headerBlock.includes('@flow');
332 const isScript = firstLine.includes('@script');
333
334 const plugin = mode === 'ts' ? tsPlugin : rustPlugin;
335
336 const pluginOptions = {
337 ...pragmaOpts,
338 ...(compilationModeArg != null
339 ? {compilationMode: compilationModeArg}
340 : {}),
341 panicThreshold: 'all_errors' as const,
342 logger,
343 };
344
345 // For Flow files, use hermes-parser which supports component syntax.
346 // For TypeScript files, use @babel/parser with typescript+jsx plugins.
347 const babelPlugins: Array<babel.PluginItem> = isFlow
348 ? [hermesParserPlugin, [plugin, pluginOptions]]
349 : [[plugin, pluginOptions]];
350
351 let error: string | null = null;
352 let code: string | null = null;
353 try {
354 const result = babel.transformSync(source, {
355 filename: fixturePath,
356 sourceType: isScript ? 'script' : 'module',
357 ...(isFlow ? {} : {parserOpts: {plugins: ['typescript', 'jsx']}}),
358 plugins: babelPlugins,
359 configFile: false,
360 babelrc: false,
361 });
362 code = result?.code ?? null;
363 } catch (e) {
364 error = e instanceof Error ? e.message : String(e);
365 }
366
367 return {log, code, error};
368 }
369
370 // --- Format a single log item as comparable string ---
371 function formatLogItem(item: LogItem): string {
372 if (item.kind === 'entry') {
373 return `## ${item.name}\n${item.value}`;
374 } else {
375 return `[${item.eventKind}]${item.fnName ? ' ' + item.fnName : ''}: ${
376 item.detail
377 }`;
378 }
379 }
380
381 // --- Format log items as comparable string ---
382 function formatLog(log: LogItem[]): string {
383 return log.map(formatLogItem).join('\n');
384 }
385
386 // --- Normalize opaque IDs ---
387 // Type IDs and Identifier IDs are opaque identifiers whose absolute values
388 // differ between TS and Rust due to differences in allocation order.
389 // We normalize by remapping each unique ID to a sequential index.
390 function normalizeIds(text: string): string {
391 // ID maps are reset at function boundaries (## HIR) because TS uses a global
392 // type counter while Rust creates a fresh Environment per function, so raw IDs
393 // from different functions may collide in Rust but never in TS.
394 let typeMap = new Map<string, number>();
395 let nextTypeId = 0;
396 let idMap = new Map<string, number>();
397 let nextIdId = 0;
398 let declMap = new Map<string, number>();
399 let nextDeclId = 0;
400 let generatedMap = new Map<string, number>();
401 let nextGeneratedId = 0;
402 let blockMap = new Map<string, number>();
403 let nextBlockId = 0;
404 let isFirstHIR = true;
405
406 // Process line-by-line so we can reset maps at function boundaries
407 const lines = text.split('\n');
408 const result = lines.map(line => {
409 // Reset all maps when a new function's compilation starts (## HIR header).
410 // The first HIR entry doesn't need a reset since maps are already empty.
411 if (line === '## HIR') {
412 if (!isFirstHIR) {
413 typeMap = new Map();
414 nextTypeId = 0;
415 idMap = new Map();
416 nextIdId = 0;
417 declMap = new Map();
418 nextDeclId = 0;
419 generatedMap = new Map();
420 nextGeneratedId = 0;
421 blockMap = new Map();
422 nextBlockId = 0;
423 }
424 isFirstHIR = false;
425 }
426
427 return (
428 line
429 // Normalize block IDs (bb0, bb1, ...) — these are auto-incrementing counters
430 // that may differ between TS and Rust due to different block allocation counts
431 // in earlier passes (lowering, IIFE inlining, etc.).
432 .replace(/\bbb(\d+)\b/g, (_match, num) => {
433 const key = `bb:${num}`;
434 if (!blockMap.has(key)) {
435 blockMap.set(key, nextBlockId++);
436 }
437 return `bb${blockMap.get(key)}`;
438 })
439 // Normalize <generated_N> shape IDs — these are auto-incrementing counters
440 // that may differ between TS and Rust due to allocation ordering.
441 .replace(/<generated_(\d+)>/g, (_match, num) => {
442 const key = `generated:${num}`;
443 if (!generatedMap.has(key)) {
444 generatedMap.set(key, nextGeneratedId++);
445 }
446 return `<generated_${generatedMap.get(key)}>`;
447 })
448 .replace(/Type\(\d+\)/g, match => {
449 if (!typeMap.has(match)) {
450 typeMap.set(match, nextTypeId++);
451 }
452 return `Type(${typeMap.get(match)})`;
453 })
454 .replace(/((?:id|declarationId): )(\d+)/g, (_match, prefix, num) => {
455 if (prefix === 'id: ') {
456 const key = `id:${num}`;
457 if (!idMap.has(key)) {
458 idMap.set(key, nextIdId++);
459 }
460 return `${prefix}${idMap.get(key)}`;
461 } else {
462 const key = `decl:${num}`;
463 if (!declMap.has(key)) {
464 declMap.set(key, nextDeclId++);
465 }
466 return `${prefix}${declMap.get(key)}`;
467 }
468 })
469 .replace(/Identifier\((\d+)\)/g, (_match, num) => {
470 const key = `id:${num}`;
471 if (!idMap.has(key)) {
472 idMap.set(key, nextIdId++);
473 }
474 return `Identifier(${idMap.get(key)})`;
475 })
476 // Normalize printed identifiers like "x$5" in error descriptions.
477 // The $N suffix is an opaque IdentifierId that may differ between TS and Rust.
478 .replace(/(\w+)\$(\d+)/g, (_match, name, num) => {
479 const key = `id:${num}`;
480 if (!idMap.has(key)) {
481 idMap.set(key, nextIdId++);
482 }
483 return `${name}\$${idMap.get(key)}`;
484 })
485 // Normalize mutableRange: [N:M] values by stripping them entirely.
486 // In TS, identifier.mutableRange shares a reference with scope.range,
487 // so modifications to scope.range automatically propagate. In Rust,
488 // mutableRange is a copy and diverges from scope.range after certain
489 // passes. Since scope.range is separately displayed and validated,
490 // mutableRange comparison adds noise without catching real bugs.
491 .replace(/mutableRange: \[\d+:\d+\]/g, 'mutableRange: [_:_]')
492 );
493 });
494 return result.join('\n');
495 }
496
497 // --- Simple unified diff ---
498 function unifiedDiff(expected: string, actual: string): string {
499 const expectedLines = expected.split('\n');
500 const actualLines = actual.split('\n');
501 const lines: string[] = [];
502 lines.push(`${RED}--- TypeScript${RESET}`);
503 lines.push(`${GREEN}+++ Rust${RESET}`);
504
505 // Simple line-by-line diff (not a real unified diff, but good enough for debugging)
506 const maxLen = Math.max(expectedLines.length, actualLines.length);
507 let contextStart = -1;
508 for (let i = 0; i < maxLen; i++) {
509 const eLine = i < expectedLines.length ? expectedLines[i] : undefined;
510 const aLine = i < actualLines.length ? actualLines[i] : undefined;
511 if (eLine === aLine) {
512 // matching line — skip (or show as context near diffs)
513 continue;
514 }
515 if (contextStart !== i) {
516 lines.push(`${YELLOW}@@ line ${i + 1} @@${RESET}`);
517 }
518 contextStart = i + 1;
519 if (eLine !== undefined && aLine !== undefined) {
520 lines.push(`${RED}-${eLine}${RESET}`);
521 lines.push(`${GREEN}+${aLine}${RESET}`);
522 } else if (eLine !== undefined) {
523 lines.push(`${RED}-${eLine}${RESET}`);
524 } else if (aLine !== undefined) {
525 lines.push(`${GREEN}+${aLine}${RESET}`);
526 }
527 }
528 return lines.join('\n');
529 }
530
531 // --- Format code with prettier ---
532 async function formatCode(code: string, isFlow: boolean): Promise<string> {
533 return prettier.format(code, {
534 semi: true,
535 parser: isFlow ? 'flow' : 'babel-ts',
536 });
537 }
538
539 // --- Main ---
540 const fixtures = discoverFixtures(fixturesPath);
541 if (fixtures.length === 0) {
542 console.error('No fixtures found at', fixturesPath);
543 process.exit(1);
544 }
545
546 if (!jsonMode && !failuresMode) {
547 console.log(
548 `Testing ${BOLD}${fixtures.length}${RESET} fixtures for pass: ${BOLD}${passArg}${RESET}`,
549 );
550 console.log('');
551 }
552
553 let passed = 0;
554 let failed = 0;
555 let tsHadEntries = false;
556 const failures: Array<{
557 fixture: string;
558 detail: string;
559 }> = [];
560 const failedFixtures: string[] = [];
561
562 // Code comparison tracking
563 let codePassed = 0;
564 let codeFailed = 0;
565 const codeFailures: Array<{
566 fixture: string;
567 detail: string;
568 }> = [];
569 const codeFailedFixtures: string[] = [];
570
571 // Per-pass failure tracking for frontier detection
572 const perPassResults = new Map<string, {passed: number; failed: number}>();
573 for (const pass of PASS_ORDER) {
574 perPassResults.set(pass, {passed: 0, failed: 0});
575 }
576
577 // --- Find the earliest diverging pass for a fixture ---
578 function findDivergencePass(tsLog: LogItem[], rustLog: LogItem[]): string {
579 const maxLen = Math.max(tsLog.length, rustLog.length);
580 for (let i = 0; i < maxLen; i++) {
581 const tsItem = i < tsLog.length ? tsLog[i] : undefined;
582 const rustItem = i < rustLog.length ? rustLog[i] : undefined;
583
584 if (tsItem === undefined || rustItem === undefined) {
585 // One log is shorter — attribute to the pass of the last available entry
586 const item = tsItem ?? rustItem;
587 if (item && item.kind === 'entry') {
588 return item.name;
589 }
590 // For events, attribute to the preceding entry's pass
591 for (let j = i - 1; j >= 0; j--) {
592 const prev = tsLog[j] ?? rustLog[j];
593 if (prev && prev.kind === 'entry') return prev.name;
594 }
595 // No preceding entry — attribute to first pass
596 return PASS_ORDER[0];
597 }
598
599 const tsFormatted = normalizeIds(formatLogItem(tsItem));
600 const rustFormatted = normalizeIds(formatLogItem(rustItem));
601 if (tsFormatted !== rustFormatted) {
602 if (tsItem.kind === 'entry') {
603 return tsItem.name;
604 }
605 // For events, find the most recent entry pass
606 for (let j = i - 1; j >= 0; j--) {
607 if (tsLog[j] && tsLog[j].kind === 'entry') {
608 return (tsLog[j] as LogEntry).name;
609 }
610 }
611 // No preceding entry — attribute to first pass
612 return PASS_ORDER[0];
613 }
614 }
615 // No divergence found (shouldn't happen since caller verified logs differ)
616 return PASS_ORDER[0];
617 }
618
619 (async () => {
620 for (const fixturePath of fixtures) {
621 const relPath = path.relative(REPO_ROOT, fixturePath);
622 const ts = compileFixture('ts', fixturePath);
623 const rust = compileFixture('rust', fixturePath);
624
625 // Check if TS produced any entries for the target pass
626 if (ts.log.some(item => item.kind === 'entry' && item.name === passArg)) {
627 tsHadEntries = true;
628 }
629
630 // Compare the full log (entries + events in order, up to target pass)
631 const tsFormatted = normalizeIds(formatLog(ts.log));
632 const rustFormatted = normalizeIds(formatLog(rust.log));
633
634 // When both compilers throw an error (same final outcome), tolerate
635 // differences in debug output. Rust's fault-tolerant pipeline emits
636 // partial debug IR before reaching the same fatal error that TS's
637 // throw-immediate approach reports with no debug output at all.
638 const bothErrored =
639 ts.error != null &&
640 rust.error != null &&
641 ts.code == null &&
642 rust.code == null;
643
644 if (tsFormatted === rustFormatted || bothErrored) {
645 passed++;
646 // Count as passed for all passes that appeared in the log
647 const seenPasses = new Set<string>();
648 for (const item of ts.log) {
649 if (item.kind === 'entry') seenPasses.add(item.name);
650 }
651 for (const pass of seenPasses) {
652 const stats = perPassResults.get(pass);
653 if (stats) stats.passed++;
654 }
655 } else {
656 failed++;
657 // Find which pass diverged and attribute the failure
658 const divergePass = findDivergencePass(ts.log, rust.log);
659 const stats = perPassResults.get(divergePass);
660 if (stats) stats.failed++;
661 // Count passes before divergence as passed
662 const seenPasses: string[] = [];
663 for (const item of ts.log) {
664 if (item.kind === 'entry' && item.name !== divergePass) {
665 seenPasses.push(item.name);
666 } else if (item.kind === 'entry') {
667 break;
668 }
669 }
670 for (const pass of seenPasses) {
671 const stats = perPassResults.get(pass);
672 if (stats) stats.passed++;
673 }
674
675 failedFixtures.push(relPath);
676 if (limitArg === 0 || failures.length < limitArg) {
677 failures.push({
678 fixture: relPath,
679 detail: unifiedDiff(tsFormatted, rustFormatted),
680 });
681 }
682 }
683
684 // Compare final code output
685 const source = fs.readFileSync(fixturePath, 'utf8');
686 const headerBlock = source.substring(0, source.indexOf('*/') + 2 || 200);
687 const isFlow = headerBlock.includes('@flow');
688 try {
689 const tsCode = await formatCode(ts.code ?? '', isFlow);
690 const rustCode = await formatCode(rust.code ?? '', isFlow);
691 if (tsCode === rustCode) {
692 codePassed++;
693 } else {
694 codeFailed++;
695 codeFailedFixtures.push(relPath);
696 if (limitArg === 0 || codeFailures.length < limitArg) {
697 codeFailures.push({
698 fixture: relPath,
699 detail: unifiedDiff(tsCode, rustCode),
700 });
701 }
702 }
703 } catch {
704 // If prettier fails, treat as a code mismatch
705 const tsCode = ts.code ?? '';
706 const rustCode = rust.code ?? '';
707 if (tsCode === rustCode) {
708 codePassed++;
709 } else {
710 codeFailed++;
711 codeFailedFixtures.push(relPath);
712 if (limitArg === 0 || codeFailures.length < limitArg) {
713 codeFailures.push({
714 fixture: relPath,
715 detail: unifiedDiff(tsCode, rustCode),
716 });
717 }
718 }
719 }
720 }
721
722 // --- Check for invalid pass name ---
723 if (!tsHadEntries) {
724 console.error(
725 `${RED}ERROR: TypeScript compiler produced no log entries for pass "${passArg}" across all fixtures.${RESET}`,
726 );
727 console.error('This likely means the pass name is incorrect.');
728 console.error('');
729 console.error(
730 'Pass names must match exactly as used in Pipeline.ts, e.g.:',
731 );
732 console.error(
733 ' HIR, PruneMaybeThrows, SSA, InferTypes, AnalyseFunctions, ...',
734 );
735 process.exit(1);
736 }
737
738 // --- Compute frontier ---
739 let frontier: string | null = null;
740 for (const pass of PASS_ORDER) {
741 const stats = perPassResults.get(pass);
742 if (stats && stats.failed > 0) {
743 frontier = pass;
744 break;
745 }
746 }
747
748 // --- Summary ---
749 const total = fixtures.length;
750 let frontierStr: string;
751 if (frontier != null) {
752 frontierStr = frontier;
753 } else if (passArgRaw) {
754 // Explicit pass arg given and it's clean — we can't know the global frontier
755 frontierStr = `${passArg} passes, rerun without a pass name to find frontier`;
756 } else {
757 frontierStr = 'none';
758 }
759
760 // --- Per-pass breakdown ---
761 const perPassParts: string[] = [];
762 for (const pass of PASS_ORDER) {
763 const stats = perPassResults.get(pass);
764 if (stats && (stats.passed > 0 || stats.failed > 0)) {
765 perPassParts.push(
766 `${pass} ${stats.passed}/${stats.passed + stats.failed}`,
767 );
768 }
769 }
770
771 // --- Output ---
772 if (jsonMode) {
773 const output = {
774 pass: passArg,
775 autoDetected: !passArgRaw,
776 total,
777 passed,
778 failed,
779 frontier: frontier,
780 perPass: Object.fromEntries(
781 [...perPassResults.entries()].filter(
782 ([_, v]) => v.passed > 0 || v.failed > 0,
783 ),
784 ),
785 failures: failedFixtures,
786 codePassed,
787 codeFailed,
788 codeFailures: codeFailedFixtures,
789 };
790 console.log(JSON.stringify(output));
791 } else if (failuresMode) {
792 for (const f of failedFixtures) {
793 console.log(f);
794 }
795 } else {
796 const summaryColor = failed === 0 ? GREEN : RED;
797 const summaryLine = `${summaryColor}Results: ${passed} passed, ${failed} failed (${total} total), frontier: ${frontierStr}${RESET}`;
798 const codeSummaryColor = codeFailed === 0 ? GREEN : RED;
799 const codeSummaryLine = `${codeSummaryColor}Code: ${codePassed} passed, ${codeFailed} failed (${total} total)${RESET}`;
800
801 // Print summary first
802 console.log(summaryLine);
803 console.log(codeSummaryLine);
804 if (perPassParts.length > 0) {
805 console.log(`Per-pass: ${perPassParts.join(', ')}`);
806 }
807 console.log('');
808
809 // --- Show log failures ---
810 for (const failure of failures) {
811 console.log(`${RED}FAIL${RESET} ${failure.fixture}`);
812 console.log(failure.detail);
813 console.log('');
814 }
815
816 // --- Show code failures ---
817 if (codeFailures.length > 0) {
818 console.log(`${BOLD}--- Code comparison failures ---${RESET}`);
819 console.log('');
820 for (const failure of codeFailures) {
821 console.log(`${RED}FAIL (code)${RESET} ${failure.fixture}`);
822 console.log(failure.detail);
823 console.log('');
824 }
825 }
826
827 // --- Summary again (so tail -1 works) ---
828 console.log('---');
829 if (failures.length < failed) {
830 console.log(
831 `${DIM} (showing first ${failures.length} of ${failed} log failures)${RESET}`,
832 );
833 }
834 if (codeFailures.length < codeFailed) {
835 console.log(
836 `${DIM} (showing first ${codeFailures.length} of ${codeFailed} code failures)${RESET}`,
837 );
838 }
839 console.log(summaryLine);
840 console.log(codeSummaryLine);
841 }
842
843 process.exit(failed > 0 || codeFailed > 0 ? 1 : 0);
844 })();