Skip to main content

nova_driver/
lib.rs

1//! Compilation pipeline orchestration for Nova.
2//!
3//! Drives source → lex → parse → resolve → typecheck → MIR →
4//! Cranelift (JIT for `nova run`, object emission + native linking for
5//! `nova build`), rendering diagnostics from every stage through
6//! `nova-diagnostics`. This is the crate behind `nova run`,
7//! `nova build`, and `nova check`.
8
9mod analyze;
10mod link;
11mod program;
12mod runtime_cache;
13
14pub use analyze::{analyze, analyze_program, Analysis, DiskSources, Options, Probe, Sources};
15pub use program::{package_of, Program, Root, RootKind, Roots};
16pub use runtime_cache::{set_embedded_runtime, EmbeddedRuntime};
17
18use std::collections::HashSet;
19use std::hash::{Hash, Hasher};
20use std::path::{Path, PathBuf};
21
22use anyhow::{Context, Result};
23use nova_codegen_cranelift::CompiledProgram;
24use nova_diagnostics::{render, Diagnostic, FileDb, FileId, Severity, Span};
25use nova_hir as hir;
26use nova_hir::Ty;
27use nova_pm::PackageId;
28use nova_resolver::{Builtin, Def, DefId, DefKind, Definitions, ModuleId, ModuleSource, TestFn};
29use program::Loaded;
30
31/// Outcome of a pipeline invocation that may fail with user errors.
32pub enum Outcome<T> {
33    /// The pipeline succeeded.
34    Ok(T),
35    /// User-facing errors were reported (already rendered to stderr).
36    Failed {
37        /// Number of error-severity diagnostics.
38        errors: usize,
39    },
40}
41
42/// Type-check a file, printing diagnostics. `Outcome::Ok(())` means the
43/// program is well-formed and would compile.
44///
45/// This also runs monomorphization (MIR lowering), since some checks —
46/// notably trait-bound satisfaction — are performed there per
47/// `12-TYPESYSTEM.md` §5.4. Without it `nova check` would call a program
48/// "well-formed" that `nova run` / `nova build` then reject.
49pub fn check_file(path: &Path) -> Result<Outcome<()>> {
50    check_program(Program::for_file(path))
51}
52
53/// What [`check_program_counted`] rendered.
54#[derive(Debug, Clone, Copy, PartialEq, Eq)]
55pub struct Checked {
56    pub errors: usize,
57    /// The warnings shown. A registry package's are not (spec 3.3b §5.4).
58    pub warnings: usize,
59}
60
61/// [`check_file`] on `program` (spec 3.3a §5.1). MIR runs only when the
62/// program runs: its entry is `src/main.nova`, or a loose file.
63pub fn check_program(program: Program) -> Result<Outcome<()>> {
64    let checked = check_program_counted(program)?;
65    if checked.errors > 0 {
66        Ok(Outcome::Failed {
67            errors: checked.errors,
68        })
69    } else {
70        Ok(Outcome::Ok(()))
71    }
72}
73
74/// [`check_program`], counting the errors and warnings it rendered.
75/// Publishing's verification fails on either (spec 3.3b §6.6).
76pub fn check_program_counted(program: Program) -> Result<Checked> {
77    let runs = program.runs;
78    let mut ctx = FrontendContext::new(program)?;
79    if let Some((module, _tests, _fresh_def_id)) = ctx.check(false)? {
80        if runs {
81            if let Err(diags) = nova_mir::lower_module(&module) {
82                ctx.render(&diags);
83            }
84        }
85    }
86    Ok(Checked {
87        errors: ctx.errors,
88        warnings: ctx.warnings,
89    })
90}
91
92/// Compile a file to native code via the Cranelift JIT.
93pub fn compile_file(path: &Path) -> Result<Outcome<CompiledProgram>> {
94    compile_program(Program::for_file(path))
95}
96
97/// [`compile_file`] on `program`.
98pub fn compile_program(program: Program) -> Result<Outcome<CompiledProgram>> {
99    let mir = match lower_to_mir(program)? {
100        Outcome::Ok(mir) => mir,
101        Outcome::Failed { errors } => return Ok(Outcome::Failed { errors }),
102    };
103    match nova_codegen_cranelift::compile_jit(&mir) {
104        Ok(program) => Ok(Outcome::Ok(program)),
105        Err(e) => {
106            // An unresolvable `extern` symbol is a user error (the symbol isn't
107            // in the C runtime / any loaded library), not a compiler bug — the
108            // JIT can only discover it at finalize time. Report it as a clean
109            // diagnostic, mirroring the `nova build` linker-error path.
110            if let Some(sym) = e.downcast_ref::<nova_codegen_cranelift::UnresolvedExternSymbol>() {
111                let diag = Diagnostic::error(
112                    "E0902",
113                    format!(
114                        "{sym} at run time; an `extern` function must be provided by the \
115                         C runtime or an already-loaded library (a `nova build` executable \
116                         resolves it at link time instead)"
117                    ),
118                );
119                render::emit_all(&FileDb::new(), &[diag]);
120                return Ok(Outcome::Failed { errors: 1 });
121            }
122            Err(e).context("internal codegen error (this is a compiler bug)")
123        }
124    }
125}
126
127/// Compile a file to a standalone native executable (`nova build`).
128///
129/// Emits a Cranelift object file next to `output`, links it with the
130/// `nova-runtime` static library through the platform linker, and removes
131/// the intermediate object on success.
132pub fn build_file(path: &Path, output: &Path) -> Result<Outcome<PathBuf>> {
133    build_program(Program::for_file(path), output)
134}
135
136/// [`build_file`] on `program`.
137pub fn build_program(program: Program, output: &Path) -> Result<Outcome<PathBuf>> {
138    let mir = match lower_to_mir(program)? {
139        Outcome::Ok(mir) => mir,
140        Outcome::Failed { errors } => return Ok(Outcome::Failed { errors }),
141    };
142    let bytes = nova_codegen_cranelift::compile_object(&mir)
143        .context("internal codegen error (this is a compiler bug)")?;
144
145    let obj_ext = if cfg!(windows) { "obj" } else { "o" };
146    let obj_path = intermediate(output, obj_ext);
147    std::fs::write(&obj_path, bytes)
148        .with_context(|| format!("failed to write {}", obj_path.display()))?;
149
150    let linked = link::link_executable(&obj_path, output);
151    let _ = std::fs::remove_file(&obj_path);
152    linked?;
153    Ok(Outcome::Ok(output.to_path_buf()))
154}
155
156/// An intermediate-file path derived from `output` that can never alias it:
157/// the full output file name plus `.nova.<ext>` (so even `-o out.ll` /
158/// `-o out.obj` yield a distinct `out.ll.nova.ll` / `out.obj.nova.obj` and the
159/// final binary is never overwritten or deleted as an intermediate).
160fn intermediate(output: &Path, ext: &str) -> PathBuf {
161    let mut name = output
162        .file_name()
163        .map(|n| n.to_os_string())
164        .unwrap_or_else(|| std::ffi::OsString::from("out"));
165    name.push(".nova.");
166    name.push(ext);
167    output.with_file_name(name)
168}
169
170/// Compile a file to an optimized standalone executable via the LLVM backend
171/// (`nova build --release`).
172///
173/// Emits textual LLVM IR next to `output`, compiles it to an object file with
174/// a discovered LLVM toolchain (`clang`/`llc`, `-O2`), then links it with the
175/// `nova-runtime` static library through the same platform linker as the debug
176/// build. If no LLVM toolchain is found the generated `.ll` is left in place
177/// and a helpful error is returned.
178pub fn build_file_release(path: &Path, output: &Path) -> Result<Outcome<PathBuf>> {
179    build_program_release(Program::for_file(path), output)
180}
181
182/// [`build_file_release`] on `program`.
183pub fn build_program_release(program: Program, output: &Path) -> Result<Outcome<PathBuf>> {
184    let mir = match lower_to_mir(program)? {
185        Outcome::Ok(mir) => mir,
186        Outcome::Failed { errors } => return Ok(Outcome::Failed { errors }),
187    };
188    let ir = nova_codegen_llvm::compile_ir(&mir)
189        .context("internal LLVM IR generation error (this is a compiler bug)")?;
190
191    let ll_path = intermediate(output, "ll");
192    std::fs::write(&ll_path, ir.as_bytes())
193        .with_context(|| format!("failed to write {}", ll_path.display()))?;
194
195    let obj_ext = if cfg!(windows) { "obj" } else { "o" };
196    let obj_path = intermediate(output, obj_ext);
197
198    // Compile IR → object, then link. Keep the `.ll` on failure (for the LLVM
199    // toolchain to be pointed at or for inspection); remove it on success.
200    let result = link::compile_ir_to_object(&ll_path, &obj_path)
201        .and_then(|()| link::link_executable(&obj_path, output));
202    let _ = std::fs::remove_file(&obj_path);
203    match result {
204        Ok(()) => {
205            let _ = std::fs::remove_file(&ll_path);
206            Ok(Outcome::Ok(output.to_path_buf()))
207        }
208        Err(e) => Err(e.context(format!(
209            "release build failed; generated LLVM IR left at {}",
210            ll_path.display()
211        ))),
212    }
213}
214
215/// A name no Nova source can ever produce for a top-level function: the
216/// lexer's identifier grammar is `[a-zA-Z_][a-zA-Z0-9_]*` (`nova-lexer/src/
217/// lib.rs`'s `RawToken::Ident` regex), which cannot lex a `.` inside one
218/// `Ident` token — the same "impossible in surface syntax" property
219/// `nova_mir::mangle` already relies on (its symbols are `name.<def_id>`).
220/// `build_test_binary` uses this to rename a user's own `main`, if the
221/// source declares one, so it cannot be mistaken for the synthesized one.
222const SHADOWED_USER_MAIN_NAME: &str = "main.shadowed_by_nova_test";
223
224/// What every function called `main` outside the entry module is renamed
225/// to (spec
226/// `docs/superpowers/specs/2026-10-08-phase-3-3a-local-packages-design.md`
227/// §4.6): a name the lexer cannot produce, like
228/// [`SHADOWED_USER_MAIN_NAME`].
229const NOT_THE_ENTRY_MAIN: &str = "main.not_the_entry";
230
231/// The test function each doc test module adds (spec
232/// `docs/superpowers/specs/2026-10-11-phase-3-5b-doc-tests-design.md`
233/// §5.2): in a program of doc tests, the only `@test` function collected.
234/// Each module is its own namespace, so each can use this name.
235pub const DOC_TEST_FUNCTION: &str = "nova_doctest";
236
237/// Rename every `main` declared outside the entry module, `ModuleId(0)`,
238/// so that `nova_mir::lower_module`'s search by name finds the entry's own
239/// `main`, or reports E0601 (spec 3.3a §4.6). A call reaches its function
240/// by `DefId`, so the rename breaks no call.
241fn keep_the_entry_main(module: &mut hir::Module, definitions: &Definitions) {
242    for function in &mut module.functions {
243        if function.name != "main" {
244            continue;
245        }
246        let def = definitions.defs().get(function.def_id.0 as usize);
247        if let Some(Def {
248            kind: DefKind::Fn { item_index },
249            ..
250        }) = def
251        {
252            if definitions.module_of(*item_index) != ModuleId(0) {
253                function.name = NOT_THE_ENTRY_MAIN.to_string();
254            }
255        }
256    }
257}
258
259/// Name each test of a `tests/` file `<file stem>::<function>` (spec 3.3a
260/// §4.6). The synthesized `main` prints these names as its inventory, so
261/// `nova test` and the binary agree on them.
262fn name_tests(tests: &mut [TestFn], definitions: &Definitions, modules: &[Loaded]) {
263    for test in tests {
264        let def = definitions.defs().get(test.def_id.0 as usize);
265        let Some(Def {
266            kind: DefKind::Fn { item_index },
267            ..
268        }) = def
269        else {
270            continue;
271        };
272        let module = definitions.module_of(*item_index).0 as usize;
273        if let Some(module) = modules.get(module).filter(|m| m.test_file) {
274            test.name = format!(
275                "{}::{}",
276                FrontendContext::module_name(&module.path),
277                test.name
278            );
279        }
280    }
281}
282
283/// Compile `path` as a test binary (`nova test`, Task 5): `main` is
284/// synthesized to dispatch to exactly one collected `@test` by index
285/// (`Builtin::TestSelector`) rather than to the file's own `main` — a test
286/// file need not define one, since a missing `main` is enforced only at MIR
287/// lowering (`E0601` in `nova_mir::lower_module`), which the synthesized
288/// function supplies before that check ever runs.
289///
290/// If the source *does* declare its own `main`, that function is renamed
291/// (never removed — something else in the module may still call it by
292/// `DefId`) before the synthesized dispatcher is pushed, so the dispatcher
293/// is the only function left named `"main"`. This matters because
294/// `nova-mir/src/mono.rs`'s entry-point search is
295/// `module.functions.iter().find(|f| f.name == "main")` — the *first*
296/// match — while pushing onto `module.functions` puts the dispatcher
297/// *last*: without the rename, a source file with both `@test` functions
298/// and its own `main` would silently run the user's `main` instead of any
299/// test, compiling and linking cleanly with no diagnostic anywhere.
300///
301/// Returns the built executable's path and the collected tests in source
302/// order, so a caller can run the binary once per test with `NOVA_TEST_INDEX`
303/// set without recompiling or re-deriving either the path or the inventory
304/// from the source again.
305pub fn build_test_binary(path: &Path) -> Result<(PathBuf, Vec<TestFn>)> {
306    build_test_program(Program::for_file(path))
307}
308
309/// Rewrites a diagnostic before it is counted and rendered, with the
310/// program's files at hand (spec
311/// `docs/superpowers/specs/2026-10-11-phase-3-5b-doc-tests-design.md` §7):
312/// it may move the diagnostic's labels into other files of the database,
313/// adding them. `false` hides a warning; an error is always shown.
314pub type MapDiagnostic<'a> = dyn Fn(&mut FileDb, &mut Diagnostic) -> bool + 'a;
315
316/// The text of the std module a program's `FileDb` names `name`, such as
317/// `<std/time>`, or `None` for any other name. The build adds std's modules
318/// to the database only after loading, so a [`MapDiagnostic`] that shows a
319/// diagnostic in one, as a std doc test's does (spec 3.5b §7), reads its
320/// text here.
321pub fn std_source(name: &str) -> Option<&'static str> {
322    let short = name.strip_prefix("<std/")?.strip_suffix('>')?;
323    nova_resolver::STD_MODULES
324        .iter()
325        .chain(std::iter::once(&nova_resolver::STD_TEST_MODULE))
326        .find(|(module, _)| module.strip_prefix("$std.") == Some(short))
327        .map(|&(_, text)| text)
328}
329
330/// [`build_test_binary`] on `program`: the test binary of its roots.
331pub fn build_test_program(program: Program) -> Result<(PathBuf, Vec<TestFn>)> {
332    build_test_program_with(program, &DiskSources, None)
333}
334
335/// [`build_test_program`], reading every module through `sources`, and
336/// passing each diagnostic through `map` before it is counted and rendered
337/// (spec 3.5b §7, §11.2). Doc tests are modules no file holds.
338pub fn build_test_program_with(
339    program: Program,
340    sources: &dyn Sources,
341    map: Option<&MapDiagnostic>,
342) -> Result<(PathBuf, Vec<TestFn>)> {
343    let entry = program
344        .roots
345        .first()
346        .map(|root| root.path.clone())
347        .unwrap_or_default();
348    let mut ctx = FrontendContext::with(program, sources, map)?;
349    let Some((mut module, tests, fresh_def_id)) = ctx.check(true)? else {
350        anyhow::bail!(
351            "could not compile due to {} previous error{}",
352            ctx.errors,
353            if ctx.errors == 1 { "" } else { "s" }
354        );
355    };
356    for f in &mut module.functions {
357        if f.name == "main" {
358            f.name = SHADOWED_USER_MAIN_NAME.to_string();
359        }
360    }
361    module
362        .functions
363        .push(synthesize_test_main(fresh_def_id, &tests));
364
365    let mir = match nova_mir::lower_module(&module) {
366        Ok(mir) => mir,
367        Err(diags) => {
368            ctx.render(&diags);
369            anyhow::bail!(
370                "could not compile due to {} previous error{}",
371                ctx.errors,
372                if ctx.errors == 1 { "" } else { "s" }
373            );
374        }
375    };
376    let bytes = nova_codegen_cranelift::compile_object(&mir)
377        .context("internal codegen error (this is a compiler bug)")?;
378
379    // A caller-distinguishing directory, keyed on the *canonicalized* input
380    // path rather than its file stem: fixtures overwhelmingly share the
381    // name `main.nova` (50 occurrences in this workspace's own
382    // `nova-cli/tests/run_tests.rs` alone), so two different source files in
383    // two different directories must not resolve to the same output path —
384    // a real race under `cargo test`'s default parallelism (a locked-file
385    // link error on Windows; a binary overwritten mid-execution on Unix).
386    let dir = std::env::temp_dir()
387        .join("nova-test-bin")
388        .join(path_fingerprint(&entry));
389    std::fs::create_dir_all(&dir).with_context(|| format!("failed to create {}", dir.display()))?;
390    let output = dir.join(format!(
391        "{}{}",
392        FrontendContext::module_name(&entry),
393        std::env::consts::EXE_SUFFIX
394    ));
395
396    let obj_ext = if cfg!(windows) { "obj" } else { "o" };
397    let obj_path = intermediate(&output, obj_ext);
398    std::fs::write(&obj_path, bytes)
399        .with_context(|| format!("failed to write {}", obj_path.display()))?;
400
401    let linked = link::link_executable(&obj_path, &output);
402    let _ = std::fs::remove_file(&obj_path);
403    linked?;
404    Ok((output, tests))
405}
406
407/// A short, deterministic fingerprint of `path`'s canonicalized form, for
408/// `build_test_binary`'s output directory. Canonicalizing first means two
409/// different relative spellings of the same file collide on purpose, while
410/// two files that merely share a name in different directories do not. A
411/// path no file holds, such as a doc test's (spec 3.5b §11.2), is joined
412/// onto the current directory instead, so two packages' relative
413/// `<doc tests>/doc0.nova` do not share a binary.
414fn path_fingerprint(path: &Path) -> String {
415    let canon = path.canonicalize().unwrap_or_else(|_| {
416        std::env::current_dir()
417            .map(|dir| dir.join(path))
418            .unwrap_or_else(|_| path.to_path_buf())
419    });
420    let mut hasher = std::collections::hash_map::DefaultHasher::new();
421    canon.hash(&mut hasher);
422    format!("{:016x}", hasher.finish())
423}
424
425/// A zero-width span at the dummy file: there is no source location for a
426/// hand-built `hir::Function`, so every node `synthesize_test_main` builds
427/// uses the same sentinel `FileId::DUMMY` documents itself for.
428fn dummy_span() -> Span {
429    Span::point(0, FileId::DUMMY)
430}
431
432fn expr(kind: hir::ExprKind, ty: Ty) -> hir::Expr {
433    hir::Expr {
434        kind,
435        ty,
436        span: dummy_span(),
437    }
438}
439
440/// Build the dispatching `main` that `build_test_binary` adds to the module.
441///
442/// Constructed directly as an `hir::Function` — mirroring the synthetic
443/// `main` `nova-mir/src/mono.rs`'s own unit tests build, field for field —
444/// rather than as generated Nova source. Generated source is how `std`
445/// reaches a program, but it would route through the resolver, which
446/// enforces `pub`, and `@test` functions are deliberately not `pub`;
447/// constructing the HIR directly sidesteps visibility rather than fighting
448/// it. Because `nova-mir/src/mono.rs`'s monomorphizer locates the entry point
449/// by the name `"main"`, not by `def_id`, nothing downstream needs to know
450/// this particular `main` was synthesized rather than parsed.
451///
452/// Body: bind `test_selector()` to local 0 (`sel`), then one
453/// `if sel == i { test_i() }` per collected test in source order, then
454/// (reached only when `sel` matched none of them — including whenever
455/// `sel < 0`, the documented sentinel) print the inventory: a count line,
456/// then one name per line.
457fn synthesize_test_main(main_id: DefId, tests: &[TestFn]) -> hir::Function {
458    let sel = hir::LocalId(0);
459
460    let mut stmts = vec![expr(
461        hir::ExprKind::Let {
462            local: sel,
463            init: Box::new(expr(
464                hir::ExprKind::Call {
465                    func: hir::Callee::Builtin(Builtin::TestSelector),
466                    type_args: Vec::new(),
467                    args: Vec::new(),
468                },
469                Ty::Int,
470            )),
471        },
472        Ty::Unit,
473    )];
474
475    for (i, t) in tests.iter().enumerate() {
476        let cond = expr(
477            hir::ExprKind::Binary {
478                op: hir::BinOp::Eq,
479                lhs: Box::new(expr(hir::ExprKind::Local(sel), Ty::Int)),
480                rhs: Box::new(expr(hir::ExprKind::IntLit(i as i64), Ty::Int)),
481            },
482            Ty::Bool,
483        );
484        let call = expr(
485            hir::ExprKind::Call {
486                func: hir::Callee::Def(t.def_id),
487                type_args: Vec::new(),
488                args: Vec::new(),
489            },
490            // `nova-resolver`'s `test_shape_violations` rejects a `@test`
491            // function that is `async` or returns non-`Unit` (E0084), so every
492            // callee here is an ordinary `Unit`-typed function. Both halves are
493            // load-bearing: the direct call below neither awaits nor polls, so
494            // an `async` callee would be the future-building wrapper and this
495            // `if` would allocate a state object and discard it, running none
496            // of the test's body.
497            Ty::Unit,
498        );
499        stmts.push(expr(
500            hir::ExprKind::If {
501                cond: Box::new(cond),
502                then: Box::new(call),
503                else_: None,
504            },
505            Ty::Unit,
506        ));
507    }
508
509    let println = |s: String| {
510        expr(
511            hir::ExprKind::Call {
512                func: hir::Callee::Builtin(Builtin::Println),
513                type_args: Vec::new(),
514                args: vec![expr(hir::ExprKind::StrLit(s), Ty::String)],
515            },
516            Ty::Unit,
517        )
518    };
519    let mut inventory = vec![println(tests.len().to_string())];
520    inventory.extend(tests.iter().map(|t| println(t.name.clone())));
521    let sel_is_negative = expr(
522        hir::ExprKind::Binary {
523            op: hir::BinOp::Lt,
524            lhs: Box::new(expr(hir::ExprKind::Local(sel), Ty::Int)),
525            rhs: Box::new(expr(hir::ExprKind::IntLit(0), Ty::Int)),
526        },
527        Ty::Bool,
528    );
529    stmts.push(expr(
530        hir::ExprKind::If {
531            cond: Box::new(sel_is_negative),
532            then: Box::new(expr(
533                hir::ExprKind::Block {
534                    stmts: inventory,
535                    trailing: None,
536                },
537                Ty::Unit,
538            )),
539            else_: None,
540        },
541        Ty::Unit,
542    ));
543
544    hir::Function {
545        def_id: main_id,
546        name: "main".to_string(),
547        generics: 0,
548        bounds: Vec::new(),
549        takes_env: false,
550        capture_count: 0,
551        params: 0,
552        locals: vec![hir::Local {
553            name: "sel".to_string(),
554            ty: Ty::Int,
555            is_mut: false,
556            span: dummy_span(),
557        }],
558        ret_ty: Ty::Unit,
559        // The synthesized `@test` harness entry point is plain generated
560        // dispatch code (a chain of `if sel == i { test_i() }`), never a
561        // user-written `async fn` — so it is never async.
562        is_async: false,
563        body: expr(
564            hir::ExprKind::Block {
565                stmts,
566                trailing: None,
567            },
568            Ty::Unit,
569        ),
570        span: dummy_span(),
571    }
572}
573
574/// Run the front end and MIR lowering, rendering any diagnostics.
575fn lower_to_mir(program: Program) -> Result<Outcome<nova_mir::Module>> {
576    let mut ctx = FrontendContext::new(program)?;
577    let Some((module, _tests, _fresh_def_id)) = ctx.check(false)? else {
578        return Ok(Outcome::Failed { errors: ctx.errors });
579    };
580    match nova_mir::lower_module(&module) {
581        Ok(mir) => Ok(Outcome::Ok(mir)),
582        Err(diags) => {
583            ctx.render(&diags);
584            Ok(Outcome::Failed { errors: ctx.errors })
585        }
586    }
587}
588
589/// Compile and immediately execute a file (`nova run`), with `args` as the
590/// program's arguments: `std/process`'s `args()` returns exactly this list.
591pub fn run_file(path: &Path, args: Vec<String>) -> Result<Outcome<()>> {
592    run_program(Program::for_file(path), args)
593}
594
595/// [`run_file`] on `program`.
596pub fn run_program(program: Program, args: Vec<String>) -> Result<Outcome<()>> {
597    match compile_program(program)? {
598        Outcome::Ok(program) => {
599            program.run_with_args(args);
600            Ok(Outcome::Ok(()))
601        }
602        Outcome::Failed { errors } => Ok(Outcome::Failed { errors }),
603    }
604}
605
606/// Shared front-end state: what to load, the file database, and error
607/// accounting.
608struct FrontendContext<'a> {
609    db: FileDb,
610    graph: Option<nova_pm::Graph>,
611    roots: Vec<Root>,
612    errors: usize,
613    warnings: usize,
614    /// The registry packages' modules, once loaded: a warning only about
615    /// them is not shown.
616    registry_files: HashSet<FileId>,
617    /// Where every module is read (spec 3.5b §11.2).
618    sources: &'a dyn Sources,
619    /// Called on each diagnostic before it is counted and rendered (spec
620    /// 3.5b §7).
621    map: Option<&'a MapDiagnostic<'a>>,
622}
623
624impl<'a> FrontendContext<'a> {
625    /// Take `program`, rendering its graph's diagnostics. When they hold
626    /// no error, the entry must exist: imported modules are read lazily.
627    fn new(program: Program) -> Result<Self> {
628        FrontendContext::with(program, &DiskSources, None)
629    }
630
631    /// [`FrontendContext::new`], reading every module through `sources`
632    /// and passing each diagnostic through `map`.
633    fn with(
634        program: Program,
635        sources: &'a dyn Sources,
636        map: Option<&'a MapDiagnostic<'a>>,
637    ) -> Result<Self> {
638        let Program {
639            db,
640            graph,
641            roots,
642            diagnostics,
643            ..
644        } = program;
645        let mut ctx = FrontendContext {
646            db,
647            graph,
648            roots,
649            errors: 0,
650            warnings: 0,
651            registry_files: HashSet::new(),
652            sources,
653            map,
654        };
655        ctx.render(&diagnostics);
656        if ctx.errors == 0 {
657            let entry = ctx
658                .entry()
659                .context("nothing to compile: the package has no source file for this")?;
660            ctx.sources
661                .read(entry)
662                .with_context(|| format!("failed to open {}", entry.display()))?;
663        }
664        Ok(ctx)
665    }
666
667    /// The first root.
668    fn entry(&self) -> Option<&Path> {
669        self.roots.first().map(|root| root.path.as_path())
670    }
671
672    fn render(&mut self, diagnostics: &[Diagnostic]) {
673        let mut shown: Vec<Diagnostic> = diagnostics
674            .iter()
675            .filter(|d| !program::a_dependencys_warning(d, &self.registry_files))
676            .cloned()
677            .collect();
678        if let Some(map) = self.map {
679            let db = &mut self.db;
680            shown.retain_mut(|d| map(db, d) || d.severity == Severity::Error);
681        }
682        self.errors += shown
683            .iter()
684            .filter(|d| d.severity == Severity::Error)
685            .count();
686        self.warnings += shown
687            .iter()
688            .filter(|d| d.severity == Severity::Warning)
689            .count();
690        if !shown.is_empty() {
691            render::emit_all(&self.db, &shown);
692        }
693    }
694
695    /// The module name for a source path — its file stem.
696    fn module_name(path: &Path) -> String {
697        path.file_stem()
698            .map(|s| s.to_string_lossy().into_owned())
699            .unwrap_or_else(|| "main".to_string())
700    }
701
702    /// Load, lex and parse every module the roots reach, through
703    /// `sources`, rendering the lex, parse and import diagnostics. The
704    /// loading itself is [`program::load_program`], which the language
705    /// server shares.
706    fn load_modules(&mut self) -> Result<Vec<Loaded>> {
707        let entry = self.entry().map(Path::to_path_buf).unwrap_or_default();
708        let load =
709            program::load_program(self.graph.as_ref(), &self.roots, self.sources, &mut self.db)
710                .with_context(|| format!("failed to read {}", entry.display()))?;
711        self.registry_files = program::registry_files(self.graph.as_ref(), &load.modules);
712        self.render(&load.diagnostics);
713        Ok(load.modules)
714    }
715
716    /// Run load → lex → parse → resolve → typecheck across all modules.
717    /// Returns the merged typed module, the `@test` functions collected along
718    /// the way (source order, per `nova_resolver::TestFn`'s doc comment), and
719    /// a `DefId` that no definition and no function of the module holds — or
720    /// `None` if any stage reported errors.
721    ///
722    /// `with_test_module` seeds `std/test` (`assert`/`assert_eq`/`assert_ne`)
723    /// alongside every `STD_MODULES` entry, so it is glob-imported the same way — but
724    /// only when `true`. `build_test_binary` is the only caller that passes
725    /// `true`; `check_file` and `lower_to_mir` (behind `check_file`,
726    /// `compile_file`, `build_file`, `build_file_release`) pass `false`, so
727    /// those names never resolve in an ordinary compile.
728    ///
729    /// The fresh `DefId` is past both kinds of id the module holds.
730    /// `Definitions` documents `defs` as one global counter shared by every
731    /// definition kind (functions, sums, records, traits, methods, externs,
732    /// associated types), allocated by `push_def` in strict `Vec::len()`
733    /// order, so every id resolution handed out is `< defs().len()`. The type
734    /// checker then numbers the functions it lifts for closures and for named
735    /// functions used as values from `defs().len()` on, so the fresh id is
736    /// also past every function's. `build_test_binary` uses it to name a
737    /// synthesized `main` that cannot alias an id a `@test` function or a
738    /// lifted closure already owns.
739    fn check(
740        &mut self,
741        with_test_module: bool,
742    ) -> Result<Option<(nova_hir::Module, Vec<TestFn>, DefId)>> {
743        // The graph's errors were rendered by `new`.
744        if self.errors > 0 {
745            return Ok(None);
746        }
747        let mut modules = self.load_modules()?;
748        if self.errors > 0 {
749            return Ok(None);
750        }
751        // Whole-branch review, finding 1 (Critical): a `@test` function's
752        // body is written assuming `std/test`'s `assert`/`assert_eq`/
753        // `assert_ne` are in scope, which — per the doc comment above — is
754        // true only when `with_test_module` is `true`. Left in the
755        // compilation unit under `nova run`/`build`/`check`, such a body's
756        // calls to them fail to resolve (`E0001`), which means a program
757        // could never both `nova build` and `nova test` cleanly, and `nova
758        // check` (the editor path) reported spurious errors on every test in
759        // the project. Stripping the items here — before any of them reaches
760        // `nova_resolver::resolve_program` — removes the problem instead of
761        // working around it: a stripped function gets no `DefId`, no scope
762        // entry, and its body is never visited by anything downstream, the
763        // same "does not exist in this build" semantics as Rust's
764        // `#[cfg(test)]`. See `strip_test_functions`'s own doc comment for
765        // why this must happen *before* resolution rather than after.
766        //
767        // Phase 3.3a: under `nova test` the root package's tests are
768        // kept and a dependency's stripped (spec
769        // docs/superpowers/specs/2026-10-08-phase-3-3a-local-packages-design.md
770        // §4.6). Phase 3.5b: a program of doc tests keeps only the test
771        // each doc test adds (spec 3.5b §5.2).
772        strip_test_functions(&mut modules, with_test_module);
773        // Register each embedded std module's source in the `FileDb` so a
774        // syntax error inside one (a compiler bug, since they ship with the
775        // compiler) is reported against a real, named file instead of
776        // `FileId::DUMMY`. One id per `nova_resolver::STD_MODULES` entry, in
777        // order — e.g. `$std.core` names `<std/core>`.
778        let std_files: Vec<FileId> = nova_resolver::STD_MODULES
779            .iter()
780            .map(|&(name, src)| {
781                let short = name.strip_prefix("$std.").unwrap_or(name);
782                self.db.add(format!("<std/{short}>"), src)
783            })
784            .collect();
785        // `std/test`, registered the same way but only under `nova test`
786        // (`with_test_module`): one more `FileId` allocated *conditionally*,
787        // which is fine precisely because it rides alongside `STD_MODULES`
788        // rather than being folded into it — nothing computes an index from
789        // `STD_MODULES.len()` expecting it to already include this entry.
790        let extra_std = with_test_module.then(|| {
791            let (name, src) = nova_resolver::STD_TEST_MODULE;
792            let short = name.strip_prefix("$std.").unwrap_or(name);
793            let file_id = self.db.add(format!("<std/{short}>"), src);
794            (nova_resolver::STD_TEST_MODULE, file_id)
795        });
796        let sources: Vec<ModuleSource> = modules
797            .iter()
798            .map(|m| ModuleSource {
799                name: m.name.clone(),
800                file: &m.ast,
801                imports: m.imports.clone(),
802                text: self.db.get_source(m.file),
803                package: m.package.map(|p| p.0),
804                importable: m.importable.clone(),
805            })
806            .collect();
807        let resolved = nova_resolver::resolve_program(&sources, &std_files, extra_std);
808        self.render(&resolved.diagnostics);
809        if self.errors > 0 {
810            return Ok(None);
811        }
812        let mut tests = resolved.tests;
813        name_tests(&mut tests, &resolved.definitions, &modules);
814
815        let checked = nova_typeck::check_with(
816            &resolved.file,
817            &resolved.definitions,
818            &nova_typeck::CheckOptions {
819                sources: Some(&self.db),
820                ..Default::default()
821            },
822        );
823        self.render(&checked.diagnostics);
824        if self.errors > 0 {
825            return Ok(None);
826        }
827        let mut module = checked.module;
828        keep_the_entry_main(&mut module, &resolved.definitions);
829        let fresh_def_id = DefId(
830            module
831                .functions
832                .iter()
833                .map(|f| f.def_id.0 + 1)
834                .fold(resolved.definitions.defs().len() as u32, u32::max),
835        );
836        Ok(Some((module, tests, fresh_def_id)))
837    }
838}
839
840/// Remove top-level `@test` functions from `modules`, in place, before
841/// resolution:
842/// - without `keep_root_tests` (`nova run`, `build` and `check`), every one;
843/// - with it (`nova test`, and the language server's `tests` option), a
844///   dependency's, whose tests never run in its dependent's binary (spec
845///   3.3a §4.6), keeping the root package's and a loose program's;
846/// - with it, in a program of doc tests (spec
847///   `docs/superpowers/specs/2026-10-11-phase-3-5b-doc-tests-design.md`
848///   §5.2, §6.2), every one but the [`DOC_TEST_FUNCTION`] each doc test
849///   module adds: the library's tests run in the unit tests' binary, and a
850///   `@test` function a block declares is not collected.
851///
852/// What follows was written when this ran only for `nova run`, `build`
853/// and `check`, and every module was the program's own; why a test is
854/// removed before resolution still holds for every one it removes.
855///
856/// A `@test` function is ordinary Nova source with no special compilation
857/// unit of its own — before this fix, `nova_resolver::resolve_program` collected it,
858/// gave it a `DefId`, and `nova_typeck::check` type-checked its body exactly
859/// like any other function, regardless of `with_test_module`. That body is
860/// written assuming `std/test`'s `assert`/`assert_eq`/`assert_ne` are in
861/// scope; they are seeded only under `nova test` (this file's own doc
862/// comment on `check`), so under every other compilation mode those calls
863/// failed to resolve (`E0001`) — a program containing so much as one test
864/// could not be built or checked at all, and `nova check` (the editor path)
865/// reported spurious errors on every test in the project on every keystroke.
866///
867/// **Removed here, before `resolve_program` ever runs**, rather than kept
868/// through resolution and typeck and discarded afterward. Two designs were
869/// available and only one is actually correct:
870///
871/// - *Strip before resolution* (this one): the function never gets a
872///   `DefId`, never enters its module's scope, and its body is never visited
873///   by anything — the same "does not exist in this build" semantics as
874///   Rust's `#[cfg(test)]`. Anything that still tries to call it by name
875///   (unusual, but not forbidden by anything in the language) gets the
876///   ordinary, well-tested "cannot find function" `E0001`, exactly as if the
877///   function had never been written.
878/// - *Strip after resolution* (e.g. filter `nova_typeck::check`'s per-`DefId`
879///   iteration instead): resolution would still assign the function a real
880///   `DefId` and scope entry, and `collect_signatures` runs before any
881///   per-function body pass, so *other*, kept, functions could still
882///   type-check a call to it successfully against a signature that then has
883///   no corresponding `hir::Function` body in the compiled module — a
884///   dangling `DefId` reference that MIR lowering has no defined behavior
885///   for. This is strictly worse than the bug being fixed: today's failure
886///   is a clean compile error; that failure mode would be a compiler panic
887///   or a silently broken binary, and only in the one case where non-test
888///   code happens to reference a test function by name.
889///
890/// Deliberately done in `nova-driver`, not `nova_resolver`, and keyed off
891/// this driver's own `with_test_module` rather than any new parameter on
892/// `nova_resolver::resolve_program`: `nova_resolver::resolve`/`resolve_program`
893/// are also called directly, with no notion of "compiling for `nova test`",
894/// by a large fraction of `nova-resolver`'s and `nova-typeck`'s own unit
895/// tests — including the very tests that pin `E0082`-`E0085` and `TestFn`
896/// collection on `@test` functions (`nova-typeck/src/check.rs`'s
897/// `an_unknown_attribute_is_e0082_and_names_it` and neighbors). Teaching
898/// `resolve_program` to drop `@test` items itself, gated on whether it was
899/// given `extra_std`, would silently empty out every one of those functions'
900/// subject items before validation ever ran, breaking them. Confining the
901/// strip to this driver function leaves `nova_resolver`'s validation and
902/// collection behavior — and every test that exercises it directly —
903/// completely unchanged.
904///
905/// As a consequence, `nova_resolver::validate_test_function`'s attribute-
906/// shape diagnostics (`E0082`-`E0085`) do not fire for a stripped function
907/// — matching `#[cfg(test)]`, where an invalid test signature is likewise
908/// uncaught outside `cargo test`. `nova test` validates the shape of every
909/// `@test` function it keeps.
910fn strip_test_functions(modules: &mut [Loaded], keep_root_tests: bool) {
911    let doc_tests = modules.iter().any(|m| m.doc);
912    for module in modules.iter_mut() {
913        let root = matches!(module.package, None | Some(PackageId(0)));
914        let doc = module.doc;
915        module.ast.items.retain(|item| match &item.value {
916            nova_ast::Item::Function(f) if f.attrs.iter().any(|a| a.name.value == "test") => {
917                keep_root_tests
918                    && if doc_tests {
919                        doc && f.name.value == DOC_TEST_FUNCTION
920                    } else {
921                        root
922                    }
923            }
924            _ => true,
925        });
926    }
927}
928
929/// What doc tests need of a build (spec
930/// `docs/superpowers/specs/2026-10-11-phase-3-5b-doc-tests-design.md`
931/// §7, §11.2), where only this crate can see it.
932#[cfg(test)]
933mod doc_test_builds {
934    use super::*;
935
936    /// Every path holds an empty program.
937    struct Memory;
938
939    impl Sources for Memory {
940        fn read(&self, _: &Path) -> std::io::Result<String> {
941            Ok("fn main() {}\n".to_string())
942        }
943    }
944
945    #[test]
946    fn a_map_hides_the_warnings_it_rejects_and_never_an_error() {
947        let map = |_: &mut FileDb, _: &mut Diagnostic| false;
948        let program = Program::loose(Path::new("<nova driver: memory>/main.nova"));
949        let mut ctx = FrontendContext::with(program, &Memory, Some(&map)).expect("the entry reads");
950        ctx.render(&[
951            Diagnostic::warning("W0000", "a warning"),
952            Diagnostic::error("E0000", "an error"),
953        ]);
954        assert_eq!((ctx.errors, ctx.warnings), (1, 0));
955    }
956
957    #[test]
958    fn a_path_no_file_holds_is_fingerprinted_from_the_current_directory() {
959        // Two packages' relative `<doc tests>/doc0.nova` must not share a
960        // test binary.
961        let relative = Path::new("<nova driver: no such dir>").join("doc0.nova");
962        let absolute = std::env::current_dir()
963            .expect("a current directory")
964            .join(&relative);
965        assert_eq!(path_fingerprint(&relative), path_fingerprint(&absolute));
966    }
967
968    #[test]
969    fn std_source_gives_a_std_modules_text_by_its_name_in_the_database() {
970        assert_eq!(
971            std_source("<std/core>"),
972            Some(nova_resolver::STD_MODULES[0].1)
973        );
974        assert_eq!(
975            std_source("<std/time>"),
976            Some(nova_resolver::STD_MODULES[10].1)
977        );
978        assert_eq!(
979            std_source("<std/test>"),
980            Some(nova_resolver::STD_TEST_MODULE.1)
981        );
982        assert_eq!(std_source("<std/nope>"), None);
983        assert_eq!(std_source("std/time/lib.nova"), None);
984        assert_eq!(std_source("src/lib.nova"), None);
985    }
986}
987
988/// End-to-end proof that a generated `async fn` actually *runs*: the future
989/// this compiler builds, JIT-compiled, driven by the real `nova-runtime`
990/// executor, with the value read back out of the state object's output slot.
991///
992/// This lives here rather than in `nova-codegen-cranelift` (which owns the
993/// MIR → native seam) or `nova-cli` (which owns `nova run`) because it needs
994/// both halves at once: the whole front end, to get real MIR out of real
995/// source, and an in-process runtime, to poll the resulting future and inspect
996/// the answer. `nova-driver` is the only crate that already has the front end
997/// and the JIT; `nova-runtime` is a dev-dependency for the executor entry
998/// points.
999///
1000/// **Why `main` is synthesized in MIR instead of written in Nova.** Nothing in
1001/// the language can reach `block_on` yet. Not because a future cannot be
1002/// *named* — `Future` is a nameable type, handled by `nova-typeck`'s
1003/// `convert_ty` ahead of its primitive table, so `fn take(f: Future<Int>)`
1004/// type-checks — but because nothing in source can *drive* one: there is no
1005/// poll or await outside an `async fn`, and an `extern` signature accepts only
1006/// `Int`, `Float` and `Bool`, so no foreign function can be handed one either.
1007/// Task 7's `std/task` is what closes that. Until then the only way to
1008/// *execute* a generated poll function is to
1009/// build the calling code at the level where futures do exist — MIR — which is
1010/// exactly the level `nova-cli`'s `nova run` tests cannot reach. So those tests
1011/// assert that such a program compiles and runs; these assert that it computes
1012/// the right value.
1013#[cfg(test)]
1014mod async_end_to_end {
1015    use nova_diagnostics::FileId;
1016    use nova_mir::{Function, MirTy, Module, RtFunc, Stmt, Temp, Terminator};
1017
1018    /// One probe per test, because the executor's task table is a
1019    /// `thread_local!` and libtest gives each `#[test]` its own thread: the
1020    /// probe's own task is therefore always id 0. A second probe on the same
1021    /// thread would shift the ids and make `output_bits(0)` quietly return the
1022    /// first probe's answer, so that is refused rather than reasoned about.
1023    const PROBE_TASK_ID: i64 = 0;
1024
1025    thread_local! {
1026        static PROBED: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
1027    }
1028
1029    /// An argument to pass to the async function under test.
1030    enum Arg {
1031        Int(i64),
1032        Float(f64),
1033    }
1034
1035    /// Compile `src`, call its `async fn <name>` with `args`, drive the
1036    /// resulting future to completion through the real executor, and return
1037    /// the raw 64 bits of its output slot.
1038    ///
1039    /// Raw bits, not a typed value: `STATE_SLOT_OUTPUT` is one 8-byte slot that
1040    /// the poll function stores through with the output's own machine class, and
1041    /// the executor copies out as an `i64` because it cannot tell a `Float` from
1042    /// an `Int` from a pointer. Comparing against `f64::to_bits` is therefore
1043    /// the strongest available check on a `Float` output — it fails if the store
1044    /// or the load used the wrong class, the wrong offset, or the wrong width.
1045    fn run_async_fn(src: &str, name: &str, args: &[Arg]) -> i64 {
1046        run_async_fn_with(src, name, args, &|_| {})
1047    }
1048
1049    /// As [`run_async_fn`], with `fixup` applied to the lowered module before it
1050    /// is compiled.
1051    ///
1052    /// The hook exists for exactly one thing: substituting a poll function that
1053    /// actually suspends. Nothing compiled from Nova source can, in this phase —
1054    /// every future's poll either completes on its first call or stops at an
1055    /// await, and the awaits bottom out in an `async fn` with none, so the whole
1056    /// chain reports `POLL_READY` on the first poll. The `POLL_PENDING` path
1057    /// through a generated poll function is therefore unreachable from source
1058    /// until a primitive that parks exists, and the only way to execute it now is
1059    /// to hand the *awaiting* function -- real generated code, unmodified -- an
1060    /// inner future whose poll pends.
1061    fn run_async_fn_with(src: &str, name: &str, args: &[Arg], fixup: &dyn Fn(&mut Module)) -> i64 {
1062        assert!(
1063            !PROBED.with(|p| p.replace(true)),
1064            "one probe per test: see PROBE_TASK_ID"
1065        );
1066
1067        let (tokens, lex_errors) = nova_lexer::lex(src, FileId::DUMMY);
1068        assert!(lex_errors.is_empty(), "lex: {lex_errors:?}");
1069        let (ast, parse_errors) = nova_parser::parse(&tokens, FileId::DUMMY);
1070        assert!(parse_errors.is_empty(), "parse: {parse_errors:?}");
1071        let resolved = nova_resolver::resolve(&ast.expect("no AST"));
1072        assert!(
1073            resolved.diagnostics.is_empty(),
1074            "resolve: {:?}",
1075            resolved.diagnostics
1076        );
1077        let checked = nova_typeck::check(&resolved.file, &resolved.definitions);
1078        assert!(
1079            checked.diagnostics.is_empty(),
1080            "typeck: {:?}",
1081            checked.diagnostics
1082        );
1083        let mut mir = nova_mir::lower_module(&checked.module).expect("MIR lowering");
1084
1085        // The wrapper: the half of the transform that kept the original
1086        // mangled symbol. Its `$poll` sibling is deliberately NOT called
1087        // directly -- going through the wrapper is what exercises the state
1088        // allocation, the parameter seeding and the fat-pointer construction.
1089        let prefix = format!("{name}.");
1090        let wrapper = mir
1091            .functions
1092            .iter()
1093            .map(|f| f.name.clone())
1094            .find(|n| n.starts_with(&prefix) && !n.ends_with("$poll"))
1095            .unwrap_or_else(|| {
1096                panic!(
1097                    "no wrapper for `{name}`; have {:?}",
1098                    mir.functions.iter().map(|f| &f.name).collect::<Vec<_>>()
1099                )
1100            });
1101        assert!(
1102            mir.functions
1103                .iter()
1104                .any(|f| f.name == format!("{wrapper}$poll")),
1105            "the transform must also have emitted `{wrapper}$poll`"
1106        );
1107
1108        fixup(&mut mir);
1109        replace_main(&mut mir, &wrapper, args);
1110        let program = nova_codegen_cranelift::compile_jit(&mir).expect("JIT compile");
1111        program.run();
1112
1113        // A poll function returning a status that is neither POLL_PENDING nor
1114        // POLL_READY does not reach this call at all, and does not present as
1115        // a test failure either. `poll_one` panics naming the value, and that
1116        // panic then has to unwind out through the JIT-compiled `main` frame --
1117        // Cranelift output with no landing pads -- so **the process dies** and
1118        // the harness reports only that the test exited abnormally. Run the one
1119        // test with `-- --nocapture` to see the executor's message, which does
1120        // name the offending status.
1121        //
1122        // A task left queued rather than polled -- the other failure shape --
1123        // is caught by `output_bits` itself: it asserts the task is done
1124        // before handing back its output, so that case panics here with its
1125        // own message rather than silently returning the `output: 0`
1126        // initializer. There is no `nova_rt_task_is_done` pre-check for it any
1127        // more: that entry point now takes a future, not a task id, and this
1128        // harness only ever kept `PROBE_TASK_ID`, the id `TaskSpawn` returned
1129        // to the JIT-compiled `main` -- the future itself was never a value
1130        // this Rust frame held, so there is nothing to pass it.
1131        //
1132        // `output_bits` rather than `nova_rt_task_take_output`: the probe is a
1133        // *spawned* task, and a spawned task's GC root is released at
1134        // completion, so its output can no longer be taken. Every probe in
1135        // this module returns a scalar (`Int`, `Float`, `Bool` or unit),
1136        // which is what `output_bits` is for -- its bits are read, not
1137        // dereferenced.
1138        nova_runtime::task::output_bits(PROBE_TASK_ID)
1139    }
1140
1141    /// Overwrite `main` with MIR that builds the future twice: once spawned as
1142    /// the probe (whose output copy the caller reads with `output_bits`), and
1143    /// once as `block_on`'s root, whose only job is to drain the queue so the
1144    /// probe gets polled. Two separate futures, because one future must not be
1145    /// registered as two tasks sharing a state object.
1146    fn replace_main(mir: &mut Module, wrapper: &str, args: &[Arg]) {
1147        let mut temps: Vec<MirTy> = Vec::new();
1148        let mut stmts: Vec<Stmt> = Vec::new();
1149        fn push(temps: &mut Vec<MirTy>, ty: MirTy) -> Temp {
1150            let t = Temp(temps.len() as u32);
1151            temps.push(ty);
1152            t
1153        }
1154
1155        let mut arg_temps = Vec::new();
1156        for a in args {
1157            match a {
1158                Arg::Int(v) => {
1159                    let t = push(&mut temps, MirTy::I64);
1160                    stmts.push(Stmt::ConstInt(t, *v));
1161                    arg_temps.push(t);
1162                }
1163                Arg::Float(v) => {
1164                    let t = push(&mut temps, MirTy::F64);
1165                    stmts.push(Stmt::ConstFloat(t, *v));
1166                    arg_temps.push(t);
1167                }
1168            }
1169        }
1170
1171        for spawn in [true, false] {
1172            let fut = push(&mut temps, MirTy::Ptr);
1173            stmts.push(Stmt::Call {
1174                dst: Some(fut),
1175                callee: wrapper.to_string(),
1176                args: arg_temps.clone(),
1177            });
1178            let status = push(&mut temps, MirTy::I64);
1179            stmts.push(Stmt::CallRuntime {
1180                dst: Some(status),
1181                func: if spawn {
1182                    RtFunc::TaskSpawn
1183                } else {
1184                    RtFunc::TaskBlockOn
1185                },
1186                args: vec![fut],
1187            });
1188        }
1189
1190        let main = mir
1191            .functions
1192            .iter_mut()
1193            .find(|f| f.name == "main")
1194            .expect("`main` was lowered");
1195        *main = Function {
1196            name: "main".to_string(),
1197            params: 0,
1198            takes_env: false,
1199            capture_count: 0,
1200            temps,
1201            ret: MirTy::Unit,
1202            is_async: false,
1203            blocks: vec![nova_mir::Block {
1204                stmts,
1205                term: Terminator::Return(None),
1206            }],
1207        };
1208    }
1209
1210    #[test]
1211    fn an_await_free_async_fn_returning_int_runs_and_produces_its_value() {
1212        let out = run_async_fn(
1213            "async fn f() -> Int { 40 + 2 }\nfn main() { let x = f() }",
1214            "f",
1215            &[],
1216        );
1217        assert_eq!(out, 42);
1218    }
1219
1220    /// **The `Float` case, and the one that matters most.** `mir_ty` collapses
1221    /// `Int`, `Char` and every pointer-like type onto one 64-bit integer class,
1222    /// so an `Int` probe passes even if the output is stored, loaded or
1223    /// returned through the wrong one of them. `F64` is the only class that
1224    /// crosses register banks, and the exact type at which this project already
1225    /// shipped a reachable ICE: an `async fn f() -> Float { 1.5 }` merely
1226    /// called from `main` hit a Cranelift verifier error that the `Int` version
1227    /// silently survived.
1228    #[test]
1229    fn an_await_free_async_fn_returning_float_runs_and_produces_its_value() {
1230        let out = run_async_fn(
1231            "async fn f() -> Float { 1.5 }\nfn main() { let x = f() }",
1232            "f",
1233            &[],
1234        );
1235        assert_eq!(
1236            out as u64,
1237            1.5f64.to_bits(),
1238            "the output slot must hold 1.5 as an f64 bit pattern, not an \
1239             integer 1 or a truncation: got {out:#x}, want {:#x}",
1240            1.5f64.to_bits()
1241        );
1242    }
1243
1244    /// **`std/core`'s `mix64` and the runtime's `splitmix64_finalize` compute the
1245    /// same function, and until these two probes nothing asserted it.** The
1246    /// records have said it for some time -- ADR 0005 describes `mix64` as the
1247    /// splitmix64 finalizer written in signed Nova with masked shifts, and the
1248    /// runtime's own doc comment says the constants correspond -- but each side
1249    /// was only ever pinned against *published* splitmix64 vectors, separately.
1250    /// Two implementations can both match a published vector at two points and
1251    /// still disagree elsewhere; nothing compared them to each other.
1252    ///
1253    /// The comparison is possible without inverting anything because the seed is
1254    /// a process-global `OnceLock` in `nova-runtime`, and `nova-driver` takes
1255    /// that crate as a dev-dependency of the *same instance* -- so
1256    /// `nova_rt_int_hash_seed()` here returns the very value the JIT-compiled
1257    /// code XORed in. Whichever side reaches the `OnceLock` first initialises
1258    /// it; order does not matter.
1259    ///
1260    /// One probe per test is the module's rule, so the many-key form has to
1261    /// arrive as a single `Int`: the Nova body XOR-accumulates `(k).hash()` over
1262    /// 128 keys spanning both signs, and the assertion recomputes that fold from
1263    /// `splitmix64_finalize`. A constant or a shift drifting on either side
1264    /// changes the fold with overwhelming probability -- a mask mutation alters
1265    /// roughly half of all inputs, so surviving 128 keys is a `2^-128`-order
1266    /// event rather than a coincidence to worry about.
1267    ///
1268    /// Both signs on purpose. Nova's `>>` is arithmetic and `mix64` masks each
1269    /// shift to emulate a logical one, so a dropped mask is visible only on
1270    /// inputs whose sign bit is set -- and the seed makes the *hashed* value's
1271    /// sign unpredictable from the key's, which is why the range is chosen
1272    /// rather than the mask relied upon.
1273    ///
1274    /// **What these two probes cover that nothing else did, counted rather than
1275    /// reasoned.** Both mutations below were applied and the whole workspace
1276    /// suite run for each, and the failures enumerated:
1277    ///
1278    /// - The runtime's `splitmix64_finalize` first constant, last nibble `9` to
1279    ///   `b`: **two failures across the suite, and they are these two probes.**
1280    ///   Nothing else noticed. `nova_rt_str_hash` routes through that function,
1281    ///   but every test of it asserts a relation -- same key same hash, different
1282    ///   keys different hashes -- and a relation survives any bijective change.
1283    /// - Nova's `mix64` first constant, `-4658895280553007687` to
1284    ///   `...685`: four failures -- `hash_run`, `hash_build_standalone` and these
1285    ///   two. The golden's `splitmix64 canonical x1/x3` line reads `false`
1286    ///   `false`, which is the published-vector check doing its job.
1287    ///
1288    /// So Nova-side drift already had a witness and Rust-side drift had none.
1289    /// That asymmetry is the whole reason these exist; the fold's own margin is
1290    /// secondary.
1291    ///
1292    /// `Bool` and `Char` get no probe of their own, deliberately. All three
1293    /// impls call this one `mix64`, differing only in what they feed it, and
1294    /// `tests/runtime/hash.stdout`'s `char/int agree` line already pins that
1295    /// agreement -- so a `Bool` or `Char` probe would cost a full compile and
1296    /// JIT for nothing this fold does not already establish.
1297    #[test]
1298    fn mix64_agrees_with_the_runtime_finalizer_over_keys_of_both_signs() {
1299        let out = run_async_fn(
1300            "async fn fold() -> Int {\n\
1301            \x20 let mut acc = 0\n\
1302            \x20 let mut k = -64\n\
1303            \x20 while k < 64 {\n\
1304            \x20   acc = acc ^ (k).hash()\n\
1305            \x20   k = k + 1\n\
1306            \x20 }\n\
1307            \x20 acc\n\
1308             }\n\
1309             fn main() { let x = fold() }",
1310            "fold",
1311            &[],
1312        );
1313        let seed = nova_runtime::nova_rt_int_hash_seed() as u64;
1314        let want = (-64i64..64).fold(0i64, |acc, k| {
1315            acc ^ nova_runtime::splitmix64_finalize((k as u64) ^ seed) as i64
1316        });
1317        assert_eq!(
1318            out, want,
1319            "Nova's `mix64` and the runtime's `splitmix64_finalize` disagree \
1320             somewhere in the keys -64..63. Both are the splitmix64 finalizer \
1321             and the records say so; one of them has drifted. The single-key \
1322             probe names a specific input."
1323        );
1324    }
1325
1326    /// The same tie at one key, kept for diagnosis rather than for coverage.
1327    /// When the fold above fails it reports two folded totals, which say nothing
1328    /// about *where*; this one fails with a concrete input and two concrete
1329    /// hashes. `-1` is chosen because every one of `mix64`'s three masked shifts
1330    /// sign-extends on it before the seed is applied, so a dropped mask is most
1331    /// likely to show here.
1332    #[test]
1333    fn mix64_agrees_with_the_runtime_finalizer_at_a_single_key() {
1334        let out = run_async_fn(
1335            "async fn one() -> Int { (-1).hash() }\n\
1336             fn main() { let x = one() }",
1337            "one",
1338            &[],
1339        );
1340        let seed = nova_runtime::nova_rt_int_hash_seed() as u64;
1341        let want = nova_runtime::splitmix64_finalize((-1i64 as u64) ^ seed) as i64;
1342        assert_eq!(
1343            out, want,
1344            "`(-1).hash()` in Nova and `splitmix64_finalize(-1 ^ seed)` in the \
1345             runtime must agree: got {out:#x}, want {want:#x}"
1346        );
1347    }
1348
1349    /// Arguments reach the WRAPPER, not `poll` -- so a wrapper that forgets to
1350    /// copy them into their state slots leaves the body reading the allocator's
1351    /// zeroes. At `Float`, which also proves the seeding store and the body's
1352    /// reload agree on the register class, not merely on the offset.
1353    #[test]
1354    fn an_async_fns_parameters_reach_its_body_through_the_state_object() {
1355        let out = run_async_fn(
1356            "async fn add(a: Float, b: Float) -> Float { a + b }\n\
1357             fn main() { let x = add(1.0, 2.0) }",
1358            "add",
1359            &[Arg::Float(1.5), Arg::Float(2.25)],
1360        );
1361        assert_eq!(
1362            out as u64,
1363            3.75f64.to_bits(),
1364            "1.5 + 2.25 must reach the body; a zeroed parameter slot yields \
1365             0.0. Commutative, so `parameter_slots_are_seeded_in_order` \
1366             covers ordering: got {out:#x}"
1367        );
1368    }
1369
1370    /// Ordering, which a commutative operator cannot see: `a - b` distinguishes
1371    /// parameter slot 0 from slot 1.
1372    #[test]
1373    fn parameter_slots_are_seeded_in_order() {
1374        let out = run_async_fn(
1375            "async fn sub(a: Float, b: Float) -> Float { a - b }\n\
1376             fn main() { let x = sub(1.0, 2.0) }",
1377            "sub",
1378            &[Arg::Float(10.0), Arg::Float(2.5)],
1379        );
1380        assert_eq!(
1381            out as u64,
1382            7.5f64.to_bits(),
1383            "10.0 - 2.5 = 7.5; reversed slots would give -7.5: got {out:#x}"
1384        );
1385    }
1386
1387    /// A value carried across block boundaries entirely through state slots --
1388    /// the property Task 6 relies on. A `while` loop's accumulator is written
1389    /// in the body block and read in the header block, so if the spill were
1390    /// incomplete this returns garbage or zero rather than the sum.
1391    #[test]
1392    fn a_loop_accumulator_survives_the_spill_across_blocks() {
1393        let out = run_async_fn(
1394            "async fn total(n: Int) -> Int {\n  \
1395             let mut i = 0\n  \
1396             let mut t = 0\n  \
1397             while i < n { t = t + i\n i = i + 1 }\n  \
1398             t\n\
1399             }\n\
1400             fn main() { let x = total(1) }",
1401            "total",
1402            &[Arg::Int(10)],
1403        );
1404        assert_eq!(out, 45, "0+1+..+9 = 45");
1405    }
1406
1407    /// A heap value built INSIDE the poll function, then reduced to a scalar
1408    /// output. The state object holds the interpolated `String`'s pointer in a
1409    /// temp slot while `str_len_chars` is called on it, so this covers three
1410    /// things no other probe here does: `nova_rt_alloc` running from inside a
1411    /// generated poll function, a `Ptr`-class value round-tripping through a
1412    /// state slot, and a runtime call in a poll body.
1413    ///
1414    /// A `String` OUTPUT is not asserted directly, because nothing public in
1415    /// `nova-runtime` decodes a `NovaStr` -- the output slot would only be
1416    /// readable as an opaque non-zero pointer, which a wrong slot could
1417    /// accidentally satisfy. Comparing the string inside Nova and returning the
1418    /// `Bool` checks the value instead, and adds the third distinct output class
1419    /// (`MirTy::I8`, an 8-byte slot holding one byte) to the two above.
1420    #[test]
1421    fn a_heap_value_allocated_inside_the_poll_fn_round_trips_through_a_state_slot() {
1422        let out = run_async_fn(
1423            "async fn f(n: Int) -> Bool { let s = \"ab${n}cd\"
1424 s == \"ab7cd\" }
1425             fn main() { let x = f(1) }",
1426            "f",
1427            &[Arg::Int(7)],
1428        );
1429        assert_eq!(
1430            out & 0xff, 1,
1431            "the interpolated string must equal \"ab7cd\"; a parameter that did              not reach the body would build \"ab0cd\" and give 0: got {out:#x}"
1432        );
1433    }
1434
1435    /// Replace `<name>`'s generated poll function with one that reports
1436    /// `POLL_PENDING` the first `pends` calls on a given state object and then
1437    /// completes with `value`.
1438    ///
1439    /// Hand-built MIR obeying the same ABI the transform generates against: it
1440    /// reads the resume tag out of slot `STATE_SLOT_TAG`, advances it, writes its
1441    /// `Float` result to `STATE_SLOT_OUTPUT` and returns a status. Only the
1442    /// *awaited* function is substituted; the function under test is the real
1443    /// generated code, so what the probe exercises is its suspend and its resume.
1444    ///
1445    /// One state object per call to `<name>`, since each call allocates its own,
1446    /// so a stub built with `pends = 1` pends once per await rather than once per
1447    /// program.
1448    fn replace_poll_with_a_pending_stub(mir: &mut Module, name: &str, pends: i64, value: f64) {
1449        let prefix = format!("{name}.");
1450        let poll = mir
1451            .functions
1452            .iter_mut()
1453            .find(|f| f.name.starts_with(&prefix) && f.name.ends_with("$poll"))
1454            .unwrap_or_else(|| panic!("no `$poll` for `{name}`"));
1455
1456        // Temp 0 is the state object (the poll ABI's env), temp 1 is task_ctx.
1457        let mut temps = vec![MirTy::Ptr, MirTy::Ptr];
1458        fn push(temps: &mut Vec<MirTy>, ty: MirTy) -> Temp {
1459            let t = Temp(temps.len() as u32);
1460            temps.push(ty);
1461            t
1462        }
1463        let state = Temp(0);
1464
1465        // Block 0 dispatches on the tag: tag k (k < pends) goes to the k-th
1466        // suspend block, and anything else -- which is tag `pends` -- completes.
1467        let tag = push(&mut temps, MirTy::I64);
1468        let dispatch = nova_mir::Block {
1469            stmts: vec![Stmt::RecordField {
1470                dst: tag,
1471                record: state,
1472                index: nova_mir::STATE_SLOT_TAG,
1473                ty: MirTy::I64,
1474            }],
1475            term: Terminator::Switch {
1476                disc: tag,
1477                arms: (0..pends)
1478                    .map(|k| (k, nova_mir::BlockId(k as u32 + 1)))
1479                    .collect(),
1480                default: nova_mir::BlockId(pends as u32 + 1),
1481            },
1482        };
1483
1484        let mut blocks = vec![dispatch];
1485        for k in 0..pends {
1486            let next = push(&mut temps, MirTy::I64);
1487            let pending = push(&mut temps, MirTy::I64);
1488            blocks.push(nova_mir::Block {
1489                stmts: vec![
1490                    Stmt::ConstInt(next, k + 1),
1491                    Stmt::SetField {
1492                        record: state,
1493                        index: nova_mir::STATE_SLOT_TAG,
1494                        value: next,
1495                        ty: MirTy::I64,
1496                    },
1497                    Stmt::ConstInt(pending, nova_mir::POLL_PENDING),
1498                ],
1499                term: Terminator::Return(Some(pending)),
1500            });
1501        }
1502        let out = push(&mut temps, MirTy::F64);
1503        let ready = push(&mut temps, MirTy::I64);
1504        blocks.push(nova_mir::Block {
1505            stmts: vec![
1506                Stmt::ConstFloat(out, value),
1507                Stmt::SetField {
1508                    record: state,
1509                    index: nova_mir::STATE_SLOT_OUTPUT,
1510                    value: out,
1511                    ty: MirTy::F64,
1512                },
1513                Stmt::ConstInt(ready, nova_mir::POLL_READY),
1514            ],
1515            term: Terminator::Return(Some(ready)),
1516        });
1517
1518        *poll = Function {
1519            name: poll.name.clone(),
1520            params: 1,
1521            takes_env: true,
1522            capture_count: 0,
1523            temps,
1524            ret: MirTy::I64,
1525            is_async: false,
1526            blocks,
1527        };
1528    }
1529
1530    /// **A real suspend and a real resume.** The awaited future pends twice, so
1531    /// the awaiting function -- ordinary generated code -- returns `POLL_PENDING`
1532    /// to the executor, is re-queued, and comes back into the same await twice
1533    /// before the value arrives.
1534    ///
1535    /// This is the probe for the same-tag rule. A suspend that stored the tag
1536    /// *after* its own would resume past this await: the second poll would
1537    /// dispatch on a tag with no arm and reach the trap block, which aborts the
1538    /// process rather than failing the assertion -- so a run that gets as far as
1539    /// comparing a value has already proved the tag came back to the await.
1540    ///
1541    /// At `Float`, and with `+ 1.0` after the await, so the awaited value has to
1542    /// have travelled through the inner state object's output slot as an f64 and
1543    /// then been used: reading the slot before the inner future wrote it yields
1544    /// `0.0`, giving `1.0` rather than `3.5`.
1545    #[test]
1546    fn an_await_on_a_twice_pending_future_resumes_at_the_same_await() {
1547        let out = run_async_fn_with(
1548            "async fn g() -> Float { 0.0 }\n\
1549             async fn f() -> Float { g().await + 1.0 }\n\
1550             fn main() { let x = f() }",
1551            "f",
1552            &[],
1553            &|mir| replace_poll_with_a_pending_stub(mir, "g", 2, 2.5),
1554        );
1555        assert_eq!(
1556            out as u64,
1557            3.5f64.to_bits(),
1558            "2.5 + 1.0 must come back from the resumed await; an output slot read \
1559             before the inner future wrote it gives 1.0: got {out:#x}, want {:#x}",
1560            3.5f64.to_bits()
1561        );
1562    }
1563
1564    /// An await **inside a loop**, suspending on every iteration: one suspend
1565    /// point resumed at four separate times, with the loop's own accumulator and
1566    /// counter living in state slots across each suspension.
1567    ///
1568    /// Three things only this shape reaches. The loop's back edge has to follow
1569    /// the renumbering the split introduces, or it lands in the resumable poll
1570    /// function's trap block on the second iteration. The resume has to re-enter
1571    /// the await rather than the loop header, or the iteration count is wrong. And
1572    /// the accumulator has to survive a *suspend*, not merely a block boundary --
1573    /// the await-free loop probe above already covers the latter, and would pass
1574    /// with the accumulator living in a register across the whole poll call.
1575    ///
1576    /// The result discriminates each: a lost accumulator gives `0.5`, a lost
1577    /// counter loops forever or once, and a wrong awaited value gives `0.0`.
1578    #[test]
1579    fn an_await_inside_a_loop_suspends_and_resumes_on_every_iteration() {
1580        let out = run_async_fn_with(
1581            "async fn half() -> Float { 0.0 }\n\
1582             async fn total(n: Int) -> Float {\n  \
1583             let mut i = 0\n  \
1584             let mut t = 0.0\n  \
1585             while i < n { t = t + half().await\n i = i + 1 }\n  \
1586             t\n\
1587             }\n\
1588             fn main() { let x = total(1) }",
1589            "total",
1590            &[Arg::Int(4)],
1591            &|mir| replace_poll_with_a_pending_stub(mir, "half", 1, 0.5),
1592        );
1593        assert_eq!(
1594            out as u64,
1595            2.0f64.to_bits(),
1596            "four iterations of 0.5 must accumulate to 2.0 across four \
1597             suspensions: got {out:#x}, want {:#x}",
1598            2.0f64.to_bits()
1599        );
1600    }
1601
1602    /// The ready path, with no stub anywhere: a suspend point whose inner future
1603    /// completes on its first poll, so the await falls straight through to its
1604    /// continuation. This is what every `.await` a `.nova` file can write does
1605    /// today, and it is a different path through the status switch than the two
1606    /// probes above take.
1607    #[test]
1608    fn an_await_on_an_already_ready_future_falls_through_to_its_continuation() {
1609        let out = run_async_fn(
1610            "async fn g(x: Float) -> Float { x * 2.0 }\n\
1611             async fn f(a: Float) -> Float { g(a).await + 0.25 }\n\
1612             fn main() { let x = f(1.0) }",
1613            "f",
1614            &[Arg::Float(1.5)],
1615        );
1616        assert_eq!(
1617            out as u64,
1618            3.25f64.to_bits(),
1619            "1.5 * 2.0 + 0.25 = 3.25; the awaited value must be the inner \
1620             future's own output, not its argument or zero: got {out:#x}, want {:#x}",
1621            3.25f64.to_bits()
1622        );
1623    }
1624
1625    /// Two awaits in one body, so the resume tags have to be distinct and the
1626    /// second await's continuation has to be reachable from the first's.
1627    /// Subtraction, not addition: reversed continuations give `-1.5`.
1628    #[test]
1629    fn two_awaits_in_one_body_each_get_their_own_resume_state() {
1630        let out = run_async_fn(
1631            "async fn g(x: Float) -> Float { x }\n\
1632             async fn f() -> Float { g(4.0).await - g(2.5).await }\n\
1633             fn main() { let x = f() }",
1634            "f",
1635            &[],
1636        );
1637        assert_eq!(
1638            out as u64,
1639            1.5f64.to_bits(),
1640            "4.0 - 2.5 = 1.5: got {out:#x}, want {:#x}",
1641            1.5f64.to_bits()
1642        );
1643    }
1644
1645    /// An `.await` inside a `match` arm, executed. The suspend point is in a
1646    /// block reached through the match's own tag `Switch`, whose arm targets the
1647    /// split has to renumber -- an arm left naming its pre-split id reaches the
1648    /// resumable poll function's trap block, which aborts the process rather than
1649    /// producing a wrong value.
1650    ///
1651    /// The scrutinee is built inside the `async fn` rather than passed in, since
1652    /// a probe argument can only be a scalar.
1653    #[test]
1654    fn an_await_inside_a_match_arm_runs_the_arm_it_selected() {
1655        let out = run_async_fn(
1656            "type Choice = | Left | Right\n\
1657             async fn g(x: Float) -> Float { x }\n\
1658             async fn f(a: Float) -> Float {\n  \
1659             let c: Choice = Left\n  \
1660             match c { Left => g(a).await + 1.0, Right => 0.0, }\n\
1661             }\n\
1662             fn main() { let x = f(1.0) }",
1663            "f",
1664            &[Arg::Float(2.5)],
1665        );
1666        assert_eq!(
1667            out as u64,
1668            3.5f64.to_bits(),
1669            "the `Left` arm awaits and adds 1.0, so 2.5 gives 3.5; the `Right` \
1670             arm would give 0.0: got {out:#x}, want {:#x}",
1671            3.5f64.to_bits()
1672        );
1673    }
1674
1675    /// A unit-output await: nothing is copied out of the inner future's output
1676    /// slot, but the inner future still has to be polled and the continuation
1677    /// still has to run. The `Float` the body returns afterwards is what shows it
1678    /// did.
1679    #[test]
1680    fn a_unit_output_await_still_polls_its_future_and_continues() {
1681        let out = run_async_fn(
1682            "async fn g() { }\n\
1683             async fn f(a: Float) -> Float { g().await\n a + 1.0 }\n\
1684             fn main() { let x = f(1.0) }",
1685            "f",
1686            &[Arg::Float(0.5)],
1687        );
1688        assert_eq!(
1689            out as u64,
1690            1.5f64.to_bits(),
1691            "0.5 + 1.0 = 1.5 must run after the unit await: got {out:#x}, want {:#x}",
1692            1.5f64.to_bits()
1693        );
1694    }
1695
1696    /// A unit-returning `async fn` never touches the output slot itself, yet
1697    /// the executor reads it on completion regardless. What is asserted here is
1698    /// that the run completes at all: reaching `output_bits` means the poll
1699    /// function returned exactly `POLL_READY` and the executor's unconditional
1700    /// read of `STATE_SLOT_OUTPUT` stayed inside a state object with the
1701    /// fewest temp slots any body can have -- the `STATE_MIN_SIZE` case.
1702    #[test]
1703    fn a_unit_returning_async_fn_completes_and_reads_a_valid_output_slot() {
1704        let out = run_async_fn("async fn f() { }\nfn main() { let x = f() }", "f", &[]);
1705        assert_eq!(out, 0, "the explicit zero the transform stores for unit");
1706    }
1707}