main
md 634 lines 31.6 KB
Rendered Raw
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