typelisp 0.1.1

A statically typed Lisp with an interpreter and a compiler to native executables (LLVM)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
//! The committed *prelude dump*: `crates/typelisp-front/src/prelude.typld`,
//! holding both halves of compiling the prelude — the checked state its
//! definitions produced, and the native bodies for every one that can have a
//! body.
//!
//! The same shape as [`crate::compile::bootstrap`]'s compiler island —
//! [`build_prelude_artifact`] compiles into one shared module and writes the
//! pair, `src/bin/bootstrap_prelude.rs` commits the file, [`load`] applies it —
//! with three differences worth naming.
//!
//! **This is about both execution speed and load time.** The bitcode half is
//! what makes a later call to `gcd` or `abs` run compiled instead of
//! tree-walked. The checked-state half is what removes the read and the type
//! check from every startup: 27ms + 136ms of a 1.50s `typl` startup, measured
//! by `typl-bench-prelude`.
//!
//! **Not everything can be precompiled, and the boundary is not a judgement
//! call.** A generic definition has no single body to compile — the checker
//! monomorphizes per use site — and it reaches [`collect_item`] as an empty
//! `(module PATH)`, which flattens to nothing. So "compile whatever survives
//! collection" *is* the rule, with no genericity test of its own. What it
//! leaves out is most of the list/`Iter`/pathname/stream library (61 of the
//! prelude's 86 `defun`s are generic); what it takes in is the whole numeric
//! method catalog and the concrete-receiver `impl`s.
//!
//! **There is no snapshot chain here.** The island's generator installs the
//! previous island `.bc` because it is compiling *itself*. The prelude is
//! compiled by the island, which is already native, so this generator loads
//! the prelude purely interpreted ([`crate::prelude::load_interpreted`]) and
//! never reads the artifact it is about to replace. That also keeps the
//! dependency one-directional — `prelude_compiled.bc` is built with
//! `compiler_island.bc`, and `compiler_island.bc` needs only an interpreted
//! prelude — instead of the two artifacts each requiring the other.

use std::collections::BTreeMap;

use crate::check::core;
use crate::compile::symbols::CompiledItem;
use crate::eval::interp::Uncompilable;
use crate::{EvalError, Heap, Interp, Path, Value};

/// Applies the committed prelude dump: its checked state, its definitions, and
/// the native bodies over them.
///
/// This is what `typelisp::load_prelude` names. `interp` must be freshly
/// [`Interp::new`]'d — the unit's globals are created here, in the order they
/// were compiled against, and an `Interp` that has already promoted something
/// would number them differently.
///
/// The source being fixed and the dump a committed, digest-checked artifact,
/// any failure here is a build/bug condition rather than a user error — hence
/// the panics.
pub fn load(heap: &mut Heap, chk: &mut crate::Checker, interp: &mut Interp) {
    let units = typelisp_front::dump::parse(crate::prelude::DUMP, "prelude")
        .unwrap_or_else(|e| panic!("prelude: {}", e));
    let unit = units.first().unwrap_or_else(|| panic!("prelude: the committed dump holds no units"));
    let state =
        crate::compile::dump::read_types(unit, "prelude").unwrap_or_else(|e| panic!("prelude: {}", e));
    // Before anything is applied: a dump built from a different `SOURCE` than
    // the one compiled into this binary would install definitions the source
    // no longer has, and leave an edit looking like it did nothing.
    typelisp_front::dump::verify_sources_digest(&state, crate::prelude::DUMPED_SOURCES, REGEN_SCRIPT)
        .unwrap_or_else(|e| panic!("{}", e));
    crate::compile::dump::load_unit(heap, chk, interp, state, unit.bitcode)
        .unwrap_or_else(|e| panic!("prelude: {}", e));
    // Remembered so `(dump ...)` can re-emit this unit ahead of the session's
    // own; borrowed, since it is a static in this binary.
    interp.push_dump_source(std::borrow::Cow::Borrowed(crate::prelude::DUMP));
}

