| 1 | # Rust Port Step 2: Testing Infrastructure |
| 2 | |
| 3 | ## Goal |
| 4 | |
| 5 | Create a testing infrastructure that validates the Rust port produces identical results to the TypeScript compiler at every stage of the pipeline. The port proceeds incrementally — one pass at a time — so the test infrastructure must support running the pipeline up to any specified pass and comparing the intermediate state between TS and Rust. |
| 6 | |
| 7 | **Current status**: M1, M2, M3 implemented. All Rust tests expected to fail (todo!() stubs). Next step: port lower() (M4). |
| 8 | |
| 9 | **Known issues — resolved:** |
| 10 | - TS binary rewritten to call `compile()` directly (bypasses `transformFromAstSync` + `BabelPluginReactCompiler`). Individual pass functions aren't exported from dist, so logger-based capture is still used, but the Babel plugin orchestration layer is bypassed. (done) |
| 11 | - `debug_error` renamed to `format_errors` (done). `CompilerError` type name kept as-is since `CompilerDiagnostic` already exists as a different type in the diagnostics crate. |
| 12 | - Both TS and Rust now print `returnTypeAnnotation` in debug output. (done) |
| 13 | - `mark_predecessors` fallthrough handling: VERIFIED — matches TS `eachTerminalSuccessor` (does not include fallthroughs, correct). |
| 14 | - `GotoVariant::Break` usage in `remove_unnecessary_try_catch` and `remove_dead_do_while_statements`: VERIFIED — matches TS. |
| 15 | - All collection types migrated to `IndexMap`/`IndexSet` (done). |
| 16 | |
| 17 | **Known issues — remaining:** |
| 18 | - Debug output format: TS and Rust debug printers produce different output formats. Both need to converge on Rust `Debug`-style nested format. This will be addressed when the Rust lowering is implemented and output comparison becomes possible. |
| 19 | - TS debug printer collects identifiers/functions per-function; should print all from environment (matching Rust). Requires access to the Environment from TS, which is not currently exposed through the logger API. |
| 20 | - Rust binary config: `Environment::new()` needs matching config (`compilationMode: "all"`, `target: "19"`, etc.) — requires adding config support to the Rust Environment type. |
| 21 | - Error format output between TS and Rust has not been validated for byte-identical output. Will be validated when lowering produces real output. |
| 22 | |
| 23 | --- |
| 24 | |
| 25 | ## Overview |
| 26 | |
| 27 | ``` |
| 28 | fixture.js |
| 29 | │ |
| 30 | ┌──────────────────┴──────────────────┐ |
| 31 | ▼ ▼ |
| 32 | TS test binary @babel/parser ──> AST JSON |
| 33 | (parse with Babel, + Scope JSON |
| 34 | compile up to │ |
| 35 | target pass) ▼ |
| 36 | │ Rust test binary |
| 37 | │ (compile up to |
| 38 | │ target pass) |
| 39 | ▼ │ |
| 40 | TS debug output Rust debug output |
| 41 | │ │ |
| 42 | └──────────────── diff ───────────────────┘ |
| 43 | ``` |
| 44 | |
| 45 | A single entrypoint script discovers fixtures, runs both the TS and Rust binaries on each fixture, and diffs their output. The inputs differ slightly: the TS binary takes the original fixture path (parsing with Babel internally, since the TS compiler expects a Babel `NodePath`), while the Rust binary takes pre-parsed AST JSON + Scope JSON. Both produce the same detailed debug representation of the compiler state after the target pass. |
| 46 | |
| 47 | --- |
| 48 | |
| 49 | ## Entrypoint |
| 50 | |
| 51 | ### `compiler/scripts/test-rust-port.sh <pass> [<dir>]` |
| 52 | |
| 53 | ```bash |
| 54 | #!/bin/bash |
| 55 | set -e |
| 56 | |
| 57 | PASS="$1" # Required: name of the compiler pass to run up to |
| 58 | DIR="$2" # Optional: fixture root directory (default: compiler fixtures) |
| 59 | |
| 60 | # 1. Parse fixtures into AST JSON + Scope JSON (reuses existing scripts) |
| 61 | # 2. Build TS test binary (if needed) |
| 62 | # 3. Build Rust test binary (cargo build) |
| 63 | # 4. For each fixture: |
| 64 | # a. Run TS binary: node compiler/scripts/ts-compile-fixture.mjs <pass> <fixture.js> |
| 65 | # b. Run Rust binary: compiler/target/debug/test-rust-port <pass> <ast.json> <scope.json> |
| 66 | # c. Diff the outputs |
| 67 | # 5. Report results (pass/fail counts, first N diffs) |
| 68 | ``` |
| 69 | |
| 70 | **Arguments:** |
| 71 | - `<pass>` — The name of the compiler pass to run up to. Uses the same names as the `log()` calls in Pipeline.ts (e.g., `HIR`, `SSA`, `InferTypes`, `InferMutationAliasingEffects`). See [Pass Names](#pass-names) below. |
| 72 | - `[<dir>]` — Optional root directory of fixtures. Scans for `**/*.{js,jsx,ts,tsx}` files. Defaults to `compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures`. |
| 73 | |
| 74 | **Output format:** Same style as `test-babel-ast.sh` — show the first 5 failures with colored unified diffs (using `diff` or the `similar` crate pattern), then a summary count. Example: |
| 75 | |
| 76 | ``` |
| 77 | Testing 1714 fixtures up to pass: InferTypes |
| 78 | |
| 79 | FAIL compiler/simple.js |
| 80 | --- TypeScript |
| 81 | +++ Rust |
| 82 | @@ -3,7 +3,7 @@ |
| 83 | bb0 (block): |
| 84 | [1] $0:T = LoadGlobal global:console |
| 85 | - [2] $1:TFunction<BuiltInConsoleLog> = PropertyLoad $0.log |
| 86 | + [2] $1:T = PropertyLoad $0.log |
| 87 | |
| 88 | ... (first 50 lines of diff) |
| 89 | |
| 90 | Results: 1710 passed, 4 failed (1714 total) |
| 91 | ``` |
| 92 | |
| 93 | --- |
| 94 | |
| 95 | ## Pass Names |
| 96 | |
| 97 | These are the valid `<pass>` arguments, matching the `log()` name strings in Pipeline.ts. The test binaries run all passes up to and including the named pass. |
| 98 | |
| 99 | ### HIR Phase |
| 100 | |
| 101 | | Pass Name | Pipeline.ts Function | |
| 102 | |-----------|---------------------| |
| 103 | | `HIR` | `lower()` | |
| 104 | | `PruneMaybeThrows` | `pruneMaybeThrows()` (first call) | |
| 105 | | `DropManualMemoization` | `dropManualMemoization()` | |
| 106 | | `InlineIIFEs` | `inlineImmediatelyInvokedFunctionExpressions()` | |
| 107 | | `MergeConsecutiveBlocks` | `mergeConsecutiveBlocks()` | |
| 108 | | `SSA` | `enterSSA()` | |
| 109 | | `EliminateRedundantPhi` | `eliminateRedundantPhi()` | |
| 110 | | `ConstantPropagation` | `constantPropagation()` | |
| 111 | | `InferTypes` | `inferTypes()` | |
| 112 | | `OptimizePropsMethodCalls` | `optimizePropsMethodCalls()` | |
| 113 | | `AnalyseFunctions` | `analyseFunctions()` | |
| 114 | | `InferMutationAliasingEffects` | `inferMutationAliasingEffects()` | |
| 115 | | `OptimizeForSSR` | `optimizeForSSR()` | |
| 116 | | `DeadCodeElimination` | `deadCodeElimination()` | |
| 117 | | `PruneMaybeThrows2` | `pruneMaybeThrows()` (second call) | |
| 118 | | `InferMutationAliasingRanges` | `inferMutationAliasingRanges()` | |
| 119 | | `InferReactivePlaces` | `inferReactivePlaces()` | |
| 120 | | `RewriteInstructionKinds` | `rewriteInstructionKindsBasedOnReassignment()` | |
| 121 | | `InferReactiveScopeVariables` | `inferReactiveScopeVariables()` | |
| 122 | | `MemoizeFbtOperands` | `memoizeFbtAndMacroOperandsInSameScope()` | |
| 123 | | `NameAnonymousFunctions` | `nameAnonymousFunctions()` | |
| 124 | | `OutlineFunctions` | `outlineFunctions()` | |
| 125 | | `AlignMethodCallScopes` | `alignMethodCallScopes()` | |
| 126 | | `AlignObjectMethodScopes` | `alignObjectMethodScopes()` | |
| 127 | | `PruneUnusedLabelsHIR` | `pruneUnusedLabelsHIR()` | |
| 128 | | `AlignReactiveScopesToBlockScopes` | `alignReactiveScopesToBlockScopesHIR()` | |
| 129 | | `MergeOverlappingReactiveScopes` | `mergeOverlappingReactiveScopesHIR()` | |
| 130 | | `BuildReactiveScopeTerminals` | `buildReactiveScopeTerminalsHIR()` | |
| 131 | | `FlattenReactiveLoops` | `flattenReactiveLoopsHIR()` | |
| 132 | | `FlattenScopesWithHooksOrUse` | `flattenScopesWithHooksOrUseHIR()` | |
| 133 | | `PropagateScopeDependencies` | `propagateScopeDependenciesHIR()` | |
| 134 | |
| 135 | ### Reactive Phase |
| 136 | |
| 137 | | Pass Name | Pipeline.ts Function | |
| 138 | |-----------|---------------------| |
| 139 | | `BuildReactiveFunction` | `buildReactiveFunction()` | |
| 140 | | `PruneUnusedLabels` | `pruneUnusedLabels()` | |
| 141 | | `PruneNonEscapingScopes` | `pruneNonEscapingScopes()` | |
| 142 | | `PruneNonReactiveDependencies` | `pruneNonReactiveDependencies()` | |
| 143 | | `PruneUnusedScopes` | `pruneUnusedScopes()` | |
| 144 | | `MergeReactiveScopesThatInvalidateTogether` | `mergeReactiveScopesThatInvalidateTogether()` | |
| 145 | | `PruneAlwaysInvalidatingScopes` | `pruneAlwaysInvalidatingScopes()` | |
| 146 | | `PropagateEarlyReturns` | `propagateEarlyReturns()` | |
| 147 | | `PruneUnusedLValues` | `pruneUnusedLValues()` | |
| 148 | | `PromoteUsedTemporaries` | `promoteUsedTemporaries()` | |
| 149 | | `ExtractScopeDeclarationsFromDestructuring` | `extractScopeDeclarationsFromDestructuring()` | |
| 150 | | `StabilizeBlockIds` | `stabilizeBlockIds()` | |
| 151 | | `RenameVariables` | `renameVariables()` | |
| 152 | | `PruneHoistedContexts` | `pruneHoistedContexts()` | |
| 153 | | `Codegen` | `codegenFunction()` | |
| 154 | |
| 155 | --- |
| 156 | |
| 157 | ## TS Test Binary |
| 158 | |
| 159 | ### `compiler/scripts/ts-compile-fixture.mjs` |
| 160 | |
| 161 | A Node.js script that takes the original fixture path, parses it with Babel, and runs the compiler pipeline up to the target pass. It uses the real Babel `NodePath` and the existing `lower()` function directly — no JSON intermediary on the TS side. |
| 162 | |
| 163 | **Interface:** |
| 164 | ``` |
| 165 | node compiler/scripts/ts-compile-fixture.mjs <pass> <fixture-path> |
| 166 | ``` |
| 167 | |
| 168 | **Outputs to stdout:** |
| 169 | - On success: detailed debug representation of the HIR or ReactiveFunction, including outlined functions (see [Debug Output Format](#debug-output-format)) |
| 170 | - On error (thrown CompilerError): formatted error with full diagnostic details |
| 171 | - On accumulated errors (env has errors at the target pass): formatted accumulated errors — these take priority over the debug HIR output |
| 172 | |
| 173 | **Implementation approach:** |
| 174 | |
| 175 | ```typescript |
| 176 | import { parse } from '@babel/parser'; |
| 177 | import traverse from '@babel/traverse'; |
| 178 | import { lower } from '../packages/babel-plugin-react-compiler/src/HIR/BuildHIR'; |
| 179 | // ... import all passes |
| 180 | |
| 181 | function main() { |
| 182 | const [pass, fixturePath] = process.argv.slice(2); |
| 183 | const source = fs.readFileSync(fixturePath, 'utf8'); |
| 184 | |
| 185 | // Parse with Babel to get a real NodePath (same as production compiler) |
| 186 | const ast = parse(source, { sourceType: 'module', plugins: [...], errorRecovery: true }); |
| 187 | let functionPath; |
| 188 | traverse(ast, { |
| 189 | 'FunctionDeclaration|ArrowFunctionExpression|FunctionExpression'(path) { |
| 190 | functionPath = path; |
| 191 | path.stop(); |
| 192 | } |
| 193 | }); |
| 194 | |
| 195 | const env = createEnvironment(/* default config, with pragma overrides from source */); |
| 196 | |
| 197 | try { |
| 198 | const hir = lower(functionPath, env); |
| 199 | if (pass === 'HIR') { |
| 200 | if (env.hasErrors()) { |
| 201 | return printFormattedErrors(env.errors()); |
| 202 | } |
| 203 | return printDebugHIR(hir, env); // includes outlined functions |
| 204 | } |
| 205 | |
| 206 | pruneMaybeThrows(hir); |
| 207 | if (pass === 'PruneMaybeThrows') { |
| 208 | if (env.hasErrors()) { |
| 209 | return printFormattedErrors(env.errors()); |
| 210 | } |
| 211 | return printDebugHIR(hir, env); |
| 212 | } |
| 213 | |
| 214 | // ... each pass in order, with the same pattern: |
| 215 | // somePass(hir); |
| 216 | // if (pass === 'PassName') { |
| 217 | // if (env.hasErrors()) { |
| 218 | // return printFormattedErrors(env.errors()); |
| 219 | // } |
| 220 | // return printDebugHIR(hir, env); |
| 221 | // } |
| 222 | |
| 223 | } catch (e) { |
| 224 | if (e instanceof CompilerError) { |
| 225 | return printFormattedError(e); |
| 226 | } |
| 227 | throw e; // re-throw non-compiler errors |
| 228 | } |
| 229 | } |
| 230 | ``` |
| 231 | |
| 232 | **Key design decisions:** |
| 233 | |
| 234 | 1. **Independent pipeline**: Does NOT call `runWithEnvironment()`. Implements the pass sequence independently, exactly mirroring the Rust binary. This ensures we're testing the pass behavior, not the pipeline orchestration. |
| 235 | |
| 236 | 2. **Fixture path input, real Babel parse**: The TS binary takes the original fixture path and parses it with `@babel/parser` + `@babel/traverse` to get a real `NodePath` — reusing the existing `lower()` directly. This means the TS and Rust sides have slightly different inputs (fixture path vs. AST JSON + Scope JSON), but that's fine: the AST JSON is validated by the step 1 round-trip test, and the shared contract is the debug output format, not the input format. |
| 237 | |
| 238 | 3. **Validation passes**: Validation passes that run between transform passes (e.g., `validateContextVariableLValues`, `validateHooksUsage`) are included in the pipeline. If a validation pass records errors or throws, that affects the output. The test compares the full behavior including validation. |
| 239 | |
| 240 | 4. **Conditional passes**: Passes behind feature flags (e.g., `enableDropManualMemoization`, `enableJsxOutlining`) use the same default config in both TS and Rust. The config is fixed for testing — not configurable per-fixture (initially). If we later need per-fixture config, the fixture's pragma comment can be parsed. |
| 241 | |
| 242 | 5. **Config pragmas**: Parse the first line of the original fixture source for config pragmas (e.g., `// @enableJsxOutlining`), same as the snap test runner does. Apply these to the environment config before running passes. This ensures feature-flag-gated passes are tested correctly. |
| 243 | |
| 244 | --- |
| 245 | |
| 246 | ## Rust Test Binary |
| 247 | |
| 248 | ### `compiler/crates/react_compiler/src/bin/test_rust_port.rs` |
| 249 | |
| 250 | A Rust binary in the main compiler crate that mirrors the TS test binary exactly. |
| 251 | |
| 252 | **Interface:** |
| 253 | ``` |
| 254 | compiler/target/debug/test-rust-port <pass> <ast.json> <scope.json> |
| 255 | ``` |
| 256 | |
| 257 | **Same output contract as the TS binary** — identical debug format on stdout. |
| 258 | |
| 259 | **Implementation:** |
| 260 | |
| 261 | ```rust |
| 262 | fn main() -> Result<(), Box<dyn Error>> { |
| 263 | let args: Vec<String> = std::env::args().collect(); |
| 264 | let pass = &args[1]; |
| 265 | let ast_json = fs::read_to_string(&args[2])?; |
| 266 | let scope_json = fs::read_to_string(&args[3])?; |
| 267 | |
| 268 | let ast: react_compiler_ast::File = serde_json::from_str(&ast_json)?; |
| 269 | let scope: react_compiler_ast::ScopeInfo = serde_json::from_str(&scope_json)?; |
| 270 | |
| 271 | let mut env = Environment::new(/* config matching TS binary: compilationMode="all", target="19", etc. */); |
| 272 | |
| 273 | match run_pipeline(pass, &ast, &scope, &mut env) { |
| 274 | Ok(output) => { |
| 275 | print!("{}", output); |
| 276 | } |
| 277 | Err(error) => { |
| 278 | print!("{}", format_errors(&error)); |
| 279 | } |
| 280 | } |
| 281 | |
| 282 | Ok(()) |
| 283 | } |
| 284 | |
| 285 | fn run_pipeline( |
| 286 | target_pass: &str, |
| 287 | ast: &File, |
| 288 | scope: &ScopeInfo, |
| 289 | env: &mut Environment, |
| 290 | ) -> Result<String, CompilerError> { |
| 291 | let mut hir = lower(ast, scope, env)?; |
| 292 | if target_pass == "HIR" { |
| 293 | if env.has_errors() { |
| 294 | return Ok(format_errors(env.errors())); |
| 295 | } |
| 296 | return Ok(debug_hir(&hir, env)); // includes outlined functions |
| 297 | } |
| 298 | |
| 299 | prune_maybe_throws(&mut hir); |
| 300 | if target_pass == "PruneMaybeThrows" { |
| 301 | if env.has_errors() { |
| 302 | return Ok(format_errors(env.errors())); |
| 303 | } |
| 304 | return Ok(debug_hir(&hir, env)); |
| 305 | } |
| 306 | |
| 307 | // ... each pass in order, with the same pattern: |
| 308 | // some_pass(&mut hir, env)?; |
| 309 | // if target_pass == "PassName" { |
| 310 | // if env.has_errors() { |
| 311 | // return Ok(format_errors(env.errors())); |
| 312 | // } |
| 313 | // return Ok(debug_hir(&hir, env)); |
| 314 | // } |
| 315 | } |
| 316 | ``` |
| 317 | |
| 318 | **Crate structure**: The test binary lives in whatever crate contains the compiler pipeline (likely `react_compiler` or similar — to be created as passes are ported). It depends on `react_compiler_ast` for the input types. |
| 319 | |
| 320 | --- |
| 321 | |
| 322 | ## Debug Output Format |
| 323 | |
| 324 | ### Why Not PrintHIR |
| 325 | |
| 326 | The existing `PrintHIR.ts` omits important details: |
| 327 | - Mutable ranges hidden when `end <= start + 1` |
| 328 | - `DEBUG_MUTABLE_RANGES` flag defaults to `false` |
| 329 | - Type information omitted for unresolved types |
| 330 | - Source locations not printed |
| 331 | - UnaryExpression doesn't print operator |
| 332 | - Scope details minimal (just `_@scopeId` suffix) |
| 333 | - DeclarationId not printed |
| 334 | - Identifier's full type structure not shown |
| 335 | |
| 336 | For port validation, we need a representation that prints **everything** — similar to Rust's `#[derive(Debug)]` output. Every field of every identifier, every scope, every instruction must be visible so any divergence between TS and Rust is immediately caught. |
| 337 | |
| 338 | ### Debug HIR Format |
| 339 | |
| 340 | A structured text format that prints every field of the HIR, **including outlined functions**. Both TS and Rust must produce byte-identical output for the same HIR state. The format uses **Rust `Debug` trait style** — nested struct/enum formatting with curly braces and named fields. |
| 341 | |
| 342 | **Design principles:** |
| 343 | - **Rust `Debug`-style format**: Output looks like Rust's `#[derive(Debug)]` output — `StructName { field: value, ... }` for structs, `EnumVariant { ... }` for enum variants |
| 344 | - Print every field, even defaults/empty values (no elision) |
| 345 | - Deterministic ordering (blocks in RPO, instructions in order, maps by sorted key) |
| 346 | - Stable identifiers (use numeric IDs, not memory addresses) |
| 347 | - Indent with 2 spaces for nesting |
| 348 | - Include all identifiers from the environment (not just those referenced in the function) |
| 349 | - Include all outlined functions from the environment (not just those referenced in the function), each printed with the same format, numbered sequentially (`Function #0`, `Function #1`, etc.) |
| 350 | |
| 351 | **Example output after `InferTypes`:** |
| 352 | |
| 353 | ``` |
| 354 | Function #0: |
| 355 | HirFunction { |
| 356 | id: "example", |
| 357 | params: [ |
| 358 | Place { identifier: $3, effect: Read, reactive: false, loc: 1:20-1:21 }, |
| 359 | ], |
| 360 | returns: Place { identifier: $0, effect: Read, reactive: false, loc: 0:0-0:0 }, |
| 361 | returnTypeAnnotation: None, |
| 362 | context: [], |
| 363 | aliasing_effects: None, |
| 364 | } |
| 365 | |
| 366 | Identifiers: |
| 367 | $0: Identifier { id: 0, declaration_id: None, name: None, mutable_range: [0, 0], scope: None, type: Type, loc: 0:0-0:0 } |
| 368 | $1: Identifier { id: 1, declaration_id: 0, name: Some("x"), mutable_range: [1, 5], scope: None, type: TFunction(BuiltInArray), loc: 1:20-1:21 } |
| 369 | ... |
| 370 | |
| 371 | Blocks: |
| 372 | bb0 (block): |
| 373 | preds: [] |
| 374 | phis: [] |
| 375 | instructions: |
| 376 | Instruction { id: EvaluationOrder(1), lvalue: Place { identifier: $1, effect: Mutate, reactive: false, loc: 1:0-1:10 }, value: LoadGlobal { name: "console" }, effects: None, loc: 1:0-1:10 } |
| 377 | ... |
| 378 | terminal: Return { value: Place { identifier: $2, effect: Read, reactive: false, loc: 5:2-5:10 }, loc: 5:2-5:10 } |
| 379 | ``` |
| 380 | |
| 381 | Note: This is Rust `Debug`-style formatting. Field names use `snake_case`. Optional values use `None`/`Some(...)`. Enum variants use `VariantName { ... }` or `VariantName(...)` syntax. |
| 382 | |
| 383 | ### Debug Reactive Function Format |
| 384 | |
| 385 | Same approach for `ReactiveFunction` — print the full tree structure with all fields visible. |
| 386 | |
| 387 | ### Debug Error Format |
| 388 | |
| 389 | When compilation produces errors (thrown or accumulated), output a structured error representation: |
| 390 | |
| 391 | ``` |
| 392 | Error: |
| 393 | category: InvalidReact |
| 394 | severity: InvalidReact |
| 395 | reason: "Hooks must be called unconditionally" |
| 396 | description: "Cannot call a hook (useState) conditionally" |
| 397 | loc: 3:4-3:20 |
| 398 | suggestions: [] |
| 399 | details: |
| 400 | - severity: InvalidReact |
| 401 | reason: "This is a conditional" |
| 402 | loc: 2:2-5:3 |
| 403 | ``` |
| 404 | |
| 405 | All fields of `CompilerDiagnostic` are included — reason, description, loc, severity, category, suggestions (with text + loc), and any nested detail diagnostics. |
| 406 | |
| 407 | ### Implementation Strategy |
| 408 | |
| 409 | **TS side**: Create a `debugHIR(hir: HIRFunction, env: Environment): string` function in the test script that walks the HIR and prints everything using Rust `Debug`-style formatting (`StructName { field: value, ... }`). Prints all identifiers and outlined functions from the environment (not just those referenced by the function). This is NOT a modification to the existing `PrintHIR.ts` — it's a separate debug printer in the test infrastructure. Must also print `returnTypeAnnotation`. |
| 410 | |
| 411 | **Rust side**: Implement a custom `debug_hir()` function that produces Rust `Debug`-style output. While this is similar to `#[derive(Debug)]`, a custom implementation is needed for consistent field ordering and formatting. Prints all identifiers and functions from the environment. |
| 412 | |
| 413 | **Shared format specification**: The format is defined once (in this document) and both sides implement it. The round-trip test validates they produce identical output. Both sides must print `returnTypeAnnotation`. |
| 414 | |
| 415 | --- |
| 416 | |
| 417 | ## Error Handling in Test Binaries |
| 418 | |
| 419 | Both test binaries handle errors uniformly: every pass checkpoint (each `if (pass === ...)` check) first inspects the environment for accumulated errors. If errors are present, the formatted errors are returned **instead of** the debug HIR. This ensures that error output is always comparable between TS and Rust. |
| 420 | |
| 421 | ### Thrown Errors (try/catch in TS, Result::Err in Rust) |
| 422 | |
| 423 | - `CompilerError.invariant()` — truly unexpected state |
| 424 | - `CompilerError.throwTodo()` — unsupported but known pattern |
| 425 | - `CompilerError.throw*()` — other throwing methods |
| 426 | |
| 427 | In TS, the entire pipeline is wrapped in a `try/catch`. When a `CompilerError` is caught, the test binary prints the formatted error. Non-`CompilerError` exceptions re-throw (test binary crashes with non-zero exit code, treated as a test failure). |
| 428 | |
| 429 | In Rust, passes return `Result<_, CompilerDiagnostic>`. The `Err` case is handled at the top level by printing the formatted error. Panics (e.g., from `.unwrap()`) crash the binary with a non-zero exit code, treated as a test failure. |
| 430 | |
| 431 | ### Accumulated Errors (env.hasErrors()) |
| 432 | |
| 433 | Errors recorded via `env.recordError()` / `env.logErrors()` accumulate on the environment. At every pass checkpoint, the test binary checks `env.hasErrors()` **before** printing the debug HIR. If errors are present, the formatted error list is printed instead of the HIR — the pipeline does not continue past the target pass when errors exist. |
| 434 | |
| 435 | This means each pass checkpoint follows the same pattern: |
| 436 | |
| 437 | ``` |
| 438 | run_pass(hir); |
| 439 | if target_pass == "PassName": |
| 440 | if env.has_errors(): |
| 441 | return format_errors(env.errors()) // errors take priority |
| 442 | return debug_hir(hir, env) // no errors → print HIR |
| 443 | ``` |
| 444 | |
| 445 | ### Comparison Rules |
| 446 | |
| 447 | 1. If TS throws and Rust returns Err: compare the formatted error output |
| 448 | 2. If TS succeeds and Rust succeeds: compare the debug HIR/reactive output (including outlined functions) |
| 449 | 3. If TS throws and Rust succeeds (or vice versa): test fails (mismatch) |
| 450 | 4. If TS has accumulated errors and Rust doesn't (or vice versa): test fails |
| 451 | 5. If both have accumulated errors at the same pass: compare the formatted error lists |
| 452 | |
| 453 | --- |
| 454 | |
| 455 | ## Fixture Discovery |
| 456 | |
| 457 | The test script scans the fixture directory for `**/*.{js,jsx,ts,tsx}` files, matching the pattern used by `test-babel-ast.sh`. For each fixture: |
| 458 | |
| 459 | 1. Parse with Babel to produce AST JSON + Scope JSON (reusing `babel-ast-to-json.mjs` and `babel-scope-to-json.mjs`) |
| 460 | 2. Skip fixtures that fail to parse (`.parse-error` marker) |
| 461 | 3. Run both TS and Rust binaries |
| 462 | 4. Diff outputs |
| 463 | |
| 464 | **Fixture paths**: The test script passes the original fixture path to the TS binary (which handles its own parsing) and the pre-parsed AST/Scope JSON paths to the Rust binary. |
| 465 | |
| 466 | --- |
| 467 | |
| 468 | ## Input Asymmetry: Fixture Path vs. AST JSON |
| 469 | |
| 470 | The TS and Rust test binaries take different inputs: |
| 471 | |
| 472 | - **TS binary**: Takes the original fixture path. Parses with `@babel/parser`, runs `@babel/traverse` to build scope info, and calls the existing `lower()` with a real Babel `NodePath`. This is the simplest approach — `lower()` is deeply entangled with Babel's `NodePath` API (`path.get()`, `path.scope.getBinding()`, etc.), so reusing it directly avoids reimplementing those dependencies. |
| 473 | |
| 474 | - **Rust binary**: Takes pre-parsed AST JSON + Scope JSON (produced by the step 1 infrastructure). Deserializes into `react_compiler_ast::File` and `ScopeInfo`, then calls a Rust `lower()` that works with these types directly — no Babel dependency. |
| 475 | |
| 476 | This asymmetry is intentional and acceptable: |
| 477 | 1. The AST JSON round-trip is already validated by step 1 (1714/1714 fixtures pass), so the Rust side sees the same AST data that Babel produced. |
| 478 | 2. The shared contract between the two sides is the **debug output format**, not the input format. |
| 479 | 3. Keeping the TS side on real Babel `NodePath`s means we're comparing against the production compiler's actual behavior, not a reimplementation of its input handling. |
| 480 | |
| 481 | --- |
| 482 | |
| 483 | ## Implementation Plan |
| 484 | |
| 485 | ### M1: Debug Output Format + TS Test Binary |
| 486 | |
| 487 | **Goal**: Get the TS side working end-to-end so we have a reference output for every fixture at every pass. |
| 488 | |
| 489 | 1. **Define the debug output format** — Write a precise specification for the text format. Create a `DebugPrintHIR.ts` module in `compiler/scripts/` (test infrastructure, not compiler source) that implements the format. |
| 490 | |
| 491 | 2. **Define the debug error format** — Specify exact formatting for `CompilerDiagnostic` objects, including all fields. |
| 492 | |
| 493 | 3. **Create `compiler/scripts/ts-compile-fixture.mjs`** — The TS test binary. Takes `<pass> <fixture-path>` and produces debug output. Parses the fixture source with Babel to get a real `NodePath`, runs passes up to the target, prints debug output. |
| 494 | |
| 495 | 4. **Validate the TS binary** — Run it on all fixtures at several pass points (`HIR`, `SSA`, `InferTypes`, `InferMutationAliasingEffects`, `InferMutationAliasingRanges`) and verify the output is sensible and deterministic (running twice produces identical output). |
| 496 | |
| 497 | ### M2: Shell Script + Diff Infrastructure |
| 498 | |
| 499 | **Goal**: The test script runs the TS binary on all fixtures and produces output files. Later, when Rust passes are implemented, it will also run the Rust binary and diff. |
| 500 | |
| 501 | 1. **Create `compiler/scripts/test-rust-port.sh`** — The entrypoint script. Initially only runs the TS side (Rust passes don't exist yet). Supports `<pass>` and `[<dir>]` arguments. |
| 502 | |
| 503 | 2. **Diff formatting** — Implement colored unified diff output, similar to `test-babel-ast.sh`. Show first 5 failures with diffs, then summary counts. |
| 504 | |
| 505 | 3. **Exit codes** — Exit 0 on all pass, non-zero on any failure. Useful for CI integration. |
| 506 | |
| 507 | ### M3: Rust Test Binary Scaffold |
| 508 | |
| 509 | **Goal**: Scaffold the Rust binary and a `todo!`-only stub for `lower()` so the end-to-end test loop works immediately — even though every test will fail. This validates the full test infrastructure (fixture discovery, Rust binary invocation, diff output) before any real porting begins. |
| 510 | |
| 511 | 1. **Create the Rust compiler crate** — `compiler/crates/react_compiler/` with the binary target `test-rust-port`. Depends on `react_compiler_ast` for input types. |
| 512 | |
| 513 | 2. **Stub `lower()`** — Create a `lower()` function with the correct signature that immediately calls `todo!("lower not yet implemented")`. This means the Rust binary will panic for every fixture, producing a non-zero exit code. The test script treats this as a test failure (expected at this stage). |
| 514 | |
| 515 | 3. **Stub pipeline** — The `run_pipeline()` function calls the stubbed `lower()` and has placeholder match arms for all other pass names. Every pass beyond `lower()` also hits `todo!()`. |
| 516 | |
| 517 | 4. **Implement `debug_hir()`** — Rust debug printer matching the TS format exactly. This won't be exercised until `lower()` is real, but having it in place means the first real pass port immediately produces diffable output. |
| 518 | |
| 519 | 5. **Implement `debug_error()`** — Rust error printer matching the TS format. |
| 520 | |
| 521 | 6. **Integrate into `test-rust-port.sh`** — Run both TS and Rust binaries, diff outputs. At this stage, **all tests are expected to fail** (Rust panics on `todo!()`). The test script should report the failure count and distinguish between "Rust panicked" vs "output mismatch" failures: |
| 522 | |
| 523 | ``` |
| 524 | Testing 1714 fixtures up to pass: HIR |
| 525 | |
| 526 | Results: 0 passed, 1714 failed (1714 total) |
| 527 | 1714 rust panicked (todo!), 0 output mismatch |
| 528 | ``` |
| 529 | |
| 530 | This confirms the infrastructure works end-to-end. As `lower()` and subsequent passes are implemented, the "rust panicked" count drops and "passed" / "output mismatch" counts rise. |
| 531 | |
| 532 | **Why stub with `todo!()` now**: The goal of this phase is to validate the test infrastructure itself, not the compiler port. By having a Rust binary that compiles and runs (but panics), we prove that fixture discovery, AST JSON passing, Rust binary invocation, and diff reporting all work correctly. When the real `lower()` port begins (step 4+), the developer can immediately see their progress reflected in the test results without any infrastructure work. |
| 533 | |
| 534 | ### M4: Ongoing — Per-Pass Validation |
| 535 | |
| 536 | As each pass is ported to Rust, replace the `todo!()` stub with a real implementation: |
| 537 | |
| 538 | 1. Replace the `todo!()` in the pass with a real implementation |
| 539 | 2. Run `test-rust-port.sh <pass>` to compare TS and Rust output |
| 540 | 3. Fix any differences until all (or nearly all) fixtures pass |
| 541 | 4. Move to the next pass |
| 542 | |
| 543 | The first pass to port is `lower()`. Once it's real, fixtures at the `HIR` pass will transition from "rust panicked" to either "passed" or "output mismatch". The test infrastructure is complete after M3 — M4 is the ongoing usage pattern. |
| 544 | |
| 545 | --- |
| 546 | |
| 547 | ## File Layout |
| 548 | |
| 549 | ``` |
| 550 | compiler/ |
| 551 | scripts/ |
| 552 | test-rust-port.sh # Entrypoint script |
| 553 | ts-compile-fixture.mjs # TS test binary |
| 554 | debug-print-hir.mjs # Debug HIR printer (TS) |
| 555 | debug-print-reactive.mjs # Debug ReactiveFunction printer (TS) |
| 556 | debug-print-error.mjs # Debug error printer (TS) |
| 557 | crates/ |
| 558 | react_compiler/ |
| 559 | Cargo.toml |
| 560 | src/ |
| 561 | bin/ |
| 562 | test_rust_port.rs # Rust test binary |
| 563 | lib.rs |
| 564 | debug_print.rs # Debug HIR/Reactive/Error printer (Rust) |
| 565 | pipeline.rs # Pipeline runner (pass-by-pass) |
| 566 | react_compiler_hir/ |
| 567 | Cargo.toml |
| 568 | src/ |
| 569 | lib.rs # HIR types |
| 570 | environment.rs # Environment type |
| 571 | react_compiler_lowering/ |
| 572 | Cargo.toml |
| 573 | src/ |
| 574 | lib.rs # pub fn lower() entry point |
| 575 | build_hir.rs # Lowering functions |
| 576 | hir_builder.rs # HIRBuilder struct |
| 577 | react_compiler_diagnostics/ |
| 578 | Cargo.toml |
| 579 | src/ |
| 580 | lib.rs # CompilerError, CompilerDiagnostic, etc. |
| 581 | react_compiler_ast/ # Existing AST crate (from step 1) |
| 582 | ... |
| 583 | ``` |
| 584 | |
| 585 | --- |
| 586 | |
| 587 | ## TS Binary: Parsing Strategy |
| 588 | |
| 589 | The TS test binary parses the original fixture source with `@babel/parser` and `@babel/traverse`, then calls the existing `lower()` with the real `NodePath`. This ensures the TS reference output is 100% faithful to what the production compiler would produce. Any differences in the Rust side's HIR output reveal bugs in the Rust lowering — not artifacts of a reimplemented TS input layer. |
| 590 | |
| 591 | --- |
| 592 | |
| 593 | ## Configuration |
| 594 | |
| 595 | Both test binaries use the **same configuration**. This includes `compilationMode: "all"`, `target: "19"`, and other settings that ensure both sides produce comparable output, plus any overrides from pragma comments in the fixture source. |
| 596 | |
| 597 | **Pragma parsing**: The first line of each fixture may contain config pragmas like `// @enableJsxOutlining @enableNameAnonymousFunctions:false`. Both test binaries parse this line and apply the overrides before running passes. |
| 598 | |
| 599 | **TS side**: Reuse the existing pragma parser from the snap test runner. |
| 600 | |
| 601 | **Rust side**: Implement a simple pragma parser that produces the same config. Initially, before the Rust pragma parser is built, use a fixed default config and skip fixtures with non-default pragmas (or have the TS binary output the resolved config as a JSON header that the Rust binary can consume). |
| 602 | |
| 603 | --- |
| 604 | |
| 605 | ## Determinism Requirements |
| 606 | |
| 607 | For the diff to be meaningful, both test binaries must be fully deterministic: |
| 608 | |
| 609 | 1. **Map/Set iteration order**: TS uses insertion-order Maps and Sets. Rust should use `IndexMap`/`IndexSet` (from the `indexmap` crate) for insertion-order maps and sets, matching TS's insertion-order `Map` and `Set`. The debug printer must sort by key (block IDs, identifier IDs, scope IDs) before printing. |
| 610 | |
| 611 | 2. **ID assignment**: Both sides must assign the same IDs (IdentifierId, BlockId, ScopeId) in the same order. This is ensured by following the same pipeline logic. |
| 612 | |
| 613 | 3. **Floating point**: Avoid floating point in debug output. All numeric values are integers (IDs, ranges, line/column numbers). |
| 614 | |
| 615 | 4. **Source locations**: Print locations as `line:column-line:column`. Both sides read the same source locations from the AST JSON. |
| 616 | |
| 617 | --- |
| 618 | |
| 619 | ## Scope and Non-Goals |
| 620 | |
| 621 | ### In Scope |
| 622 | - Testing every pass from `lower` through `codegen` |
| 623 | - HIR debug output comparison |
| 624 | - ReactiveFunction debug output comparison |
| 625 | - Error output comparison (thrown and accumulated) |
| 626 | - Support for custom fixture directories |
| 627 | - Config pragma support |
| 628 | |
| 629 | ### Not In Scope (Initially) |
| 630 | - Performance benchmarking (separate effort) |
| 631 | - Testing the Babel plugin integration (the Rust compiler is a standalone binary) |
| 632 | - Testing codegen output (the `Codegen` pass produces a Babel AST, which is tested by comparing its debug representation — not by running the generated code) |
| 633 | - Parallel test execution (run fixtures sequentially initially; parallelize later if needed) |
| 634 | - Watch mode |