/// What an AOT executable needs from the prelude on top of the definitions
/// [`load`] installs — see [`load_for_aot`].
pub struct AotPrelude {
    /// The committed prelude bitcode, to be linked into the executable's own
    /// module so a call to `abs` resolves to a body rather than to nothing.
    pub bitcode: &'static [u8],
    /// Each prelude `defvar`, in the order its compiled slot id was assigned.
    /// The executable re-runs these at its own startup: the compiled bodies
    /// address their globals by baked-in slot id, so the storage has to exist,
    /// with the same numbering, before any of them runs.
    pub global_inits: Vec<(Path, Value)>,
}

/// [`load`], plus what an AOT executable needs to *carry* the prelude rather
/// than borrow this process's copy of it.
///
/// The difference between the two loaders is the difference between a JIT and
/// an executable. `load` installs the bitcode's bodies as addresses in this
/// process ([`crate::compile::driver::install_compiled_library`]); an
/// executable has no process to install into, so it gets the bitcode itself,
/// linked into its module, and a startup sequence for the state those bodies
/// assume. Today that state is the globals; the forms are read out here rather
/// than reconstructed later because the dump is parsed exactly once.
///
/// The returned forms are permanently rooted — every caller is a one-shot
/// compile against a throwaway heap, and they have to outlive a load that
/// pushes and pops the ordinary root stack throughout.
pub fn load_for_aot(heap: &mut Heap, chk: &mut crate::Checker, interp: &mut Interp) -> Result<AotPrelude, String> {
    let units = typelisp_front::dump::parse(crate::prelude::DUMP, "prelude")?;
    let unit = units.first().ok_or_else(|| "prelude: the committed dump holds no units".to_string())?;
    let state = crate::compile::dump::read_types(unit, "prelude")?;
    typelisp_front::dump::verify_sources_digest(&state, crate::prelude::DUMPED_SOURCES, REGEN_SCRIPT)?;

    // Taken off the state before `load_unit` consumes it, and matched against
    // the recorded `globals` afterwards: this list decides the order the
    // executable initializes them in, and the ids in the bitcode are only
    // correct if that order is the one they were compiled against.
    let mut global_inits: Vec<(Path, Value)> = Vec::new();
    for f in &state.forms {
        let tl = crate::owned_form::owned_to_value(heap, f).map_err(|e| e.to_string())?;
        // A *permanent* root, not `push_root`: these have to survive the whole
        // of `load_unit` and a compiler-island load after it, and the ordinary
        // root stack is a strict LIFO that every one of those callers pushes
        // and pops on. Rooted the LIFO way, they were collected out from under
        // the caller and turned up later as "a global initializer was built
        // from something that is not a `defvar`" — the cell had been recycled.
        // Rooting the whole top-level form keeps a `defvar` nested in a
        // `(module ...)` alive with it.
        heap.push_permanent_root(tl);
        collect_defvars(heap, tl, &mut global_inits)?;
    }

    let globals = state.globals.clone();
    // Types and definitions only: the bodies go into the *executable* (the
    // caller links `bitcode` into its module), so JIT-installing them here
    // would compile every prelude body a second time, per `compile-file`, for
    // addresses this process never calls.
    crate::compile::dump::load_unit_types_only(heap, chk, interp, state)?;

    // `load_unit` already refuses a numbering it cannot reproduce; what this
    // adds is that *this* list is that numbering. A `defvar` the walk above
    // missed would otherwise show up as an executable whose prelude globals
    // are one slot off — compiled code reading somebody else's storage, with
    // no error anywhere.
    if globals.len() != global_inits.len()
        || globals.iter().zip(&global_inits).any(|((name, _), (path, _))| name != &path.to_string())
    {
        return Err(format!(
            "prelude: the dump records {} global(s) but its forms hold {} `defvar`(s), or they are \
             in a different order — an AOT executable cannot reproduce the slot numbering the \
             bitcode was compiled against",
            globals.len(),
            global_inits.len()
        ));
    }

    Ok(AotPrelude { bitcode: unit.bitcode, global_inits })
}

/// The `defvar`s in `tl`, in the order [`collect_item`] numbers them:
/// recursing into a `(module ...)` the same way, so a global the library keeps
/// in its internal module gets the slot the bitcode was compiled against.
fn collect_defvars(heap: &Heap, tl: Value, out: &mut Vec<(Path, Value)>) -> Result<(), String> {
    if core::op_is(heap, tl, typelisp_mem::wk::MODULE) {
        let body = core::fields(heap, tl).map_err(|e| e.to_string())?;
        for item in body.into_iter().skip(1) {
            collect_defvars(heap, item, out)?;
        }
    } else if core::op_is(heap, tl, typelisp_mem::wk::DEFVAR) {
        let path = core::path_field(heap, tl, 0).ok_or_else(|| "prelude: defvar without a name".to_string())?;
        out.push((path, tl));
    }
    Ok(())
}

/// What to run when the committed dump no longer matches `SOURCE`.
pub use crate::prelude::REGEN_SCRIPT;

/// [`crate::prelude::load_interpreted`], collecting along the way everything a
/// compiled artifact has to know about the prelude.
///
/// Two callers need exactly this and not the install: this module's
/// [`build_prelude_bitcode`], which is about to *produce* the artifact, and
/// the island generator ([`crate::compile::bootstrap::build_island_bitcode`]),
/// which needs prelude definitions in scope but must not depend on the
/// prelude artifact — that dependency in both directions is a chicken-and-egg
/// neither script could break.
pub fn load_interpreted_plan(heap: &mut Heap, chk: &mut crate::Checker, interp: &mut Interp) -> PreludePlan {
    let mut plan = PreludePlan::default();
    // Two separate locals, not two closures over `plan`: the callbacks are
    // live at the same time, so each has to capture something the other does
    // not touch.
    //
    // The read forms are collected here and hashed after the load rather than
    // hashed in one call during it: the loader reads them one at a time now,
    // interleaved with checking and `exec` (`prelude::load_interpreted_with`),
    // so there is no moment at which the whole list exists inside it. The
    // hash is over the same values in the same order, so it is the same
    // number the committed artifacts were built against. They stay rooted for
    // the same reason `plan.forms` does — the reader roots each one and this
    // generator never pops.
    let mut read_forms: Vec<crate::Value> = Vec::new();
    crate::prelude::load_interpreted_with(
        heap,
        chk,
        interp,
        &mut |_heap, v| {
            read_forms.push(v);
        },
        &mut |heap, tl| {
            // Rooted and never popped: the generator writes these into the
            // dump long after the load, with a whole island load in between.
            // Both callers are one-shot generators against a throwaway heap.
            heap.push_root(tl);
            plan.forms.push(tl);
            collect_item(heap, tl, &mut plan).expect("prelude: collect failed");
        },
    );
    plan.source_hash = crate::compile::bootstrap::hash_read_forms(heap, &read_forms)
        .expect("prelude: hashing the read forms failed");
    plan
}

/// The gaps in what the compile path can lower, each with what it costs —
/// `(target name, what stays interpreted because of it)`.
///
/// **Empty, and that is the interesting part.** Every prelude definition that
/// survives [`collect_item`] — every non-generic one — now has a compiled
/// body. It listed twenty targets when the precompiled prelude was first
/// built: `char::char->string`, the ten stream/file builtins, the bitwise
/// bignum primitives, `bool::equal`/`symbol::eq`/`string::substring`, and
/// three free builtins. Between them they held 111 definitions interpreted;
/// closing them was the 2026-08-14 "コンパイル経路の穴" work
/// ([implementation-log.md](../../../docs/dev/implementation-log.md)).
///
/// Keep the list — and the reconcile check below — rather than deleting both:
/// its job now is to fail the build the moment a *new* gap appears. Add a
/// prelude definition that reaches a builtin nothing lowers and the generator
/// stops with that target named, instead of quietly shipping an artifact
/// missing a body that used to be there.
///
/// Recording *targets* rather than definitions stays right for the same
/// reason it was: a list of definitions is a list of consequences, five times
/// longer, and silent about what would actually have to be built.
///
/// [`build_prelude_bitcode`] recomputes the set on every build
/// ([`Interp::precheck_compilable`]) and fails unless it matches this list
/// exactly, printing what it found — in both directions, so an entry that
/// stops blocking anything is as loud as one that starts.
pub const PRELUDE_COMPILE_UNSUPPORTED: &[(&str, &str)] = &[];

/// What one pass over the prelude source yields: the definitions with
/// compilable bodies, and the globals whose compiled-slot ids have to be
/// assigned in a reproducible order.
///
/// Produced by the same code path at generation time and at load time — that
/// is the whole point. The ids `Interp::promote_global` hands out are baked
/// into the emitted IR as constants, so the generator's numbering and the
/// loader's must agree; deriving both from one walk of one source is what
/// makes them agree by construction rather than by two lists someone keeps in
/// sync.
#[derive(Default)]
pub struct PreludePlan {
    /// Every `defun`/`defmethod` with a body to compile, in declaration order.
    pub items: Vec<CompiledItem>,
    /// Every `defvar`/`defconstant`, in declaration order.
    pub globals: Vec<Path>,
    /// Every checked top-level form, in declaration order, rooted for the
    /// lifetime of the heap it was loaded into — the dump's checked-state half.
    pub forms: Vec<Value>,
    /// The staleness key for the forms this plan came from
    /// ([`crate::compile::bootstrap::hash_read_forms`]): what the generator
    /// embeds in the artifact and the loader checks it against.
    ///
    /// Carried here rather than recomputed by each side from
    /// `island_source_hash(prelude::SOURCE)`, which would read the prelude a
    /// second time into a second 256K-cell `Heap` — at every startup, for a
    /// parse that just happened.
    pub source_hash: u64,
}

impl PreludePlan {
    /// The items worth emitting: everything collected, minus whatever
    /// [`Interp::precheck_compilable`] rejects.
    ///
    /// Generator-side only. The *loader* does not repeat this walk — it hands
    /// `install_compiled_library` every collected item and lets the artifact
    /// answer, since a module defines exactly the bodies that were emitted
    /// into it. Asking the module is both cheaper (a symbol lookup against a
    /// startup-time budget, versus a transitive call-graph walk per
    /// definition) and impossible to disagree with, which a second run of the
    /// same predicate is not.
    pub fn compilable(&self, heap: &Heap, interp: &Interp) -> Vec<CompiledItem> {
        self.items
            .iter()
            .filter(|item| crate::compile::driver::precheck_compilable(interp, heap, &item.node_name()).is_ok())
            .cloned()
            .collect()
    }
}

/// Records what [`PreludePlan`] must know about one checked top-level form.
///
/// Recurses into a `(module ...)`, which covers the `impl` block grouping
/// `Checker::check_impl` returns and the monomorphization bundle a generic
/// instantiation arrives in. A generic template — and a `deftrait`, which
/// lowers the same way — is an *empty* `(module PATH)`, so both flatten to
/// nothing here without a test of their own.
pub(crate) fn collect_item(heap: &Heap, tl: Value, plan: &mut PreludePlan) -> Result<(), String> {
    let tag = core::op(heap, tl).map(str::to_string).unwrap_or_default();
    match tag.as_str() {
        "module" => {
            let body = core::fields(heap, tl).map_err(|e| e.to_string())?;
            for item in body.into_iter().skip(1) {
                collect_item(heap, item, plan)?;
            }
        }
        "defun" => {
            let path = core::path_field(heap, tl, 0).ok_or_else(|| "prelude: defun without a name".to_string())?;
            plan.items.push(CompiledItem::Fn(path));
        }
        "defmethod" => {
            let type_path = core::path_field(heap, tl, 0).ok_or_else(|| "prelude: defmethod without a type".to_string())?;
            let method = match core::field(heap, tl, 1) {
                Some(Value::Symbol(id)) => heap.symbol_name(id).to_string(),
                _ => return Err("prelude: defmethod without a name".to_string()),
            };
            plan.items.push(CompiledItem::Method(type_path, method));
        }
        // `defconstant` lowers to the same core form.
        "defvar" => {
            let path = core::path_field(heap, tl, 0).ok_or_else(|| "prelude: defvar without a name".to_string())?;
            plan.globals.push(path);
        }
        // A macro body is an ordinary `Sexpr -> Sexpr` function — expanding it
        // *is* calling it, and `exec` registers it in the same `fns` table a
        // `defun` goes in — so it compiles like one and belongs in the
        // artifact. Without this the expander stayed interpreted forever,
        // which is the half of "macros can be compiled" the implementation was
        // missing (the other half, "expand before compiling the expansion",
        // was already true and then some: expansion finishes at check time,
        // before any codegen runs).
        "defmacro" => {
            let path = core::path_field(heap, tl, 0).ok_or_else(|| "prelude: defmacro without a name".to_string())?;
            plan.items.push(CompiledItem::Fn(path));
        }
        // No codegen of their own. `exec` still registers what they define:
        // an enum's variants and a struct's field representations, which the
        // compile bridge reads back through `Interp::compile_definitions`.
        // A declaration, not a body to emit: `Interp::exec` resolved the
        // symbol and hung a thunk on the `FnDef`, and there is nothing here to
        // compile. (The prelude itself declares no FFI — see `docs/ja/reference/syntax.md` §3.3
        // — but a dump replaying one reaches this walk.)
        "defstruct" | "defenum" | "use" | "defffi" => {}
        other => {
            return Err(format!(
                "prelude: top-level `{}` has no place in the compiled artifact — \
                 the prelude is definitions only (defun/defmethod/defvar/defconstant/\
                 defstruct/defenum/defmacro/deftrait/impl/module)",
                other
            ))
        }
    }
    Ok(())
}

/// Assigns every prelude global its compiled-slot id, in declaration order,
/// and returns the assignment for the dump to record.
///
/// Must run at the same point on both sides — right after the prelude's forms
/// are `exec`'d, before anything touches bitcode — because the ids are baked
/// into the artifact as constants and `typelisp_rt`'s table hands them out
/// sequentially from zero per `Interp`. Eager and in order rather than lazily
/// on first reference, for exactly the reason `compile::aot::compile_file`
/// promotes its `defvar`s eagerly: a numbering that depends on *which bodies
/// happened to be compiled first* is not a numbering a second process can
/// reproduce.
pub fn promote_globals(
    heap: &mut Heap,
    interp: &Interp,
    plan: &PreludePlan,
) -> Result<Vec<(String, usize)>, String> {
    let mut out = Vec::with_capacity(plan.globals.len());
    for path in &plan.globals {
        let id =
            interp.promote_global(heap, path).map_err(|e| format!("prelude: promoting `{}`: {}", path, e))?;
        out.push((path.to_string(), id));
    }
    Ok(out)
}

/// Fails unless [`PRELUDE_COMPILE_UNSUPPORTED`] names exactly the targets that
/// actually block compilation, printing what was found when it doesn't.
///
/// A rejection that is *not* a missing target ([`Uncompilable::Other`]) can
/// never be waved through: it means the compile path broke on a shape rather
/// than on a known gap, and there is no honest entry to make for it.
fn reconcile_unsupported(heap: &Heap, interp: &Interp, plan: &PreludePlan) -> Result<(), String> {
    let mut blockers: BTreeMap<String, Vec<String>> = BTreeMap::new();
    let mut broken: Vec<(String, EvalError)> = Vec::new();
    for item in &plan.items {
        let node = item.node_name();
        match crate::compile::driver::precheck_compilable(interp, heap, &node) {
            Ok(()) => {}
            Err(Uncompilable::MissingTarget(target)) => blockers.entry(target).or_default().push(node),
            Err(Uncompilable::Other(e)) => broken.push((node, e)),
        }
    }

    let listed: Vec<&str> = PRELUDE_COMPILE_UNSUPPORTED.iter().map(|(t, _)| *t).collect();
    let unlisted: Vec<&String> = blockers.keys().filter(|t| !listed.contains(&t.as_str())).collect();
    let gone: Vec<&str> = listed.iter().copied().filter(|t| !blockers.contains_key(*t)).collect();
    if broken.is_empty() && unlisted.is_empty() && gone.is_empty() {
        return Ok(());
    }

    let mut msg = String::new();
    for (node, e) in &broken {
        msg.push_str(&format!("prelude: compiling `{}` broke on its own shape: {}\n", node, e));
    }
    if !unlisted.is_empty() || !gone.is_empty() {
        msg.push_str("prelude: PRELUDE_COMPILE_UNSUPPORTED does not match what the compiler can do.\n");
        for target in &unlisted {
            msg.push_str(&format!("  blocks compilation, but not listed: {}\n", target));
        }
        for target in &gone {
            msg.push_str(&format!("  listed, but blocks nothing now: {}\n", target));
        }
        msg.push_str("\nThe current set, ready to paste into PRELUDE_COMPILE_UNSUPPORTED:\n");
        for (target, blocked) in &blockers {
            msg.push_str(&format!("    ({:?}, {:?}), // {} definition(s)\n", target, blocked[0], blocked.len()));
        }
    }
    Err(msg)
}

/// Builds the committed prelude dump in a throwaway environment: the checked
/// state the prelude's definitions produce, and the bitcode holding their
/// bodies, written as one file.
///
/// The checker delta is captured **before the island is loaded**, which is the
/// one ordering constraint here that is not obvious: both load into the same
/// `Checker`, and afterwards nothing can say which of them added what.
///
/// Mirrors [`crate::compile::bootstrap::build_island_bitcode`]: one shared
/// module, `rt_*` forward declarations, every body forward-declared before any
/// is translated (the prelude's definitions reference each other freely — the
/// numeric catalog's `gcd` calls `abs`, `signum` calls `abs`), then one
/// `add_compiled_function` per item. No `main` wrapper and no
/// addresses: this is a library of compiled functions, and the
/// `rt_*` addresses are supplied at install time by
/// `Interp::install_compiled_library`.
pub fn build_prelude_artifact() -> Result<Vec<u8>, String> {
    let mut heap = Heap::with_capacity(1 << 18);
    let mut chk = crate::Checker::new();
    let mut interp = Interp::new();

    // Interpreted, deliberately: see this module's doc comment on why there is
    // no snapshot chain.
    let before = chk.signature(&heap)?;
    let plan = load_interpreted_plan(&mut heap, &mut chk, &mut interp);
    let globals = promote_globals(&mut heap, &interp, &plan)?;
    let delta = chk.capture_delta(&heap, &before)?;
    // The island is what actually translates the bodies below, so it has to be
    // native before the first `add_compiled_function` call — and after the
    // delta above, whose subject is the prelude alone.
    crate::load_compiler(&mut heap, &mut chk, &mut interp);

    // Decide what is in before emitting anything: the island's `compile-call`
    // aborts the process on an undeclared callee rather than returning an
    // error, so "try it and see" is not available. See
    // `Interp::precheck_compilable`.
    reconcile_unsupported(&heap, &interp, &plan)?;
    let items = plan.compilable(&heap, &interp);

    let module = crate::compile::driver::fresh_module_with_declarations("prelude_compiled", &items);

    // `add_compiled_function` locks `COMPILE_LOCK` per LLVM builtin call (via
    // `eval_llvm_builtin_method`) and `Mutex` isn't reentrant, so it must run
    // without the lock held — the same constraint `aot::compile_file` and the
    // island generator both document at their own loops.
    for item in &items {
        let node = item.node_name();
        crate::compile::driver::add_compiled_function(&interp, &mut heap, module.clone(), &node, &item.symbol_name()).map_err(|e| {
            format!(
                "prelude: compiling `{}` failed: {} — the precheck said it was fine, so this is a \
                 gap the call-graph walk cannot see rather than a missing lowering to record in \
                 PRELUDE_COMPILE_UNSUPPORTED",
                node, e
            )
        })?;
    }

    // Trait-object tables are the one thing a library artifact cannot carry as
    // it stands: `dyn-new` bakes a vtable id and `dyn-upcast` a trait id, and
    // both are assigned per boxing/upcast site during translation, so a
    // reproducible numbering would need the same eager, ordered replay
    // `promote_globals` does for globals — plus, for vtables, a way to publish
    // the addresses (`compile::aot::build_main_wrapper` emits `rt_vtable_set`
    // calls; a JIT-installed library has no startup sequence to put them in).
    // No prelude body reaches either today: `as-dyn-error` is generic, and the
    // stream combinators take already-boxed `:dyn` values rather than boxing
    // any. A `dyn-call` bakes only a slot index, so dispatching on `:dyn` — the
    // composed streams do — is fine.
    //
    // Checked rather than assumed, because the failure it guards is silent: a
    // baked id that means something different at load time reads the wrong
    // vtable and calls the wrong function.
    if !interp.vtable_descriptors().is_empty() || !interp.upcast_descriptors().is_empty() {
        return Err(
            "prelude: a compiled body boxes or upcasts a trait object, whose vtable/trait ids are \
             baked in per site — the artifact needs an ordered replay of those tables at load time \
             before this can be shipped (see this function's comment)"
                .to_string(),
        );
    }

    let bitcode = {
        let _guard = crate::compile::COMPILE_LOCK.lock().unwrap();
        let bitcode = {
            let m = module.borrow();
            crate::compile::verify_module_naming_functions(&m, "prelude module").map(|()| {
                // Carries a trailing NUL by design — see the identical call in
                // `bootstrap.rs` for why it must not be trimmed.
                m.write_bitcode_to_memory().as_slice().to_vec()
            })
        };
        // Destroyed with the guard still held — see the same `drop` in
        // `bootstrap::build_island_bitcode`.
        drop(module);
        bitcode?
    };

    let state = typelisp_front::dump::capture_types_with_abi(
        &heap,
        delta,
        "prelude",
        Some(typelisp_front::dump::sources_digest(crate::prelude::DUMPED_SOURCES)),
        Some(plan.source_hash),
        &plan.forms,
        items.iter().map(unit_item).collect(),
        globals,
        // These bodies were emitted by whichever island this process is
        // running, so that is the ABI they answer to.
        //
        // `capture_types`' classic default was right only while the island
        // was classic too: the moment the island flipped, a regenerated
        // prelude was coroutine code labelled classic, and an interpreted
        // caller entered it as `f(args, argc)` -- the argument pointer
        // arriving where the frame belongs, decoded as a fixnum by
        // `rt_frame_data` ("... is not a frame").
        //
        // The prelude emits no code of its own, so `emits_abi` has nothing to
        // say -- and `UnitState`'s doc comment spells out what a unit with
        // nothing to say writes: the same value as `body_abi`. It said classic
        // instead until C6, which is a real ABI value standing in for "no
        // answer" in the one field a future changeover would read.
        crate::compile::EMITTED_BODY_ABI,
        crate::compile::EMITTED_BODY_ABI,
        // Layout: the same argument, field for field.
        crate::compile::EMITTED_LAYOUT,
        crate::compile::EMITTED_LAYOUT,
    )?;
    let types = typelisp_front::dump::write_state(&state)?;
    Ok(typelisp_front::dump::write(&[(types, bitcode)]))
}

/// One compiled definition in the form a dump records it.
///
/// The backend's [`CompiledItem`] and the front end's `UnitItem` are the same
/// two cases; they are separate types because the front end must not depend on
/// the backend (see `typelisp_front::dump::UnitItem`).
pub(crate) fn unit_item(item: &CompiledItem) -> typelisp_front::dump::UnitItem {
    match item {
        CompiledItem::Fn(path) => typelisp_front::dump::UnitItem::Fn(path.clone()),
        CompiledItem::Method(path, name) => {
            typelisp_front::dump::UnitItem::Method(path.clone(), name.clone())
        }
    }
}