Skip to main content

blue_lang_runtime/
pipeline.rs

1//! The blue pipeline: **parse → check → erase → run**, in that order, once.
2//!
3//! The order is the whole reason this module exists. Each stage is available
4//! separately for tools that want one, but the *default* path is a single
5//! function, because two of the four orderings are silently wrong:
6//!
7//! - **Erase before check** discards every annotation, so a program with type
8//!   errors passes. The checker sees `(define …)` and has nothing to check.
9//! - **Run before check** reports a type error after the side effects.
10//!
11//! Neither fails loudly. Both produce a green run on a program that should
12//! have been rejected. Leaving the order to each caller means every caller
13//! is one reordering away from turning the type checker off — so the order
14//! lives here, and callers ask for a *result*, not a sequence of steps.
15
16use tatara_lisp::Sexp;
17use tatara_lisp_eval::Value;
18
19use blue_lang_check::{NameTable, Namespace};
20
21use crate::erase::erase_types;
22use crate::inputs::Inputs;
23use crate::uses::Entry;
24
25/// Why a run stopped short.
26#[derive(Debug, thiserror::Error)]
27pub enum RunError {
28    #[error("parse error: {0}")]
29    Parse(String),
30    /// The check stage rejected the program — a type error, an unbound name,
31    /// or any other error-severity rule in `blue_lang_check::RULES`. Carries
32    /// every error, not just the first: a caller fixing one wants to see the
33    /// rest. (The variant keeps its first name because it is public API on a
34    /// released crate.)
35    ///
36    /// Each one is already rendered `file:line:col: message` against the file
37    /// the offending form came from — which, in a program with imports, is
38    /// frequently not the file the user named. The rendering happens here
39    /// rather than at the consumer because here is where the file table exists;
40    /// see `uses::ResolvedProgram::locate`.
41    #[error("{} error(s) in the check stage:\n{}", .0.len(), .0.join("\n"))]
42    Types(Vec<String>),
43    /// **No longer reachable, and that is the point.** This reported "blue
44    /// emitted a tree the reader could not read back" — a failure only a
45    /// print-then-reparse hop could have. [`crate::lower_to_spanned`] deleted
46    /// the hop, so there is nothing left to fail: the tree the evaluator gets
47    /// IS the tree erasure produced, not a re-reading of its text.
48    ///
49    /// Kept rather than removed because it is public API on a released crate
50    /// and a consumer may still match on it, per ★★ MODULARIZE, DON'T DELETE.
51    /// It is retired, not orphaned — if a future stage ever serialises again
52    /// it has a typed home. **Nothing constructs it today**; do not read its
53    /// presence as evidence the pipeline can still fail this way.
54    #[error("the emitted tatara-lisp could not be read back: {0}")]
55    Lower(String),
56    /// The program ran and raised.
57    ///
58    /// Rendered `file:line:col: message`, then the blue call stack the error
59    /// unwound through, innermost first (`crate::observe::Fault`). The
60    /// position is resolved against the file of the innermost frame's
61    /// definition, so a raise inside a callee from another file names the
62    /// callee's file and line; before the stack existed that case could only
63    /// name the caller's file, with no position.
64    #[error("runtime error: {0}")]
65    Eval(Box<crate::observe::Fault>),
66    /// A `use("name")` could not be resolved.
67    ///
68    /// Its own variant rather than folded into `Parse`, because the reader's
69    /// next action is different: a parse error is in the source in front of
70    /// them, an import error is in their packaging — a missing bidama, a
71    /// BLUE_PATH that does not contain it, or no loader at all.
72    #[error("import error: {0}")]
73    Import(String),
74}
75
76impl RunError {
77    /// The exit status a program that stopped here returns:
78    /// [`crate::BREACH_EXIT`] for a capability breach, 1 for anything else.
79    #[must_use]
80    pub fn exit_status(&self) -> u8 {
81        match self {
82            RunError::Eval(fault) if fault.breach => crate::BREACH_EXIT,
83            _ => 1,
84        }
85    }
86}
87
88/// What a run produced, plus what the checker did on the way.
89#[derive(Debug)]
90pub struct Run {
91    pub value: Value,
92    /// Nodes the type walk visited. Zero for a fully untyped program — this
93    /// is what makes "no annotations, no analysis" a *measurement* rather
94    /// than a claim.
95    pub visited: usize,
96    /// Declarations that carried an annotation.
97    pub typed_decls: usize,
98    /// Boundaries where typed code meets untyped code.
99    pub seams: usize,
100}
101
102/// Parse blue source to tatara-lisp forms.
103pub fn parse(src: &str) -> Result<Vec<Sexp>, RunError> {
104    parse_with_depth(src, blue_lang_syntax::MAX_EXPR_DEPTH)
105}
106
107/// [`parse`] with the parser's nesting bound supplied by the caller.
108///
109/// The bound exists so a stack overflow — which `catch_unwind` cannot catch —
110/// arrives as a typed `Err` instead. It is a *limit*, not a dialect: raising
111/// it changes no program's meaning, which is exactly why it is safe to expose
112/// as configuration (`blue-lang-cli`'s `config` module holds the rule).
113pub fn parse_with_depth(src: &str, max_depth: usize) -> Result<Vec<Sexp>, RunError> {
114    blue_lang_syntax::parse_program_with_depth(src, max_depth)
115        .map_err(|e| RunError::Parse(e.to_string()))
116}
117
118/// [`parse_with_depth`] keeping **every node's** source span.
119///
120/// The door for anything that will report a position to a human. It exists here,
121/// beside the spanless one, so a caller that wants spans still parses under the
122/// CONFIGURED nesting bound — a separate `blue_lang_syntax` call would be the
123/// second door `parse_with_depth`'s own docs exist to prevent, with
124/// `max_expr_depth` true of some subcommands and not others.
125pub fn parse_tree_with_depth(
126    src: &str,
127    max_depth: usize,
128) -> Result<Vec<blue_lang_syntax::Spanned>, RunError> {
129    blue_lang_syntax::parse_program_tree_with_depth(src, max_depth)
130        .map_err(|e| RunError::Parse(e.to_string()))
131}
132
133/// [`parse`] keeping **every node's** source span.
134///
135/// The spanned twin of [`parse`], at the same default bound — the door for
136/// anything downstream of a parse that will report a position, which since
137/// `use` learned to carry file identity is every path through
138/// [`run_in_surface`].
139pub fn parse_tree(src: &str) -> Result<Vec<blue_lang_syntax::Spanned>, RunError> {
140    parse_tree_with_depth(src, blue_lang_syntax::MAX_EXPR_DEPTH)
141}
142
143/// Run blue source with no build inputs.
144pub fn run(src: &str) -> Result<Run, RunError> {
145    run_with_inputs(src, Inputs::new())
146}
147
148/// Run blue source, giving the macro phase access to verified build inputs.
149///
150/// `inputs` is already verified — [`Inputs`] cannot hold bytes that do not match
151/// their declared hash — so nothing here re-checks. The capability a macro gains
152/// is exactly "these hashed bytes", never a path.
153pub fn run_with_inputs(src: &str, inputs: Inputs) -> Result<Run, RunError> {
154    run_with_loader(src, inputs, &crate::uses::NoLoader)
155}
156
157/// Run blue source with a loader, so `use("name")` can resolve.
158///
159/// Split from [`run_with_inputs`] rather than folded into it because loading a
160/// package reads a filesystem, and this crate has a `wasm32-unknown-unknown`
161/// consumer with zero host imports. The capability is injected by callers that
162/// have it — `blue_lang_pkg::LoadPath` is the real one — and absent by default,
163/// where a `use` is a typed error naming the package.
164pub fn run_with_loader(
165    src: &str,
166    inputs: Inputs,
167    loader: &dyn crate::uses::Loader,
168) -> Result<Run, RunError> {
169    run_in_surface(Entry::anonymous(src), inputs, loader, None)
170}
171
172/// Run blue source written in a `yakugo` surface.
173///
174/// The pack applies at PARSE time and nowhere else — by the time the checker
175/// sees the program it is canonical, so every stage below is identical whatever
176/// surface the author wrote in. That is what makes a surface a surface: it
177/// changes how a program is spelled and nothing about how it runs.
178///
179/// `entry` carries the source AND the file it was read from, because a type
180/// error has to be reported somewhere: a caller with a path should pass it, and
181/// one without ([`Entry::anonymous`]) gets diagnostics that say so rather than
182/// diagnostics that guess. Imported packages name themselves through the
183/// loader either way.
184///
185/// # Errors
186///
187/// As [`run_with_loader`].
188pub fn run_in_surface(
189    entry: Entry<'_>,
190    inputs: Inputs,
191    loader: &dyn crate::uses::Loader,
192    surface: Option<&blue_lang_syntax::yakugo::Yakugo>,
193) -> Result<Run, RunError> {
194    run_framed(entry, inputs, loader, surface, None)
195}
196
197/// [`run_in_surface`] held to `hold` when one is given: a host name it
198/// withholds, written in the entry file, refuses the program before it runs,
199/// and one reached through a bidama or a spawned process is a breach where it
200/// is called; a provided capability's names reach its provider. `None` runs
201/// with every capability.
202///
203/// # Errors
204///
205/// As [`run_with_loader`], and [`RunError::Types`] naming each host name the
206/// entry file writes that `hold` withholds.
207pub fn run_framed(
208    entry: Entry<'_>,
209    inputs: Inputs,
210    loader: &dyn crate::uses::Loader,
211    surface: Option<&blue_lang_syntax::yakugo::Yakugo>,
212    hold: Option<&crate::Hold>,
213) -> Result<Run, RunError> {
214    run_framed_accounted(
215        entry,
216        inputs,
217        loader,
218        surface,
219        hold,
220        &crate::Account::default(),
221    )
222}
223
224/// [`run_framed`] giving `account` of itself: the host calls the run and its
225/// processes make, and the identities of what it runs (`crate::Account`).
226///
227/// # Errors
228///
229/// As [`run_framed`].
230pub fn run_framed_accounted(
231    entry: Entry<'_>,
232    inputs: Inputs,
233    loader: &dyn crate::uses::Loader,
234    surface: Option<&blue_lang_syntax::yakugo::Yakugo>,
235    hold: Option<&crate::Hold>,
236    account: &crate::Account,
237) -> Result<Run, RunError> {
238    // The interpreter is built BEFORE the check stage, because it is the check
239    // stage's name oracle: every builtin, macro and special form a program may
240    // name is read off the interpreter that will run it, never off a list.
241    // Building it is not a stage; nothing is evaluated until `eval_prepared`.
242    let (mut interp, mut prepared) = prepared_on(entry, inputs, loader, surface)?;
243    hold_to_frame(&mut interp, &mut prepared, hold, account)?;
244    let value = evaluate_held(&mut interp, &prepared, hold, account)?;
245    Ok(Run {
246        value,
247        visited: prepared.visited,
248        typed_decls: prepared.typed_decls,
249        seams: prepared.seams,
250    })
251}
252
253/// A prepared program's top-level forms, then its `main`, held to
254/// `max_alloc_bytes` ([`crate::memory`]), met with `hold`'s.
255fn evaluate_held(
256    interp: &mut tatara_lisp_eval::Interpreter<()>,
257    prepared: &Prepared,
258    hold: Option<&crate::Hold>,
259    account: &crate::Account,
260) -> Result<Value, RunError> {
261    let bound = crate::execution_bounds_under(hold).max_alloc_bytes;
262    let (outcome, over) = crate::memory::held_on(interp, bound, |interp| {
263        let value = crate::timings::stage(crate::timings::Stage::Eval, || {
264            eval_prepared(interp, prepared, &mut ())
265        })?;
266        Ok(crate::timings::stage(crate::timings::Stage::Main, || {
267            call_main(interp, prepared, &mut ())
268        })?
269        .unwrap_or(value))
270    });
271    if let Some(steps) = &account.steps {
272        steps.store(
273            u64::try_from(interp.steps_spent()).unwrap_or(u64::MAX),
274            std::sync::atomic::Ordering::Relaxed,
275        );
276    }
277    refused_past(outcome, over)
278}
279
280/// `outcome`, or the memory refusal when the evaluation passed `over` and
281/// still returned.
282fn refused_past<T>(outcome: Result<T, RunError>, over: Option<u64>) -> Result<T, RunError> {
283    match (outcome, over) {
284        (Ok(_), Some(bound)) => Err(RunError::Eval(Box::new(crate::observe::Fault::unplaced(
285            &crate::memory::refusal(bound),
286            &[],
287        )))),
288        (outcome, _) => outcome,
289    }
290}
291
292/// Bind `hold` into `interp` and its processes, with each scoped provider's
293/// packages moved onto it (`Hold::rebind_scoped`); a withheld name the entry
294/// file writes refuses the program before it runs. With an `account` that
295/// identifies, the program's identities are deferred to it and the node is
296/// attached again knowing them; without a hold or one, nothing changes.
297fn hold_to_frame(
298    interp: &mut tatara_lisp_eval::Interpreter<()>,
299    prepared: &mut Prepared,
300    hold: Option<&crate::Hold>,
301    account: &crate::Account,
302) -> Result<(), RunError> {
303    if account.defer_identities(
304        &prepared.program,
305        std::sync::Arc::clone(&prepared.lowered),
306        main_key(prepared),
307    ) {
308        let places = places_of(&prepared.program, &prepared.erased);
309        account.places.defer(move || places);
310    }
311    let Some(hold) = hold else {
312        if account.identify.is_some() {
313            crate::attach_node_accounted(interp, None, account);
314        }
315        return Ok(());
316    };
317    let withheld = hold.rebind_scoped(&prepared.program, &mut prepared.erased);
318    let escapes = entry_escapes(&prepared.program, &withheld, &|cap| {
319        format!("this run does not allow; pass --allow {cap} to grant it")
320    });
321    if !escapes.is_empty() {
322        return Err(RunError::Types(escapes));
323    }
324    hold.bind(interp, "this run's frame", &account.host_io);
325    crate::attach_node_accounted(interp, Some(hold), account);
326    Ok(())
327}
328
329/// Each key a top-level `define` of `program` binds (`erased`, one form per
330/// top-level form), with the file it came from: what a crash trace turns a
331/// breaching call's span into a file, line and column against. Each file's
332/// text is shared by every key it defines.
333fn places_of(
334    program: &crate::uses::ResolvedProgram,
335    erased: &[tatara_lisp::Spanned],
336) -> std::collections::BTreeMap<String, blue_lang_proc::Source> {
337    let files: Vec<blue_lang_proc::Source> = program
338        .files()
339        .iter()
340        .map(|f| blue_lang_proc::Source {
341            file: f
342                .path
343                .as_ref()
344                .map_or_else(|| "<anonymous>".to_string(), |p| p.display().to_string()),
345            text: std::sync::Arc::from(f.text.as_str()),
346        })
347        .collect();
348    erased
349        .iter()
350        .enumerate()
351        .filter_map(|(i, form)| {
352            let key = blue_lang_proc::violation::defined_key(form)?;
353            let source = files.get(program.owner_of(i)?.index())?;
354            Some((key.to_string(), source.clone()))
355        })
356        .collect()
357}
358
359fn entry_escapes(
360    program: &crate::uses::ResolvedProgram,
361    refused: &[(&'static str, blue_lang_waku::Capability)],
362    grant: &dyn Fn(&str) -> String,
363) -> Vec<String> {
364    use crate::uses::ResolvedProgram;
365    let forms: Vec<tatara_lisp::Spanned> = program
366        .forms()
367        .iter()
368        .enumerate()
369        .filter(|(i, _)| program.owner_of(*i) == Some(ResolvedProgram::ENTRY))
370        .map(|(_, f)| f.clone())
371        .collect();
372    let label = |name: &str| {
373        refused
374            .iter()
375            .find(|(n, _)| *n == name)
376            .map(|(_, c)| c.label())
377    };
378    blue_lang_waku::free_names(&forms, &|name| label(name).is_none())
379        .into_iter()
380        .filter_map(|name| {
381            let cap = label(&name)?;
382            let at = program
383                .file(ResolvedProgram::ENTRY)
384                .and_then(|f| f.path.clone())
385                .map_or_else(|| "<entry>".to_string(), |p| p.display().to_string());
386            Some(format!(
387                "{at}: refused: `{name}` reaches the host ({cap}), which {}",
388                grant(cap)
389            ))
390        })
391        .collect()
392}
393
394/// The interpreter a program runs on, with `inputs` bound for its macros.
395fn runtime(inputs: Inputs) -> tatara_lisp_eval::Interpreter<()> {
396    let mut interp =
397        crate::timings::stage(crate::timings::Stage::Runtime, crate::interpreter_hostless);
398    crate::inputs::install_input_primitives(&mut interp, inputs);
399    interp
400}
401
402/// [`runtime`], and the program prepared against that interpreter's names.
403fn prepared_on(
404    entry: Entry<'_>,
405    inputs: Inputs,
406    loader: &dyn crate::uses::Loader,
407    surface: Option<&blue_lang_syntax::yakugo::Yakugo>,
408) -> Result<(tatara_lisp_eval::Interpreter<()>, Prepared), RunError> {
409    let interp = runtime(inputs);
410    let builtins = crate::timings::stage(crate::timings::Stage::Names, || builtin_names(&interp));
411    let prepared = prepare(entry, loader, surface, &builtins)?;
412    Ok((interp, prepared))
413}
414
415/// A program past every stage before evaluation, kept as data: the tree
416/// `def_hash` reads (`lowered`), the tree that runs (`erased`), one form per
417/// top-level form of `program` in both, and the runtime key of the entry
418/// file's `main`. What `blue bundle` seals into an appliance, and what
419/// [`run_compiled`] evaluates with no loader and no checker.
420pub struct Compiled {
421    pub program: crate::uses::ResolvedProgram,
422    pub lowered: Vec<tatara_lisp::Spanned>,
423    pub erased: Vec<tatara_lisp::Spanned>,
424    pub main: Option<String>,
425}
426
427/// Parse, resolve, check and erase `entry`, and stop before evaluation. Under
428/// a `hold`, a host name the entry file writes that the hold withholds
429/// refuses the program here, as [`run_framed`] refuses it before it runs.
430///
431/// # Errors
432///
433/// As [`run_framed`], with no runtime error: nothing is evaluated.
434pub fn compile(
435    entry: Entry<'_>,
436    loader: &dyn crate::uses::Loader,
437    surface: Option<&blue_lang_syntax::yakugo::Yakugo>,
438    hold: Option<&crate::Hold>,
439) -> Result<Compiled, RunError> {
440    let (_, prepared) = prepared_on(entry, Inputs::new(), loader, surface)?;
441    if let Some(hold) = hold {
442        let escapes = entry_escapes(&prepared.program, &hold.withheld_host_names(), &|cap| {
443            format!("this bundle's policy does not allow; declare allow(%i[{cap}]) in the project's Bluefile and run `blue lock`")
444        });
445        if !escapes.is_empty() {
446            return Err(RunError::Types(escapes));
447        }
448    }
449    let main = main_key(&prepared);
450    let Prepared {
451        program,
452        lowered,
453        erased,
454        ..
455    } = prepared;
456    Ok(Compiled {
457        program,
458        lowered: std::sync::Arc::unwrap_or_clone(lowered),
459        erased,
460        main,
461    })
462}
463
464/// Evaluate compiled forms under `hold`, then call `main` when one is named.
465/// The hold is not optional: a compiled program has no ambient run. `who`
466/// names the hold in a breach's message. Errors carry no position: the
467/// forms carry no source.
468///
469/// # Errors
470///
471/// [`RunError::Eval`] when a form or `main` raises, a breach included
472/// ([`crate::observe::Fault::breach`]).
473pub fn run_compiled(
474    erased: &[tatara_lisp::Spanned],
475    main: Option<&str>,
476    hold: &crate::Hold,
477    who: &'static str,
478) -> Result<Value, RunError> {
479    let mut interp = runtime(Inputs::new());
480    let account = crate::Account::default();
481    hold.bind(&mut interp, who, &account.host_io);
482    crate::attach_node_accounted(&mut interp, Some(hold), &account);
483    let fault = |e: tatara_lisp_eval::EvalError| {
484        RunError::Eval(Box::new(crate::observe::Fault::unplaced(
485            &crate::memory::named(e),
486            erased,
487        )))
488    };
489    let bound = hold.execution_bounds().max_alloc_bytes;
490    let (outcome, over) = crate::memory::held_on(&mut interp, bound, |interp| {
491        let mut value = Value::Nil;
492        for form in erased {
493            value = interp.eval_top_form(form, &mut ()).map_err(fault)?;
494        }
495        if let Some(key) = main {
496            let at = tatara_lisp::Span::synthetic();
497            let call = tatara_lisp::Spanned::new(
498                at,
499                tatara_lisp::SpannedForm::List(vec![tatara_lisp::Spanned::new(
500                    at,
501                    tatara_lisp::SpannedForm::Atom(tatara_lisp::Atom::Symbol(key.to_string())),
502                )]),
503            );
504            value = interp.eval_top_form(&call, &mut ()).map_err(fault)?;
505        }
506        Ok(value)
507    });
508    refused_past(outcome, over)
509}
510
511/// A run watched by a call observer, and the definitions the observer was
512/// built over.
513pub struct Observed {
514    pub definitions: std::sync::Arc<crate::observe::Definitions>,
515    /// The run itself: its value, or the error it stopped on.
516    pub outcome: Result<Run, RunError>,
517}
518
519/// What a call observer is built from: the program past every stage before
520/// evaluation, its tree before erasure (what `def_hash` reads) and after it
521/// (what runs), and the definitions read off it.
522pub struct Watched<'a> {
523    pub definitions: &'a std::sync::Arc<crate::observe::Definitions>,
524    pub program: &'a crate::uses::ResolvedProgram,
525    pub lowered: &'a [tatara_lisp::Spanned],
526    pub erased: &'a [tatara_lisp::Spanned],
527}
528
529/// [`run_in_surface`] with a call observer installed for the evaluation.
530/// `watch` builds the observer once every stage before evaluation has
531/// passed; the profiler, the tracer, coverage and the call memo are all built
532/// this way (`crate::observe::Recorder`, `blue-lang-mondou`'s
533/// `recall::Recall`).
534///
535/// # Errors
536///
537/// A parse, import or check error, which leaves nothing to run. A runtime
538/// error is the [`Observed::outcome`], so what ran before it is still read.
539pub fn run_observed(
540    entry: Entry<'_>,
541    inputs: Inputs,
542    loader: &dyn crate::uses::Loader,
543    surface: Option<&blue_lang_syntax::yakugo::Yakugo>,
544    watch: impl FnOnce(&Watched<'_>) -> std::sync::Arc<dyn crate::observe::CallObserver>,
545) -> Result<Observed, RunError> {
546    run_observed_framed(entry, inputs, loader, surface, None, watch)
547}
548
549/// [`run_observed`] held to `frame` as [`run_framed`] holds a run.
550///
551/// # Errors
552///
553/// As [`run_observed`], and as [`run_framed`] for a host name the entry file
554/// writes that `frame` does not grant.
555pub fn run_observed_framed(
556    entry: Entry<'_>,
557    inputs: Inputs,
558    loader: &dyn crate::uses::Loader,
559    surface: Option<&blue_lang_syntax::yakugo::Yakugo>,
560    hold: Option<&crate::Hold>,
561    watch: impl FnOnce(&Watched<'_>) -> std::sync::Arc<dyn crate::observe::CallObserver>,
562) -> Result<Observed, RunError> {
563    run_observed_accounted(
564        entry,
565        inputs,
566        loader,
567        surface,
568        hold,
569        &crate::Account::default(),
570        watch,
571    )
572}
573
574/// [`run_observed_framed`] giving `account` of itself, as
575/// [`run_framed_accounted`] does.
576///
577/// # Errors
578///
579/// As [`run_observed_framed`].
580pub fn run_observed_accounted(
581    entry: Entry<'_>,
582    inputs: Inputs,
583    loader: &dyn crate::uses::Loader,
584    surface: Option<&blue_lang_syntax::yakugo::Yakugo>,
585    hold: Option<&crate::Hold>,
586    account: &crate::Account,
587    watch: impl FnOnce(&Watched<'_>) -> std::sync::Arc<dyn crate::observe::CallObserver>,
588) -> Result<Observed, RunError> {
589    let (mut interp, mut prepared) = prepared_on(entry, inputs, loader, surface)?;
590    hold_to_frame(&mut interp, &mut prepared, hold, account)?;
591    let definitions = std::sync::Arc::new(crate::observe::Definitions::of(
592        &prepared.program,
593        &prepared.erased,
594    ));
595    interp.set_call_observer(Some(watch(&Watched {
596        definitions: &definitions,
597        program: &prepared.program,
598        lowered: &prepared.lowered,
599        erased: &prepared.erased,
600    })));
601    let outcome = evaluate_held(&mut interp, &prepared, hold, account).map(|value| Run {
602        value,
603        visited: prepared.visited,
604        typed_decls: prepared.typed_decls,
605        seams: prepared.seams,
606    });
607    interp.set_call_observer(None);
608    Ok(Observed {
609        definitions,
610        outcome,
611    })
612}
613
614/// A program past every stage before evaluation: parsed, its imports
615/// resolved, type-checked and erased. What [`run_in_surface`] and
616/// [`crate::hosted::load_hosted`] both evaluate, so the stage order this
617/// module exists to own is written once for both.
618pub(crate) struct Prepared {
619    program: crate::uses::ResolvedProgram,
620    lowered: std::sync::Arc<Vec<tatara_lisp::Spanned>>,
621    erased: Vec<tatara_lisp::Spanned>,
622    visited: usize,
623    typed_decls: usize,
624    seams: usize,
625}
626
627impl Prepared {
628    /// The forms the evaluator will run: every file's, resolved and erased.
629    pub(crate) fn erased(&self) -> &[tatara_lisp::Spanned] {
630        &self.erased
631    }
632
633    /// The entry file's namespace.
634    pub(crate) fn entry_namespace(&self) -> Namespace {
635        let first = (0..self.program.forms().len())
636            .find(|i| self.program.owner_of(*i) == Some(crate::uses::ResolvedProgram::ENTRY));
637        match first {
638            Some(i) => namespace_of(&self.program, i),
639            None => match self
640                .program
641                .file(crate::uses::ResolvedProgram::ENTRY)
642                .and_then(|f| f.package.clone())
643            {
644                Some(p) => Namespace::Bidama(p),
645                None => Namespace::File(String::new()),
646            },
647        }
648    }
649}
650
651/// Parse, resolve, check and erase, in that order.
652///
653/// `builtins` is the name table of the interpreter that will evaluate the
654/// result ([`builtin_names`]); the check stage resolves the program's names
655/// against it.
656pub(crate) fn prepare(
657    entry: Entry<'_>,
658    loader: &dyn crate::uses::Loader,
659    surface: Option<&blue_lang_syntax::yakugo::Yakugo>,
660    builtins: &NameTable,
661) -> Result<Prepared, RunError> {
662    let mut program = parse_and_resolve(entry, loader, surface)?;
663
664    // `test` blocks are declarations for the harness, not code to run.
665    //
666    // Dropped here rather than in `resolve_uses`, because `blue test` calls
667    // the resolver and then NEEDS the entry file's blocks — so the two
668    // callers want different things and the split has to live at this level.
669    //
670    // Without this, `blue run` on a file containing its own tests fails with
671    // `unbound symbol: deftest`: every package in the bidama distribution
672    // carries tests, so every one of them was unrunnable.
673    //
674    // Through `retain`, which drops each form's owner with it. A plain filter
675    // over the forms alone would slide every later form onto the wrong file.
676    program.retain(|f| !crate::uses::is_test_form(f));
677
678    // CHECK, on the annotated tree — the only tree that has annotations.
679    //
680    // **On the REAL spanned tree, including every imported package's.** This
681    // was the one caller that checked a spanless lift, because `resolve_uses`
682    // flattened the entry file and its imports into one list and `Span` is a
683    // byte range with no file identity — so a real span here would have
684    // reported an imported package's error at that offset in the ENTRY file, a
685    // precise-looking answer pointing at unrelated code.
686    //
687    // The fix is not a wider `Span` (that type is upstream, and its own docs
688    // put file identity on the caller: spans "are meaningful only relative to
689    // the string that produced them, which the caller is responsible for
690    // holding onto"). blue holds onto it BESIDE the span, per top-level form —
691    // see `uses::ResolvedProgram`.
692    let (outcome, names) = crate::timings::stage(crate::timings::Stage::Check, || {
693        check_stage(&program, builtins, false)
694    });
695    if !outcome.ok() {
696        return Err(RunError::Types(
697            outcome
698                .errors()
699                // `file:line:col: error[CODE]: message`, resolved against the
700                // file the form actually came from, then its help line.
701                .map(|d| render(&program, d))
702                .collect(),
703        ));
704    }
705
706    // ERASE, so the interpreter never sees a type — **on the spanned tree,
707    // and back out as one.**
708    //
709    // This used to be `erase_types(&program.sexps())` followed by a
710    // `lower_to_spanned` that stamped `Span::synthetic()` over every node, and
711    // the comment here said carrying real positions through was "a larger
712    // piece and is NOT built". It was not larger: erasure only ever DELETES
713    // nodes — the single node it invents is the `define` replacing
714    // `define-typed` — so there was never anything for a synthetic span to
715    // stand in for. See `crate::erase`.
716    //
717    // No lowering step follows. The tree the evaluator receives IS the tree the
718    // parser built, minus annotations, with the author's byte offsets intact.
719    let (lowered, erased) = crate::timings::stage(crate::timings::Stage::Erase, || {
720        let lowered = lower(&program, &names);
721        let erased = erase_types(&lowered);
722        (lowered, erased)
723    });
724
725    Ok(Prepared {
726        program,
727        lowered: std::sync::Arc::new(lowered),
728        erased,
729        visited: outcome.stats.visited,
730        typed_decls: outcome.stats.typed_decls,
731        seams: outcome.seams.len(),
732    })
733}
734
735/// Parse the entry (in `surface`, if one is given) and splice in its imports.
736pub(crate) fn parse_and_resolve(
737    entry: Entry<'_>,
738    loader: &dyn crate::uses::Loader,
739    surface: Option<&blue_lang_syntax::yakugo::Yakugo>,
740) -> Result<crate::uses::ResolvedProgram, RunError> {
741    let forms = crate::timings::stage(crate::timings::Stage::Parse, || {
742        let mut forms = match surface {
743            Some(pack) => blue_lang_syntax::parse_program_tree_in(entry.text, pack)
744                .map_err(|e| RunError::Parse(e.to_string()))?,
745            None => loader
746                .parse(entry.text)
747                .map_err(|e| RunError::Parse(e.to_string()))?,
748        };
749        // Every `@doc` example is a `test` block of the entry file: checked with
750        // the file's names, run by `blue test`, dropped by `run` with the others.
751        if surface.is_none() {
752            forms.extend(
753                blue_lang_syntax::doc::doctests(entry.text)
754                    .map_err(|e| RunError::Parse(e.to_string()))?,
755            );
756        }
757        Ok::<_, RunError>(forms)
758    })?;
759
760    // RESOLVE imports first, so everything below sees ONE program.
761    //
762    // Before the check on purpose: imported code is type-checked at the point
763    // its consumer imports it, rather than at whatever later moment its code
764    // first runs. A package that does not typecheck should break its importer's
765    // build, not their production run.
766    //
767    // One program, but not one FILE: the result records which file each
768    // top-level form came from, which is what lets a diagnostic below name a
769    // place instead of only a problem.
770    crate::timings::stage(crate::timings::Stage::Resolve, || {
771        crate::uses::resolve_uses(forms, entry, loader).map_err(RunError::Import)
772    })
773}
774
775/// **The check stage**: every rule in `blue_lang_check::RULES`, over a
776/// resolved program, with waivers applied.
777///
778/// One function, called by every door — `run`, the hosted loader, `blue test`
779/// and `blue check` — so a rule added to the registry is enforced by all of
780/// them at once. That is what makes an architecture rule a pipeline error
781/// rather than an optional report: there is no door that skips it.
782///
783/// Warnings (unused bindings, unused waivers) are reported for the ENTRY
784/// file only; an imported package's warnings belong to its author. Errors
785/// are reported for every file, because an imported package with one is
786/// broken for its importer.
787pub(crate) fn check_stage(
788    program: &crate::uses::ResolvedProgram,
789    builtins: &NameTable,
790    with_tests: bool,
791) -> (blue_lang_check::Outcome, NameTable) {
792    use crate::uses::ResolvedProgram;
793    let forms = program.forms();
794    let is_entry = |i: usize| program.owner_of(i) == Some(ResolvedProgram::ENTRY);
795
796    // Rules B0003–B0005: the typing ladder.
797    let mut outcome = blue_lang_check::check_program_in(forms, &|a, b| {
798        program.owner_of(a) == program.owner_of(b)
799    });
800    outcome
801        .diagnostics
802        .extend(data_law_diagnostics(program, &is_entry));
803
804    // Rules B0001–B0002: names. The program's own scopes come first (the
805    // entry file, then each bidama in import order), then the interpreter's.
806    let table = program_names(program, builtins);
807    let whole = |i: usize| with_tests && is_entry(i);
808    let (names, resolved) = blue_lang_check::names::check_names_whole(
809        forms,
810        &table,
811        &|i| namespace_of(program, i),
812        &is_entry,
813        &whole,
814    );
815    let stored = program
816        .file(ResolvedProgram::ENTRY)
817        .is_some_and(|f| f.path.is_some());
818    outcome.diagnostics.extend(names);
819    outcome.stats.names_resolved = resolved;
820    if stored {
821        let script = program
822            .file(ResolvedProgram::ENTRY)
823            .is_some_and(|f| f.package.is_none());
824        let found = blue_lang_check::form_rules::check(forms, &is_entry, script);
825        let silenced: Vec<tatara_lisp::Span> = found
826            .iter()
827            .filter(|d| d.code == blue_lang_check::Code::B0038)
828            .map(|d| d.span)
829            .collect();
830        outcome
831            .diagnostics
832            .retain(|d| !(d.code == blue_lang_check::Code::B0002 && silenced.contains(&d.span)));
833        outcome.diagnostics.extend(found);
834    }
835    outcome
836        .diagnostics
837        .extend(blue_lang_check::contracts::check(
838            forms,
839            &table,
840            &|i| namespace_of(program, i),
841            &is_entry,
842            &|i| {
843                program
844                    .owner_of(i)
845                    .and_then(|f| program.file(f))
846                    .map(|f| f.text.as_str())
847            },
848            crate::docs::signatures(),
849        ));
850
851    outcome.diagnostics.extend(doc_diagnostics(program));
852
853    // Waivers, per file: B0007 for a malformed one, B0008 for one that
854    // suppresses nothing.
855    let mut waivers = Vec::new();
856    for file in program.files() {
857        let spans: Vec<(usize, tatara_lisp::Span)> = forms
858            .iter()
859            .enumerate()
860            .filter(|(i, _)| program.owner_of(*i) == Some(file.id))
861            .map(|(i, f)| (i, f.span))
862            .collect();
863        let (found, malformed) = blue_lang_check::waiver::collect(&file.text, &spans);
864        waivers.extend(found);
865        outcome.diagnostics.extend(malformed);
866    }
867    outcome.waived = blue_lang_check::waiver::apply(&mut outcome.diagnostics, waivers, &is_entry);
868    if !stored {
869        outcome.diagnostics.retain(|d| {
870            !matches!(
871                d.code,
872                blue_lang_check::Code::B0023
873                    | blue_lang_check::Code::B0036
874                    | blue_lang_check::Code::B0037
875                    | blue_lang_check::Code::B0067
876                    | blue_lang_check::Code::B0061
877                    | blue_lang_check::Code::B0062
878                    | blue_lang_check::Code::B0063
879            )
880        });
881    }
882    let judge = blue_lang_check::evolution::FormJudge::from_env().unwrap_or_default();
883    outcome
884        .diagnostics
885        .retain(|d| judge.reports(d.code, is_entry(d.top_level)));
886    // A rule whose census has not reached zero is computed, and counted, but
887    // not enforced: its findings move to `census` (see `RULES`' `ratchet`).
888    let (enforced, census): (Vec<_>, Vec<_>) = std::mem::take(&mut outcome.diagnostics)
889        .into_iter()
890        .partition(|d| d.code.rule().active());
891    outcome.diagnostics = enforced;
892    outcome.census = census;
893    outcome
894        .diagnostics
895        .sort_by_key(|d| (d.top_level, d.span.start, d.code));
896    (outcome, table)
897}
898
899/// The documentation rules (B0056–B0060) over the entry file, when it is a
900/// bidama's: a bidama is judged at its own check, as its form is; an importer
901/// is not answerable for a dependency's documentation.
902fn doc_diagnostics(program: &crate::uses::ResolvedProgram) -> Vec<blue_lang_check::Diagnostic> {
903    use crate::uses::ResolvedProgram;
904    let Some(file) = program.file(ResolvedProgram::ENTRY) else {
905        return Vec::new();
906    };
907    let Some(package) = file.package.as_deref() else {
908        return Vec::new();
909    };
910    let forms: Vec<(usize, &tatara_lisp::Spanned)> = program
911        .forms()
912        .iter()
913        .enumerate()
914        .filter(|(i, f)| {
915            program.owner_of(*i) == Some(ResolvedProgram::ENTRY)
916                && !blue_lang_syntax::doc::is_doctest(f)
917        })
918        .collect();
919    let graph = file.path.as_deref().map(crate::doc_sources::graph_meanings);
920    let meaning = |name: &str| {
921        graph
922            .as_ref()
923            .and_then(|g| g.get(&(package.to_string(), name.to_string())).cloned())
924    };
925    let names_line = |name: &str| crate::docs::doc_of(name).map(|d| d.doc.to_string());
926    blue_lang_check::doc_rules::check_file(
927        &file.text,
928        &forms,
929        package,
930        &blue_lang_check::doc_rules::Sources {
931            meaning: &meaning,
932            names_line: &names_line,
933        },
934    )
935}
936
937fn data_law_diagnostics(
938    program: &crate::uses::ResolvedProgram,
939    is_entry: &dyn Fn(usize) -> bool,
940) -> Vec<blue_lang_check::Diagnostic> {
941    use blue_lang_check::data_laws;
942    let forms = program.forms();
943    let fragments = data_laws::Fragments::collect(forms);
944    let frontend = program.frontend();
945    let mut out = Vec::new();
946    let schema = match frontend.and_then(|f| f.table.as_ref()) {
947        Some((label, text)) => match data_laws::Schema::read(text) {
948            Ok(s) => Some(s),
949            Err(why) => {
950                if let Some((i, first)) = forms.iter().enumerate().find(|(i, _)| is_entry(*i)) {
951                    out.push(
952                        blue_lang_check::Diagnostic::new(
953                            blue_lang_check::Code::B0050,
954                            format!("the generated schema table {label} does not read: {why}"),
955                            first.span,
956                        )
957                        .at_top_level(i)
958                        .with_help("regenerate it: `nix run .#regen`"),
959                    );
960                }
961                None
962            }
963        },
964        None => None,
965    };
966    if fragments.is_empty() && schema.is_none() && !frontend.is_some_and(|f| f.view) {
967        return out;
968    }
969    out.extend(data_laws::check(
970        forms,
971        &data_laws::Context {
972            fragments: &fragments,
973            schema: schema.as_ref(),
974            is_entry,
975            view: frontend.is_some_and(|f| f.view),
976        },
977    ));
978    out
979}
980
981/// The whole name table the check stage resolves against: the program's
982/// own scopes (each file and bidama, by namespace) followed by `builtins`.
983///
984/// **A top-level call to a builtin macro can define names**: `defflow(slug,
985/// trim, downcase)` expands to `(define slug …)`, and nothing in the source
986/// spells `define`. Such a call is expanded here, on a hostless fork, and what
987/// its expansion defines is bound in the calling file's namespace. Only
988/// BUILTIN macros are expanded: their bodies are the runtime's own
989/// templates. A program's own `defmacro` is not run at check time — checking a
990/// file (and an editor checking on every keystroke) must not execute the code
991/// it checks — so names a program macro generates are not seen; none of the
992/// corpus's macros generates one (measured 2026-09-29).
993#[must_use]
994pub fn program_names(program: &crate::uses::ResolvedProgram, builtins: &NameTable) -> NameTable {
995    use blue_lang_check::Binding;
996    let mut table = NameTable::new();
997    table.add_program(program.forms(), |i| namespace_of(program, i));
998    let builtin_macro = |head: &str| {
999        builtins
1000            .scopes()
1001            .iter()
1002            .any(|s| s.namespace == Namespace::Macro && s.get(head).is_some())
1003    };
1004    let mut expander: Option<tatara_lisp_eval::Interpreter<()>> = None;
1005    for (i, form) in program.forms().iter().enumerate() {
1006        let Some(head) = form
1007            .as_list()
1008            .and_then(|items| items.first())
1009            .and_then(tatara_lisp::Spanned::as_symbol)
1010        else {
1011            continue;
1012        };
1013        if !builtin_macro(head) {
1014            continue;
1015        }
1016        let interp = expander.get_or_insert_with(crate::interpreter_hostless);
1017        // A failed expansion defines nothing here; the evaluator will report
1018        // it, with its position, when the form runs.
1019        let Ok(expanded) = interp.fully_expand(form, &mut ()) else {
1020            continue;
1021        };
1022        let nullary = blue_lang_syntax::scope::nullary_definition(&expanded);
1023        for (name, span, kind) in blue_lang_check::names::definitions_of(&expanded) {
1024            table.define(
1025                namespace_of(program, i),
1026                Binding {
1027                    nullary: nullary == Some(name.as_str()),
1028                    name,
1029                    kind,
1030                    span: Some(span),
1031                    top_level: Some(i),
1032                },
1033            );
1034        }
1035    }
1036    for scope in builtins.scopes() {
1037        table.push(scope.clone());
1038    }
1039    table.spell_bare_like(builtins);
1040    let form_file: Vec<usize> = (0..program.forms().len())
1041        .map(|i| program.owner_of(i).map_or(usize::MAX, |f| f.index()))
1042        .collect();
1043    let mut imports: std::collections::BTreeMap<usize, blue_lang_check::names::FileImports> =
1044        std::collections::BTreeMap::new();
1045    for (file, import) in program.imports() {
1046        let entry = imports.entry(file.index()).or_default();
1047        entry.add(&import.package, import.names.iter().map(|(n, _)| n.clone()));
1048        let at = import.span.start;
1049        entry.use_at = Some(entry.use_at.map_or(at, |a| a.min(at)));
1050    }
1051    // A FACADE — a bidama that defines nothing and `use`s others (zenbu) —
1052    // exists so a consumer can depend on "everything": `use("zenbu")` makes
1053    // every member's QUALIFIER reachable (`toukei::median`), never a bare
1054    // name. Followed to a fixpoint, so a facade of facades works too.
1055    let facade_uses =
1056        |pkg: &str,
1057         imports: &std::collections::BTreeMap<usize, blue_lang_check::names::FileImports>| {
1058            let defines = table.bidama(pkg).is_some_and(|s| !s.is_empty());
1059            if defines {
1060                return std::collections::BTreeSet::new();
1061            }
1062            program
1063                .files()
1064                .iter()
1065                .filter(|f| f.package.as_deref() == Some(pkg))
1066                .filter_map(|f| imports.get(&f.id.index()))
1067                .flat_map(|i| i.uses.iter().cloned())
1068                .collect::<std::collections::BTreeSet<String>>()
1069        };
1070    loop {
1071        let mut grew = false;
1072        let files: Vec<usize> = imports.keys().copied().collect();
1073        for f in files {
1074            let used: Vec<String> = imports[&f].uses.iter().cloned().collect();
1075            for p in used {
1076                for member in facade_uses(&p, &imports) {
1077                    if let Some(i) = imports.get_mut(&f) {
1078                        if i.uses.insert(member.clone()) {
1079                            i.via.insert(member, p.clone());
1080                            grew = true;
1081                        }
1082                    }
1083                }
1084            }
1085        }
1086        if !grew {
1087            break;
1088        }
1089    }
1090    let mut pkg_uses: std::collections::BTreeMap<String, std::collections::BTreeSet<String>> =
1091        std::collections::BTreeMap::new();
1092    for (file, import) in program.imports() {
1093        if let Some(p) = program.file(*file).and_then(|f| f.package.clone()) {
1094            pkg_uses
1095                .entry(p)
1096                .or_default()
1097                .insert(import.package.clone());
1098        }
1099    }
1100    table.attach_files(form_file, imports);
1101    table.attach_package_uses(pkg_uses);
1102    table.attach_needs(program.needs().clone());
1103    // Where each file lives and which distribution each bidama ships in: a
1104    // bridge reference (B0021) is an error inside the bidama's own
1105    // distribution and a warning outside it.
1106    let canonical = |p: &std::path::Path| {
1107        std::fs::canonicalize(p)
1108            .unwrap_or_else(|_| p.to_path_buf())
1109            .display()
1110            .to_string()
1111    };
1112    let mut file_paths = std::collections::BTreeMap::new();
1113    let mut pkg_roots = std::collections::BTreeMap::new();
1114    for f in program.files() {
1115        if let Some(path) = &f.path {
1116            file_paths.insert(f.id.index(), canonical(path));
1117            if let (Some(pkg), Some(root)) =
1118                (&f.package, path.parent().and_then(std::path::Path::parent))
1119            {
1120                pkg_roots
1121                    .entry(pkg.clone())
1122                    .or_insert_with(|| canonical(root));
1123            }
1124        }
1125    }
1126    table.attach_paths(file_paths, pkg_roots);
1127    let versions = program.versions().clone();
1128    table.apply_legacy(program.forms(), &|i| namespace_of(program, i), &|p| {
1129        versions.get(p).cloned()
1130    });
1131    table
1132}
1133
1134/// The forms the evaluator runs, from a checked program: every `use`
1135/// declaration inert (`nil`, so each index still names its top-level form)
1136/// and every name at the key it runs under — the RESOLVED tree under
1137/// per-bidama namespaces (`blue_lang_check::names::Resolved::resolved_tree`).
1138///
1139/// A definition runs as `retsu/first` (a script's as `%root/f`), a
1140/// reference to it as the same key, and a builtin as its bare name. Keys
1141/// cannot collide across namespaces, so a bidama's `first` no longer
1142/// replaces the builtin for anyone who did not import it, and an importer's
1143/// `helper` no longer replaces a bidama's own: the runtime binds exactly what
1144/// the check stage resolved.
1145#[must_use]
1146pub fn lower(
1147    program: &crate::uses::ResolvedProgram,
1148    names: &NameTable,
1149) -> Vec<tatara_lisp::Spanned> {
1150    lower_with(program, names, true)
1151}
1152
1153#[must_use]
1154pub fn lower_with(
1155    program: &crate::uses::ResolvedProgram,
1156    names: &NameTable,
1157    simplify_chains: bool,
1158) -> Vec<tatara_lisp::Spanned> {
1159    let mut resolved = blue_lang_check::names::resolve_program(program.forms(), names, &|i| {
1160        namespace_of(program, i)
1161    });
1162    let simplified = if simplify_chains {
1163        chain_simplified(program.forms(), &resolved)
1164    } else {
1165        None
1166    };
1167    let forms: &[tatara_lisp::Spanned] = simplified.as_deref().unwrap_or(program.forms());
1168    if simplified.is_some() {
1169        resolved =
1170            blue_lang_check::names::resolve_program(forms, names, &|i| namespace_of(program, i));
1171    }
1172    resolved
1173        .resolved_tree(forms, blue_lang_check::names::Rule::Namespaced)
1174        .into_iter()
1175        .zip(forms)
1176        .enumerate()
1177        .map(|(i, (resolved, written))| {
1178            if crate::uses::use_target(written).is_some()
1179                || blue_lang_syntax::scope::legacy_target(written).is_some()
1180            {
1181                tatara_lisp::Spanned::new(written.span, tatara_lisp::SpannedForm::Nil)
1182            } else {
1183                let file = program.owner_of(i).and_then(|id| program.file(id));
1184                match file {
1185                    Some(f) => locate_raises(&resolved, &raise_site(f)),
1186                    None => resolved,
1187                }
1188            }
1189        })
1190        .collect()
1191}
1192
1193fn chain_simplified(
1194    forms: &[tatara_lisp::Spanned],
1195    resolved: &blue_lang_check::names::Resolved,
1196) -> Option<Vec<tatara_lisp::Spanned>> {
1197    let respell: std::collections::HashMap<usize, blue_lang_check::respell::Respelling> = resolved
1198        .respellings
1199        .iter()
1200        .filter(|(_, r)| matches!(r.respell, blue_lang_check::respell::Respell::Chain(_)))
1201        .cloned()
1202        .collect();
1203    if respell.is_empty() {
1204        return None;
1205    }
1206    let bare = std::collections::HashSet::new();
1207    let canon = Canon {
1208        bare: &bare,
1209        respell: &respell,
1210    };
1211    Some(forms.iter().map(|f| canon.form(f)).collect())
1212}
1213
1214fn raise_site(file: &crate::uses::SourceFile) -> impl Fn(tatara_lisp::Span) -> String + '_ {
1215    move |span| {
1216        let (line, _) =
1217            blue_lang_syntax::Span::line_col(&file.text, span.start.min(file.text.len()));
1218        let shown = file
1219            .path
1220            .as_ref()
1221            .map_or_else(|| "<source>".to_string(), |p| p.display().to_string());
1222        format!("{shown}:{line}")
1223    }
1224}
1225
1226fn locate_raises(
1227    form: &tatara_lisp::Spanned,
1228    site: &dyn Fn(tatara_lisp::Span) -> String,
1229) -> tatara_lisp::Spanned {
1230    use tatara_lisp::{Atom, Spanned, SpannedForm};
1231    let inner = match &form.form {
1232        SpannedForm::List(items) => {
1233            let mut out: Vec<Spanned> = items.iter().map(|i| locate_raises(i, site)).collect();
1234            let raising = items.first().and_then(Spanned::as_symbol) == Some("raise")
1235                && (3..=4).contains(&items.len());
1236            if raising {
1237                out[0] = Spanned::new(
1238                    items[0].span,
1239                    SpannedForm::Atom(Atom::Symbol(crate::errors::RAISE_AT.to_string())),
1240                );
1241                out.insert(
1242                    1,
1243                    Spanned::new(items[0].span, SpannedForm::Atom(Atom::Str(site(form.span)))),
1244                );
1245            }
1246            SpannedForm::List(out)
1247        }
1248        SpannedForm::Quote(_) | SpannedForm::Quasiquote(_) => return form.clone(),
1249        other => other.clone(),
1250    };
1251    Spanned::new(form.span, inner)
1252}
1253
1254/// The namespace top-level form `i` of `program` defines into.
1255#[must_use]
1256pub fn namespace_of(program: &crate::uses::ResolvedProgram, i: usize) -> Namespace {
1257    let file = program.owner_of(i).and_then(|id| program.file(id));
1258    match file {
1259        Some(f) => match &f.package {
1260            Some(p) => Namespace::Bidama(p.clone()),
1261            None => Namespace::File(
1262                f.path
1263                    .as_ref()
1264                    .map_or_else(|| "<anonymous>".to_string(), |p| p.display().to_string()),
1265            ),
1266        },
1267        None => Namespace::File("<unknown file>".to_string()),
1268    }
1269}
1270
1271/// Names only the test harness binds.
1272///
1273/// In scope for EVERY check, not only for test blocks. A bidama may carry a
1274/// test helper — a plain `def` that asserts, called only from its `test`
1275/// blocks (`heni`'s `hn_end_to_end` is one) — and every program importing that
1276/// bidama would otherwise be refused for a function it never calls. The
1277/// refusal would be scoped to the importer's whole program, not to the fault.
1278/// The cost, stated: an `assert` outside any test that `blue run` actually
1279/// reaches is caught at runtime (`blue-assert` is unbound there), not here.
1280///
1281/// `blue-lang-test` registers these on its own interpreter, and this crate
1282/// cannot depend on it (it depends on this one), so the list lives beside the
1283/// only name in it: the lowered `assert`, which `blue-lang-syntax` owns.
1284pub const HARNESS_NAMES: &[&str] = &[
1285    blue_lang_syntax::LOWERED_ASSERT,
1286    blue_lang_syntax::LOWERED_ASSERT_PANICS,
1287    blue_lang_syntax::LOWERED_TEST_META,
1288];
1289
1290/// The check stage's builtin scopes, read off the interpreter that will run
1291/// the program: the test harness's names ([`HARNESS_NAMES`]), then its special
1292/// forms, its macros, and every value it binds — the last three in the order
1293/// the evaluator consults for a head.
1294///
1295/// Read from a live interpreter rather than a list, for the reason
1296/// `Interpreter::resolve_head` gives: a list is a second copy of the truth and
1297/// goes stale on the next installer that lands. An embedder's own
1298/// primitives are in scope here because the embedder installs them first.
1299#[must_use]
1300pub fn builtin_names<H: 'static>(interp: &tatara_lisp_eval::Interpreter<H>) -> NameTable {
1301    builtin_names_with(interp, &[])
1302}
1303
1304#[must_use]
1305pub fn builtin_names_with<H: 'static>(
1306    interp: &tatara_lisp_eval::Interpreter<H>,
1307    host: &[crate::host_surface::HostName],
1308) -> NameTable {
1309    use blue_lang_check::{Binding, Scope, ScopeKind};
1310    use tatara_lisp_eval::HeadBinding;
1311    let mut special = Scope::new(Namespace::SpecialForm);
1312    let mut macros = Scope::new(Namespace::Macro);
1313    let mut values = Scope::new(Namespace::Builtin);
1314    // `reserved_head_names` reads the ROOT frame only, so a name installed on
1315    // a fork (the input primitives, on every `run`) is invisible to it; those
1316    // are asked about by name instead, which `resolve_head` answers through
1317    // every frame.
1318    let mut names: Vec<std::sync::Arc<str>> = interp.reserved_head_names().into_iter().collect();
1319    names.extend(
1320        crate::inputs::INPUT_PRIMITIVES
1321            .iter()
1322            .filter(|n| interp.resolve_head(n).is_some())
1323            .map(|n| std::sync::Arc::from(*n)),
1324    );
1325    for name in names {
1326        let (scope, kind) = match interp.resolve_head(&name) {
1327            Some(HeadBinding::SpecialForm) => (&mut special, ScopeKind::SpecialForm),
1328            Some(HeadBinding::Macro) => (&mut macros, ScopeKind::Macro),
1329            _ => (&mut values, ScopeKind::Value),
1330        };
1331        let nullary = kind == ScopeKind::Value
1332            && interp.lookup_global(&name).is_some_and(|v| {
1333                nullary_value(&v)
1334                    || (crate::docs::doc_of(&name).is_some_and(|d| d.signature == &*name)
1335                        && matches!(
1336                            v,
1337                            tatara_lisp_eval::Value::NativeFn(_)
1338                                | tatara_lisp_eval::Value::Closure(_)
1339                        ))
1340            });
1341        scope.bind(Binding {
1342            name: name.to_string(),
1343            kind,
1344            span: None,
1345            top_level: None,
1346            nullary,
1347        });
1348    }
1349    for h in host {
1350        values.bind(Binding {
1351            name: h.name.clone(),
1352            kind: ScopeKind::Value,
1353            span: None,
1354            top_level: None,
1355            nullary: h.nullary(),
1356        });
1357    }
1358    let mut table = NameTable::new();
1359    let mut harness = Scope::new(Namespace::Harness);
1360    for n in HARNESS_NAMES {
1361        harness.bind(Binding {
1362            name: (*n).to_string(),
1363            kind: ScopeKind::Value,
1364            span: None,
1365            top_level: None,
1366            nullary: false,
1367        });
1368    }
1369    table.push(harness);
1370    table.push(special);
1371    table.push(macros);
1372    table.push(values);
1373    table.spell_bare_only(
1374        crate::docs::NAMES
1375            .iter()
1376            .filter(|d| d.signature == d.name)
1377            .map(|d| d.name.to_string())
1378            .chain(host.iter().filter(|h| h.nullary()).map(|h| h.name.clone()))
1379            .collect(),
1380    );
1381    table
1382}
1383
1384#[must_use]
1385pub fn nullary_value(value: &tatara_lisp_eval::Value) -> bool {
1386    match value {
1387        tatara_lisp_eval::Value::NativeFn(f) => {
1388            matches!(f.arity, tatara_lisp_eval::ffi::Arity::Exact(0))
1389        }
1390        tatara_lisp_eval::Value::Closure(c) => c.params.is_empty() && c.rest.is_none(),
1391        _ => false,
1392    }
1393}
1394
1395/// A diagnostic as a person reads it: `file:line:col: error[B0001]: message`,
1396/// then an indented `help:` line when it has one, and a `fix:` line naming
1397/// the one command that applies blue's fixer for it.
1398#[must_use]
1399pub fn render(program: &crate::uses::ResolvedProgram, d: &blue_lang_check::Diagnostic) -> String {
1400    let head = d.to_string();
1401    let mut out = program.locate(d.top_level, d.span, &head).to_string();
1402    if let Some(help) = &d.help {
1403        out.push_str("\n  help: ");
1404        out.push_str(help);
1405    }
1406    let file = program
1407        .owner_of(d.top_level)
1408        .and_then(|id| program.file(id))
1409        .and_then(|f| f.path.as_ref());
1410    if let Some(cmd) =
1411        file.and_then(|p| blue_lang_check::evolution::fix_command(d, &p.display().to_string()))
1412    {
1413        out.push_str("\n  fix: ");
1414        out.push_str(&cmd);
1415    }
1416    out
1417}
1418
1419/// What [`check_entry`] checks.
1420#[derive(Clone, Copy, Debug, PartialEq, Eq)]
1421pub enum Checking {
1422    /// The program `blue run` would evaluate: test blocks dropped.
1423    Program,
1424    /// The program plus the entry file's test blocks, as `blue test` and
1425    /// `blue check` see it.
1426    WithTests,
1427}
1428
1429/// A program through the check stage, with everything the stage found.
1430pub struct Checked {
1431    pub program: crate::uses::ResolvedProgram,
1432    pub outcome: blue_lang_check::Outcome,
1433    /// The name table the stage resolved against: every program scope, then
1434    /// the builtins. What an editor completes from.
1435    pub names: NameTable,
1436}
1437
1438impl Checked {
1439    /// The forms an evaluator runs: [`lower`]ed, then erased — the tree
1440    /// `run` evaluates, for a door that brings its own evaluator (the VM).
1441    #[must_use]
1442    pub fn erased(&self) -> Vec<tatara_lisp::Spanned> {
1443        erase_types(&self.lowered())
1444    }
1445
1446    /// The program [`lower`]ed: what [`Checked::erased`] erases, and the
1447    /// tree `def_hash` reads (`crate::Account::defer_identities`).
1448    #[must_use]
1449    pub fn lowered(&self) -> Vec<tatara_lisp::Spanned> {
1450        lower(&self.program, &self.names)
1451    }
1452
1453    /// What `blue test` hands the harness: the program [`lower`]ed, spans
1454    /// projected away.
1455    #[must_use]
1456    pub fn evaluable(&self) -> Vec<tatara_lisp::Sexp> {
1457        lower(&self.program, &self.names)
1458            .iter()
1459            .map(tatara_lisp::Spanned::to_sexp)
1460            .collect()
1461    }
1462
1463    /// Every non-local reference, resolved under both rules — today's flat
1464    /// one and per-bidama namespaces — and the resolved trees built from them
1465    /// (`blue ast --resolved`).
1466    #[must_use]
1467    pub fn resolve(&self) -> blue_lang_check::names::Resolved {
1468        blue_lang_check::names::resolve_program(self.program.forms(), &self.names, &|i| {
1469            namespace_of(&self.program, i)
1470        })
1471    }
1472}
1473
1474impl Checked {
1475    #[must_use]
1476    pub fn call_explicit_forms(&self) -> Vec<tatara_lisp::Spanned> {
1477        self.resolve().call_explicit_tree(self.program.forms())
1478    }
1479
1480    #[must_use]
1481    pub fn canonical_entry(&self, src: &str) -> Option<Vec<tatara_lisp::Spanned>> {
1482        use crate::uses::ResolvedProgram;
1483        let program = &self.program;
1484        let entry: Vec<&tatara_lisp::Spanned> = program
1485            .forms()
1486            .iter()
1487            .enumerate()
1488            .filter(|(i, _)| program.owner_of(*i) == Some(ResolvedProgram::ENTRY))
1489            .map(|(_, f)| f)
1490            .filter(|f| !blue_lang_syntax::doc::is_doctest(f))
1491            .collect();
1492        let written = blue_lang_syntax::parse_program_tree(src).ok()?;
1493        let same = written.len() == entry.len()
1494            && written
1495                .iter()
1496                .zip(&entry)
1497                .all(|(w, e)| w.to_sexp() == e.to_sexp() && w.span == e.span);
1498        if !same {
1499            return None;
1500        }
1501        let resolved = self.resolve();
1502        let bare: std::collections::HashSet<usize> = resolved
1503            .parenthesized_nullary_calls()
1504            .into_iter()
1505            .filter(|r| program.owner_of(r.top_level) == Some(ResolvedProgram::ENTRY))
1506            .map(blue_lang_check::names::Reference::node_address)
1507            .collect();
1508        let waived: Vec<tatara_lisp::Span> = self
1509            .outcome
1510            .waived
1511            .iter()
1512            .map(|w| w.diagnostic.span)
1513            .collect();
1514        let respell: std::collections::HashMap<usize, blue_lang_check::respell::Respelling> =
1515            resolved
1516                .respellings
1517                .iter()
1518                .filter(|(_, r)| !waived.contains(&r.span))
1519                .cloned()
1520                .collect();
1521        let canon = Canon {
1522            bare: &bare,
1523            respell: &respell,
1524        };
1525        let forms: Vec<tatara_lisp::Spanned> = entry.into_iter().map(|f| canon.form(f)).collect();
1526        let script = program
1527            .file(ResolvedProgram::ENTRY)
1528            .is_some_and(|f| f.package.is_none());
1529        let forms = if script {
1530            blue_lang_check::form_rules::without_prefix(&forms).unwrap_or(forms)
1531        } else {
1532            forms
1533        };
1534        let forms = blue_lang_check::form_rules::without_main_guard(&forms).unwrap_or(forms);
1535        Some(blue_lang_check::form_rules::eta_reduced(&forms).unwrap_or(forms))
1536    }
1537}
1538
1539struct Canon<'a> {
1540    bare: &'a std::collections::HashSet<usize>,
1541    respell: &'a std::collections::HashMap<usize, blue_lang_check::respell::Respelling>,
1542}
1543
1544impl Canon<'_> {
1545    fn form(&self, form: &tatara_lisp::Spanned) -> tatara_lisp::Spanned {
1546        use tatara_lisp::{Spanned, SpannedForm};
1547        let inner = match &form.form {
1548            SpannedForm::List(items) => match items.as_slice() {
1549                [head] if self.bare.contains(&(std::ptr::from_ref(head) as usize)) => {
1550                    return Spanned::new(form.span, head.form.clone());
1551                }
1552                [head, ..]
1553                    if self
1554                        .respell
1555                        .contains_key(&(std::ptr::from_ref(head) as usize)) =>
1556                {
1557                    let r = &self.respell[&(std::ptr::from_ref(head) as usize)];
1558                    let walked: Vec<Spanned> = items.iter().map(|i| self.form(i)).collect();
1559                    return blue_lang_check::respell::apply(form, &walked, r);
1560                }
1561                _ => SpannedForm::List(items.iter().map(|i| self.form(i)).collect()),
1562            },
1563            SpannedForm::Quote(i) => SpannedForm::Quote(Box::new(self.form(i))),
1564            SpannedForm::Quasiquote(i) => SpannedForm::Quasiquote(Box::new(self.form(i))),
1565            SpannedForm::Unquote(i) => SpannedForm::Unquote(Box::new(self.form(i))),
1566            SpannedForm::UnquoteSplice(i) => SpannedForm::UnquoteSplice(Box::new(self.form(i))),
1567            other => other.clone(),
1568        };
1569        Spanned::new(form.span, inner)
1570    }
1571}
1572
1573/// Parse, resolve and CHECK, without running: the door for `blue check`,
1574/// `blue test` and the language server.
1575///
1576/// Returns every diagnostic, errors and warnings alike, rather than failing on
1577/// the first error: a reader fixing a file wants the whole list, and an agent
1578/// repairing one wants it in one pass. Whether errors stop anything is the
1579/// caller's decision — `blue test` refuses to run tests over a program with
1580/// one, exactly as `run` refuses to evaluate it.
1581///
1582/// The name oracle is the interpreter `blue run` would build: hostless plus
1583/// input primitives.
1584///
1585/// # Errors
1586///
1587/// A parse or import error, which leaves no program to check.
1588pub fn check_entry(
1589    entry: Entry<'_>,
1590    loader: &dyn crate::uses::Loader,
1591    surface: Option<&blue_lang_syntax::yakugo::Yakugo>,
1592    checking: Checking,
1593) -> Result<Checked, RunError> {
1594    let mut program = parse_and_resolve(entry, loader, surface)?;
1595    if checking == Checking::Program {
1596        program.retain(|f| !crate::uses::is_test_form(f));
1597    }
1598    let host = match entry.path {
1599        Some(path) => crate::host_surface::near(path)
1600            .map_err(|e| RunError::Import(e.to_string()))?
1601            .map(|s| s.functions)
1602            .unwrap_or_default(),
1603        None => Vec::new(),
1604    };
1605    let interp = runtime(Inputs::new());
1606    let builtins = crate::timings::stage(crate::timings::Stage::Names, || {
1607        builtin_names_with(&interp, &host)
1608    });
1609    let (outcome, names) = crate::timings::stage(crate::timings::Stage::Check, || {
1610        check_stage(&program, &builtins, checking == Checking::WithTests)
1611    });
1612    Ok(Checked {
1613        program,
1614        outcome,
1615        names,
1616    })
1617}
1618
1619#[must_use]
1620pub fn spelling_code(spelling: blue_lang_syntax::Spelling) -> blue_lang_check::Code {
1621    use blue_lang_check::Code;
1622    use blue_lang_syntax::Spelling;
1623    match spelling {
1624        Spelling::LogicalSymbol => Code::B0025,
1625        Spelling::Unless => Code::B0026,
1626        Spelling::ElseIf => Code::B0027,
1627        Spelling::CallForm => Code::B0028,
1628        Spelling::KeywordRocket => Code::B0029,
1629        Spelling::ParameterList => Code::B0030,
1630        Spelling::OperatorSpelling => Code::B0031,
1631        Spelling::TrailingComma => Code::B0032,
1632        Spelling::NegatedComparison => Code::B0034,
1633        Spelling::ElseNil => Code::B0035,
1634        Spelling::TryCatch => Code::B0037,
1635        Spelling::Apply => Code::B0042,
1636        Spelling::MapPun => Code::B0046,
1637        Spelling::NilTest => Code::B0047,
1638        Spelling::NewlineEscape => Code::B0065,
1639        Spelling::Index => Code::B0066,
1640        Spelling::TestDeclaration => Code::B0068,
1641    }
1642}
1643
1644#[must_use]
1645pub fn form_diagnostics(text: &str) -> Vec<blue_lang_check::Diagnostic> {
1646    match blue_lang_fmt::format_source_lossless(text) {
1647        Ok(formatted) => form_diagnostics_against(text, &formatted),
1648        Err(_) => Vec::new(),
1649    }
1650}
1651
1652#[must_use]
1653pub fn form_diagnostics_against(text: &str, formatted: &str) -> Vec<blue_lang_check::Diagnostic> {
1654    use blue_lang_check::{Code, Diagnostic};
1655    if formatted.trim_end() == text.trim_end() {
1656        return Vec::new();
1657    }
1658    let help = "`blue fmt --write FILE` writes it; no compile door writes a file (`BLUE_LAYOUT=rewrite` restores formatting in place)";
1659    let mut out: Vec<Diagnostic> = blue_lang_syntax::spelling_notes(text)
1660        .unwrap_or_default()
1661        .into_iter()
1662        .map(|n| {
1663            let message = if n.canonical.is_empty() {
1664                format!("`{}` is not written", n.written)
1665            } else {
1666                format!("`{}` is written `{}`", n.written, n.canonical)
1667            };
1668            Diagnostic::new(spelling_code(n.spelling), message, n.span)
1669                .at_top_level(0)
1670                .with_help(help)
1671        })
1672        .collect();
1673    out.extend(
1674        literal_diagnostics(text)
1675            .into_iter()
1676            .map(|d| d.with_help(help)),
1677    );
1678    if out.is_empty() {
1679        let at = text
1680            .lines()
1681            .zip(formatted.lines())
1682            .position(|(a, b)| a != b)
1683            .unwrap_or_else(|| text.lines().count().min(formatted.lines().count()));
1684        let start: usize = text.lines().take(at).map(|l| l.len() + 1).sum();
1685        let start = start.min(text.len());
1686        let end = text[start..].find('\n').map_or(text.len(), |e| start + e);
1687        out.push(
1688            Diagnostic::new(
1689                Code::B0024,
1690                format!("line {} is not laid out as blue writes it", at + 1),
1691                tatara_lisp::Span::new(start, end),
1692            )
1693            .at_top_level(0)
1694            .with_help(help),
1695        );
1696    }
1697    out.sort_by_key(|d| (d.span.start, d.code));
1698    out
1699}
1700
1701fn literal_diagnostics(text: &str) -> Vec<blue_lang_check::Diagnostic> {
1702    use blue_lang_syntax::{Atom, TokenKind};
1703    let Ok(toks) = blue_lang_syntax::lex(text) else {
1704        return Vec::new();
1705    };
1706    toks.iter()
1707        .filter_map(|t| {
1708            let atom = match &t.kind {
1709                TokenKind::Int(v) => Atom::Int(*v),
1710                TokenKind::Float(v) => Atom::Float(*v),
1711                TokenKind::Str(v) => Atom::Str(v.clone()),
1712                _ => return None,
1713            };
1714            let written = text.get(t.span.start..t.span.end)?;
1715            let canonical = blue_lang_fmt::render_literal(&atom);
1716            (written != canonical).then(|| {
1717                blue_lang_check::Diagnostic::new(
1718                    blue_lang_check::Code::B0033,
1719                    format!("`{written}` is written `{canonical}`"),
1720                    t.span,
1721                )
1722                .at_top_level(0)
1723            })
1724        })
1725        .collect()
1726}
1727
1728/// A syntax error in `text` as a [`B0006`](blue_lang_check::Code::B0006)
1729/// diagnostic, so a tool reading `--format json` gets a parse failure in the
1730/// same shape as every other finding. `None` when `text` parses.
1731#[must_use]
1732pub fn syntax_diagnostic(text: &str) -> Option<blue_lang_check::Diagnostic> {
1733    let e = blue_lang_syntax::parse_program_tree(text).err()?;
1734    Some(
1735        blue_lang_check::Diagnostic::new(blue_lang_check::Code::B0006, e.message, e.span)
1736            .at_top_level(0),
1737    )
1738}
1739
1740/// A runtime message as the author wrote the names in it: a definition runs
1741/// under its key (`retsu/first`, `%root/f`), and a message naming one in
1742/// backticks names it `retsu::first`, or `f` for the author's own script.
1743#[must_use]
1744pub fn display_keys(message: &str) -> String {
1745    let mut out = String::with_capacity(message.len());
1746    let mut parts = message.split('`');
1747    if let Some(first) = parts.next() {
1748        out.push_str(first);
1749    }
1750    for (i, part) in parts.enumerate() {
1751        out.push('`');
1752        // Odd pieces (0-based here) are inside backticks.
1753        if i % 2 == 0 {
1754            out.push_str(&crate::observe::display_key(part));
1755        } else {
1756            out.push_str(part);
1757        }
1758    }
1759    out
1760}
1761
1762/// Evaluate a prepared program's top-level forms into `interp`, with `host`.
1763pub(crate) fn eval_prepared<H: 'static>(
1764    interp: &mut tatara_lisp_eval::Interpreter<H>,
1765    prepared: &Prepared,
1766    host: &mut H,
1767) -> Result<Value, RunError> {
1768    let Prepared {
1769        program, erased, ..
1770    } = prepared;
1771
1772    // RUN, one top-level form at a time, **counting them**.
1773    //
1774    // This is literally the loop `Interpreter::eval_program` runs internally
1775    // (tatara-lisp-eval `eval.rs`) — unrolled here for exactly one reason: the
1776    // INDEX. `resolve_uses` flattens N files into one form list and a `Span` is
1777    // a byte range with no file identity, so an offset alone is ambiguous
1778    // across files — 66 means something in every one of them. The index is the
1779    // join key back to `ResolvedProgram`'s file table, and `eval_program`
1780    // consumes it internally and hands back only the error.
1781    //
1782    // The same key, the same renderer and the same file table the type errors
1783    // above already use. A runtime error was the one diagnostic in this
1784    // function still reporting a bare message.
1785    let mut value = Value::Nil;
1786    for (top_level, form) in erased.iter().enumerate() {
1787        value = interp.eval_top_form(form, host).map_err(|e| {
1788            // The message, its position and the blue call stack, read off the
1789            // frames the error unwound through (`observe::Fault`). The message
1790            // is `short_message` rather than upstream's `Display`, whose
1791            // trailing ` at {span}` is a bare byte range with no file, through
1792            // `messages::describe`, which names the function a wrong argument
1793            // count was called on.
1794            RunError::Eval(Box::new(crate::observe::Fault::of(
1795                program,
1796                erased,
1797                top_level,
1798                &crate::memory::named(e),
1799                &interp.take_trace(),
1800            )))
1801        })?;
1802    }
1803    Ok(value)
1804}
1805
1806pub const MAIN: &str = "main";
1807
1808#[must_use]
1809pub fn entry_main(program: &crate::uses::ResolvedProgram) -> Option<(usize, tatara_lisp::Span)> {
1810    use crate::uses::ResolvedProgram;
1811    let stored = program
1812        .file(ResolvedProgram::ENTRY)
1813        .is_some_and(|f| f.path.is_some());
1814    if !stored {
1815        return None;
1816    }
1817    program.forms().iter().enumerate().find_map(|(i, f)| {
1818        if program.owner_of(i) != Some(ResolvedProgram::ENTRY) {
1819            return None;
1820        }
1821        let items = f.as_list()?;
1822        let sig = items.get(1)?.as_list()?;
1823        let named_main = matches!(
1824            items.first().and_then(tatara_lisp::Spanned::as_symbol),
1825            Some("define" | "define-typed")
1826        ) && sig.len() == 1
1827            && sig[0].as_symbol() == Some(MAIN);
1828        named_main.then_some((i, sig[0].span))
1829    })
1830}
1831
1832/// The entry's `main`: its top-level form, where it is named, and the key
1833/// a call to it is written with.
1834fn main_call(prepared: &Prepared) -> Option<(usize, tatara_lisp::Span, String)> {
1835    let (top_level, span) = entry_main(&prepared.program)?;
1836    let ns = namespace_of(&prepared.program, top_level);
1837    Some((top_level, span, blue_lang_check::names::key(&ns, MAIN)))
1838}
1839
1840fn main_key(prepared: &Prepared) -> Option<String> {
1841    main_call(prepared).map(|(_, _, key)| key)
1842}
1843
1844pub(crate) fn call_main<H: 'static>(
1845    interp: &mut tatara_lisp_eval::Interpreter<H>,
1846    prepared: &Prepared,
1847    host: &mut H,
1848) -> Result<Option<Value>, RunError> {
1849    let Some((top_level, span, key)) = main_call(prepared) else {
1850        return Ok(None);
1851    };
1852    let call = tatara_lisp::Spanned::new(
1853        span,
1854        tatara_lisp::SpannedForm::List(vec![tatara_lisp::Spanned::new(
1855            span,
1856            tatara_lisp::SpannedForm::Atom(tatara_lisp::Atom::Symbol(key)),
1857        )]),
1858    );
1859    interp.eval_top_form(&call, host).map(Some).map_err(|e| {
1860        RunError::Eval(Box::new(crate::observe::Fault::of(
1861            &prepared.program,
1862            &prepared.erased,
1863            top_level,
1864            &crate::memory::named(e),
1865            &interp.take_trace(),
1866        )))
1867    })
1868}
1869
1870/// **A runtime error names a place.** The gates for the last stage that
1871/// reported a bare message.
1872///
1873/// Each fixture is sized so that resolving the failing span against ANY file
1874/// but the right one lands somewhere else — a different path and a different
1875/// line — so a green run here is evidence about the join and not about the
1876/// renderer being called at all.
1877///
1878/// **Each raise is an unbound name, WAIVED.** Since the check stage resolves
1879/// names (B0001), an unbound call no longer reaches the evaluator; these
1880/// fixtures need exactly that runtime raise, so each carries
1881/// `# waive B0001: …` above the definition holding it — the one escape hatch,
1882/// used for what it is for. The waiver adds a line above each failing call,
1883/// which is why the positions below are one line later than their red runs
1884/// (recorded against the unwaived fixtures) show.
1885#[cfg(test)]
1886mod position_tests {
1887    use super::*;
1888    use crate::uses::{Entry, Loader};
1889    use std::collections::BTreeMap;
1890    use std::path::Path;
1891
1892    struct MemLoader(BTreeMap<&'static str, &'static str>);
1893
1894    impl Loader for MemLoader {
1895        fn load(&self, name: &str) -> Result<Vec<(String, String)>, String> {
1896            self.0
1897                .get(name)
1898                .map(|s| vec![(format!("{name}.b"), (*s).to_owned())])
1899                .ok_or_else(|| format!("no bidama named \"{name}\""))
1900        }
1901    }
1902
1903    /// A package whose OWN top-level code raises, deep inside a file that is
1904    /// deliberately taller than either of the other two.
1905    ///
1906    /// Byte 66 — where `kore_wa_sonzai_shinai` starts — is past the end of the
1907    /// entry file (30 bytes) and past the end of `kotae.b` (21 bytes). So the
1908    /// only file this span can honestly resolve against is this one, and the
1909    /// other two now refuse to answer rather than walking off their ends.
1910    const BAKUHATSU: &str = "\
1911def tsukawanai_a()
1912  1
1913end
1914
1915def tsukawanai_b()
1916  2
1917end
1918
1919# waive B0001: the fixture needs a raise at runtime
1920def bakuhatsu()
1921  kore_wa_sonzai_shinai()
1922end
1923
1924bakuhatsu";
1925
1926    const KOTAE: &str = "def kotae()\n  42\nend";
1927
1928    /// The same failing call, but only ever reached by a CALLER in another
1929    /// file — the shape the call stack resolved (the last position test).
1930    const BAKUHATSU2: &str =
1931        "# waive B0001: a raise at runtime\ndef yobu()\n  kore_wa_sonzai_shinai()\nend";
1932
1933    fn loader() -> MemLoader {
1934        MemLoader(BTreeMap::from([
1935            ("kotae", KOTAE),
1936            ("bakuhatsu", BAKUHATSU),
1937            ("bakuhatsu2", BAKUHATSU2),
1938        ]))
1939    }
1940
1941    fn run_named(path: &str, src: &str) -> RunError {
1942        run_in_surface(
1943            Entry {
1944                path: Some(Path::new(path)),
1945                text: src,
1946            },
1947            Inputs::new(),
1948            &loader(),
1949            None,
1950        )
1951        .expect_err("the fixture must raise")
1952    }
1953
1954    /// **The load-bearing gate: a raise inside an IMPORTED package reports the
1955    /// IMPORTED file's path and line.**
1956    ///
1957    /// Hand-computed. In `bakuhatsu.b` the failing call sits on line 11 at
1958    /// column 3, and the whole point of the fixture's height is that no other
1959    /// file in the program can produce that pair: the entry file has 2 lines
1960    /// and `kotae.b` has 3, so *any* mis-attribution is visible as a different
1961    /// path AND a different position, never as a coincidence.
1962    ///
1963    /// **Three red runs, one per edit this change makes** — each isolates a
1964    /// different half of the mechanism, which is what stops the gate being a
1965    /// tautology over "some renderer ran".
1966    ///
1967    /// 1. `eval_program` + `.map_err(|e| RunError::Eval(e.to_string()))`
1968    ///    restored (the pre-change code):
1969    ///    ```text
1970    ///    left:  "runtime error: unbound symbol: kore_wa_sonzai_shinai at 74..95"
1971    ///    right: "runtime error: bakuhatsu.b:10:3: unbound symbol `kore_wa_sonzai_shinai`"
1972    ///    ```
1973    ///    No place at all — the state this change is fixing — and the trailing
1974    ///    `at 74..95` is upstream's `Display`, an offset with no file, which is
1975    ///    why the loop renders `short_message` instead.
1976    ///
1977    /// 2. The loop kept, but `0` passed to `locate` in place of `top_level`:
1978    ///    ```text
1979    ///    left:  "runtime error: kotae.b: unbound symbol `kore_wa_sonzai_shinai`"
1980    ///    right: "runtime error: bakuhatsu.b:10:3: unbound symbol `kore_wa_sonzai_shinai`"
1981    ///    ```
1982    ///    **This is the one that proves the INDEX is load-bearing** rather than
1983    ///    the renderer: form 0 belongs to `kotae.b`, so the wrong file is named
1984    ///    — and named without a position, because the guard in `locate` sees
1985    ///    that byte 74 is not a range in a 21-byte file and declines to invent
1986    ///    one. A fixture with one imported package could not tell these apart.
1987    ///
1988    /// 3. `crate::lower_to_spanned(&crate::to_sexps(&erased))` reinstated
1989    ///    between erasure and the loop:
1990    ///    ```text
1991    ///    left:  "runtime error: bakuhatsu.b: unbound symbol `kore_wa_sonzai_shinai`"
1992    ///    right: "runtime error: bakuhatsu.b:10:3: unbound symbol `kore_wa_sonzai_shinai`"
1993    ///    ```
1994    ///    The right FILE with no position — which is exactly the shape of the
1995    ///    bug: the index survived the lift and every span did not.
1996    /// kotae loads first so bakuhatsu's forms do not start at index 0: the
1997    /// order is the fixture, and the waiver says so (B0015 wants them sorted).
1998    const TWO_IMPORTS: &str =
1999        "use(\"kotae\")\n# waive B0015: kotae loads first on purpose\nuse(\"bakuhatsu\")\n";
2000
2001    #[test]
2002    fn a_raise_inside_an_imported_package_names_that_package() {
2003        let err = run_named("entry.b", TWO_IMPORTS);
2004        assert_eq!(
2005            err.to_string(),
2006            "runtime error: bakuhatsu.b:11:3: unbound symbol `kore_wa_sonzai_shinai`\n  \
2007             at bakuhatsu::bakuhatsu (bakuhatsu.b:11:3)\n  \
2008             at <top level> (bakuhatsu.b:14:1)"
2009        );
2010    }
2011
2012    /// Anti-vacuity for the fixture itself: the position asserted above is a
2013    /// FACT ABOUT `bakuhatsu.b`, checked against the source text rather than
2014    /// against the thing that produced it.
2015    ///
2016    /// Without this, `11:3` is just a number that happened to come out of the
2017    /// code under test, and a change that moved every reported line by one
2018    /// would move this assertion with it.
2019    #[test]
2020    fn line_11_column_3_of_the_fixture_is_the_failing_call() {
2021        let line = BAKUHATSU.lines().nth(10).expect("line 11 exists");
2022        assert_eq!(line, "  kore_wa_sonzai_shinai()");
2023        assert_eq!(
2024            &line[2..],
2025            "kore_wa_sonzai_shinai()",
2026            "column 3 (1-indexed) is where the call starts"
2027        );
2028        // And the other two files cannot reach that far, which is what makes a
2029        // mis-attribution loud instead of plausible.
2030        let offset = BAKUHATSU.find("kore_wa_sonzai_shinai").expect("in fixture");
2031        assert!(
2032            offset > KOTAE.len(),
2033            "kotae.b could answer for byte {offset}"
2034        );
2035        assert!(
2036            offset > TWO_IMPORTS.len(),
2037            "the entry file could answer for byte {offset}"
2038        );
2039    }
2040
2041    /// **The `FileId(0)` path: a raise in the ENTRY file reports the entry
2042    /// file.** The single-file case, which is every program that imports
2043    /// nothing — and the one where the index and the span cannot disagree.
2044    ///
2045    /// **Red run** (2026-08-12), `eval_program` +
2046    /// `.map_err(|e| RunError::Eval(e.to_string()))` restored:
2047    /// ```text
2048    /// left:  "runtime error: unbound symbol: nani_mo_nai at 17..28"
2049    /// right: "runtime error: honmono.b:5:1: unbound symbol `nani_mo_nai`"
2050    /// ```
2051    ///
2052    /// **Second red run**, `lower_to_spanned` reinstated after erasure — the
2053    /// one that isolates the SPAN half from the index half, since a
2054    /// single-file program cannot get its file wrong:
2055    /// ```text
2056    /// left:  "runtime error: honmono.b: unbound symbol `nani_mo_nai`"
2057    /// right: "runtime error: honmono.b:5:1: unbound symbol `nani_mo_nai`"
2058    /// ```
2059    #[test]
2060    fn a_raise_in_the_entry_file_names_the_entry_file() {
2061        let err = run_named(
2062            "honmono.b",
2063            "def f()\n  1\nend\n\n# waive B0001: a raise at runtime\nnani_mo_nai()\n",
2064        );
2065        assert_eq!(
2066            err.to_string(),
2067            "runtime error: honmono.b:6:1: unbound symbol `nani_mo_nai`"
2068        );
2069    }
2070
2071    /// A raise from a form that came through the SURFACE-level erasure still
2072    /// reports a real position — the annotated path, where erasure rewrites
2073    /// the node rather than passing it through.
2074    ///
2075    /// Separate from the two above because the erasure rewrite is the only
2076    /// place a node is built rather than kept, and a synthetic span there
2077    /// would be invisible to a fixture whose failing form is untouched.
2078    ///
2079    /// **Red run** (2026-08-12), `lower_to_spanned` reinstated after erasure:
2080    /// ```text
2081    /// left:  "runtime error: chuu.b: unbound symbol `mada_nai`"
2082    /// right: "runtime error: chuu.b:2:3: unbound symbol `mada_nai`"
2083    /// ```
2084    #[test]
2085    fn a_raise_inside_an_annotated_def_still_names_its_line() {
2086        let err = run_named(
2087            "chuu.b",
2088            "# waive B0001: a raise at runtime\ndef f(a: Int) -> Int\n  mada_nai(a)\nend\n\nf(1)\n",
2089        );
2090        assert_eq!(
2091            err.to_string(),
2092            "runtime error: chuu.b:3:3: unbound symbol `mada_nai`\n  \
2093             at f (chuu.b:3:3)\n  \
2094             at <top level> (chuu.b:6:1)"
2095        );
2096    }
2097
2098    /// **The limit this module used to state, closed by the call stack.**
2099    ///
2100    /// A top-level form in the entry file calls a function defined in an
2101    /// imported one, and the raise happens inside the callee. The executing
2102    /// form is the entry's and the span is the callee's bytes, so until the
2103    /// evaluator reported the frames an error unwound through, the best blue
2104    /// could do was name the caller's file with no position (the guard in
2105    /// `uses::ResolvedProgram::locate` refusing an offset it could not
2106    /// justify). The innermost frame's definition now names the file the
2107    /// offsets belong to, and the stack shows the call that led there.
2108    ///
2109    /// **Red run** (2026-10-10): `Fault::of` locating against the executing
2110    /// top-level form instead of the innermost frame's definition:
2111    /// ```text
2112    /// left:  "runtime error: yobidashi.b: unbound symbol `kore_wa_sonzai_shinai`\n  at bakuhatsu2::yobu (bakuhatsu2.b:3:3)\n  at <top level> (yobidashi.b:3:1)"
2113    /// ```
2114    /// The old answer, kept by the guard: the caller's file and no position.
2115    #[test]
2116    fn a_raise_in_a_callee_from_another_file_names_the_callees_file() {
2117        // The comment is padding, and load-bearing: `bakuhatsu2.b`'s failing
2118        // call starts at byte 47 (after its waiver line) and ends at 70, and
2119        // this file is 57 bytes, so the start is in range here and the end is
2120        // not — a mis-attribution would still be refused rather than placed.
2121        let err = run_named(
2122            "yobidashi.b",
2123            "use(\"bakuhatsu2\")\n# pad: 47's in range\nbakuhatsu2::yobu\n",
2124        );
2125        assert_eq!(
2126            err.to_string(),
2127            "runtime error: bakuhatsu2.b:3:3: unbound symbol `kore_wa_sonzai_shinai`\n  \
2128             at bakuhatsu2::yobu (bakuhatsu2.b:3:3)\n  \
2129             at <top level> (yobidashi.b:3:1)"
2130        );
2131    }
2132}
2133
2134#[cfg(test)]
2135mod tests {
2136    use super::*;
2137
2138    fn int(src: &str) -> i64 {
2139        match run(src).unwrap_or_else(|e| panic!("{src:?}: {e}")).value {
2140            Value::Int(v) => v,
2141            other => panic!("{src:?} produced {other:?}"),
2142        }
2143    }
2144
2145    /// **The sliding scale, as one assertion.** Annotating changes the
2146    /// analysis and nothing else.
2147    #[test]
2148    fn annotating_buys_analysis_and_changes_nothing_else() {
2149        let plain = run("def add(a, b)\n  a + b\nend\nadd(2, 3)").expect("plain");
2150        let typed = run("def add(a: Int, b: Int) -> Int\n  a + b\nend\nadd(2, 3)").expect("typed");
2151
2152        assert!(matches!(plain.value, Value::Int(5)));
2153        assert!(
2154            matches!(typed.value, Value::Int(5)),
2155            "the annotated program must compute the same answer"
2156        );
2157        assert_eq!(plain.visited, 0, "no annotations means no analysis");
2158        assert!(
2159            typed.visited > 0,
2160            "an annotation must actually buy analysis, not just decorate"
2161        );
2162        assert_eq!(plain.typed_decls, 0);
2163        assert_eq!(typed.typed_decls, 1);
2164    }
2165
2166    /// **Checking happens before erasure.** This is the test that catches the
2167    /// reordering: a program with a declared-type violation must be rejected,
2168    /// and it can only be rejected while the annotations still exist.
2169    #[test]
2170    fn a_type_error_is_reported_and_the_program_does_not_run() {
2171        let err = run("def add(a: Int, b: Int) -> Str\n  a + b\nend\nadd(1, 2)")
2172            .expect_err("a declared Str return from an Int body must be rejected");
2173        assert!(
2174            matches!(err, RunError::Types(ref d) if !d.is_empty()),
2175            "expected type diagnostics, got {err}"
2176        );
2177    }
2178
2179    /// And the untyped version of the same program runs, so the rejection
2180    /// above is the annotation's doing rather than a parse failure.
2181    #[test]
2182    fn the_same_program_without_annotations_runs() {
2183        assert_eq!(int("def add(a, b)\n  a + b\nend\nadd(1, 2)"), 3);
2184    }
2185
2186    #[test]
2187    fn a_parse_error_is_reported_as_one() {
2188        assert!(matches!(run("def (").unwrap_err(), RunError::Parse(_)));
2189    }
2190
2191    /// Every stage reports in its own vocabulary, so a failure names which
2192    /// stage failed rather than surfacing as a generic error.
2193    #[test]
2194    fn a_runtime_error_is_reported_as_one() {
2195        let err = run("throw(error(\"boom\"))").expect_err("a raise");
2196        assert!(matches!(err, RunError::Eval(_)), "got {err}");
2197    }
2198
2199    /// Stdlib and primitives are both reachable through the pipeline — the
2200    /// gap that made `6 % 3` fail.
2201    #[test]
2202    fn the_pipeline_reaches_both_runtime_layers() {
2203        assert_eq!(int("6 % 3"), 0);
2204        assert_eq!(int("7 % 3"), 1);
2205        assert_eq!(int("2 + 3 * 4"), 14);
2206    }
2207
2208    /// **The deleted hop was a no-op on everything blue emits — so removing it
2209    /// is a swap, not a behaviour change.**
2210    ///
2211    /// The old lowering printed the erased tree and read it back through
2212    /// `tatara_lisp::read_spanned`. This walks a corpus and asserts the two
2213    /// paths land on the same tree, which is the equivalence the swap rests on.
2214    /// It is stated as a *measurement over this corpus*, not as a theorem:
2215    /// the round trip is not identity in general (that is precisely why it had
2216    /// to go), it merely happened to be identity for the bytes blue emits.
2217    #[test]
2218    fn the_deleted_round_trip_agreed_with_the_direct_lowering() {
2219        let corpus = [
2220            "def add(a, b)\n  a + b\nend\nadd(2, 3)",
2221            "def fact(n)\n  if n < 2\n    1\n  else\n    n * fact(n - 1)\n  end\nend\nfact(5)",
2222            "def f(a, b)\n  c = a + b\n  c * 2\nend\nf(1, 2)",
2223            "defmacro sq(e)\n  quote\n    unquote(e) * unquote(e)\n  end\nend\nsq(2 + 3)",
2224            "\"a string with spaces, a ( and a )\"",
2225            "def g(a: Int) -> Int\n  a + 1\nend\ng(1)",
2226            "6 % 3",
2227            "1.5 + 2.25",
2228        ];
2229        for src in corpus {
2230            // Projected to `Sexp` because that is what the deleted hop
2231            // operated on. Erasure itself no longer goes near it.
2232            let erased = crate::to_sexps(&erase_types(&parse_tree(src).expect("parse")));
2233
2234            let direct: Vec<Sexp> = crate::lower_to_spanned(&erased)
2235                .iter()
2236                .map(tatara_lisp::Spanned::to_sexp)
2237                .collect();
2238            assert_eq!(direct, erased, "the direct lowering must be the identity");
2239
2240            let text = erased
2241                .iter()
2242                .map(ToString::to_string)
2243                .collect::<Vec<_>>()
2244                .join("\n");
2245            let round_tripped: Vec<Sexp> = tatara_lisp::read_spanned(&text)
2246                .unwrap_or_else(|e| panic!("{src:?}: the old path could not read back: {e:?}"))
2247                .iter()
2248                .map(tatara_lisp::Spanned::to_sexp)
2249                .collect();
2250            assert_eq!(
2251                round_tripped, erased,
2252                "{src:?}: the old print-and-reparse path changed the tree"
2253            );
2254        }
2255    }
2256
2257    /// Anti-vacuity for the test above: the round trip really is *not* the
2258    /// identity in general, so agreeing on the corpus was a property of what
2259    /// blue happens to emit rather than a property of the reader.
2260    ///
2261    /// **`Atom::Symbol`'s `Display` writes the name raw, with no escaping.**
2262    /// `Atom::Str` escapes and its docs explain at length why; the symbol arm
2263    /// is `f.write_str(s)`. So print-then-read is not inverse over the symbol
2264    /// domain, and the failure is *silent*: a symbol containing a space prints
2265    /// as two tokens, reads back as two symbols, and the result is a perfectly
2266    /// well-formed tree with a different meaning. No error, nothing to catch.
2267    ///
2268    /// Measured 2026-08-02 across the separators: `a b` and `x'y` come back
2269    /// `Ok` with a different tree; `x)y`, `x"y` and `x;y` come back `Err`;
2270    /// `x{y` and `x[y` DO round-trip at this level — those two are one symbol
2271    /// in and one symbol out, so the brace-fusion reported in tatara *source*
2272    /// is not what bites a printed tree. The silent pair is what makes this a
2273    /// miscompile class rather than a noisy one.
2274    #[test]
2275    fn the_round_trip_is_not_the_identity_in_general() {
2276        let tree = Sexp::List(vec![
2277            Sexp::Atom(tatara_lisp::Atom::Symbol("f".into())),
2278            Sexp::Atom(tatara_lisp::Atom::Symbol("a b".into())),
2279        ]);
2280        let text = tree.to_string();
2281        let back: Vec<Sexp> = tatara_lisp::read_spanned(&text)
2282            .expect("it reads back cleanly — that IS the problem")
2283            .iter()
2284            .map(tatara_lisp::Spanned::to_sexp)
2285            .collect();
2286        assert_ne!(
2287            back,
2288            vec![tree.clone()],
2289            "if print-then-read became inverse over symbols, the class would be \
2290             closed upstream and this test should be deleted rather than relaxed"
2291        );
2292        // …and the direct lowering is unaffected by any of it.
2293        let direct: Vec<Sexp> = crate::lower_to_spanned(std::slice::from_ref(&tree))
2294            .iter()
2295            .map(tatara_lisp::Spanned::to_sexp)
2296            .collect();
2297        assert_eq!(direct, vec![tree]);
2298    }
2299}
2300
2301#[cfg(test)]
2302mod macro_tests {
2303    use super::*;
2304
2305    fn int(src: &str) -> i64 {
2306        match run(src).unwrap_or_else(|e| panic!("{src:?}: {e}")).value {
2307            Value::Int(v) => v,
2308            other => panic!("{src:?} produced {other:?}"),
2309        }
2310    }
2311
2312    /// **A blue macro expands and runs.** Tenet 2's surface, end to end.
2313    #[test]
2314    fn a_macro_expands_and_runs() {
2315        assert_eq!(
2316            int("defmacro double(x)\n  quote\n    unquote(x) + unquote(x)\n  end\nend\ndouble(21)"),
2317            42
2318        );
2319    }
2320
2321    /// A macro receives *source forms*, not values — so it can duplicate its
2322    /// argument, which a function cannot do without re-evaluating it.
2323    #[test]
2324    fn a_macro_operates_on_syntax_not_values() {
2325        assert_eq!(
2326            int("defmacro sq(e)\n  quote\n    unquote(e) * unquote(e)\n  end\nend\nsq(2 + 3)"),
2327            25,
2328            "the argument form `2 + 3` must be substituted twice"
2329        );
2330    }
2331
2332    /// **A runaway macro is a typed error, not a dead compiler.** This is the
2333    /// property that makes the metaprogramming surface safe to hand to a user.
2334    ///
2335    /// The assertion was `contains("expansion limit")` and now reads
2336    /// `contains("expansion")`, because the pipeline renders `short_message`
2337    /// rather than `Display`: upstream words the same fact as "exceeded 256
2338    /// expansion steps" instead of "exceeded the expansion limit of 256
2339    /// rewrites". The PROPERTY is unchanged and still asserted — the macro is
2340    /// named, and the failure is attributed to expansion rather than to
2341    /// evaluation. **This reword was the whole measured blast radius of that
2342    /// switch**, one test in the workspace.
2343    ///
2344    /// The number is deliberately NOT pinned. It is upstream's constant, this
2345    /// test owns none of it, and matching it would turn an upstream bump into
2346    /// a red run here that proves nothing about blue.
2347    ///
2348    /// The third clause is new capability rather than repair: the message now
2349    /// carries `<anonymous>:6:1`, so a runaway macro says WHERE it ran away.
2350    #[test]
2351    fn a_runaway_macro_fails_the_compilation_rather_than_the_process() {
2352        let err =
2353            run("defmacro forever(x)\n  quote\n    forever(unquote(x))\n  end\nend\nforever(1)")
2354                .expect_err("a self-referential macro must be rejected");
2355        let msg = err.to_string();
2356        assert!(
2357            msg.contains("forever") && msg.contains("expansion"),
2358            "the error must name the macro and the limit it hit: {msg}"
2359        );
2360        assert!(
2361            msg.contains("<anonymous>:6:1"),
2362            "and must place the call that ran away — line 6 is `forever(1)`: {msg}"
2363        );
2364    }
2365}
2366
2367#[cfg(test)]
2368mod input_tests {
2369    use super::*;
2370    use crate::inputs::{Declaration, Inputs};
2371
2372    /// A schema a macro will generate code from.
2373    const SCHEMA: &[u8] = b"3";
2374
2375    fn with_schema(src: &str) -> Result<Run, RunError> {
2376        let hash = Inputs::hash_of(SCHEMA);
2377        let mut inputs = Inputs::new();
2378        inputs
2379            .bind(
2380                &Declaration {
2381                    name: "schema".to_string(),
2382                    hash,
2383                },
2384                SCHEMA.to_vec(),
2385            )
2386            .expect("bind");
2387        run_with_inputs(src, inputs)
2388    }
2389
2390    fn decl_line() -> String {
2391        let mut s = String::from("definput(\"schema\", \"");
2392        s.push_str(&Inputs::hash_of(SCHEMA));
2393        s.push_str("\")\n");
2394        s
2395    }
2396
2397    /// **A macro reads a declared build input.** This is §VI OPEN #6 closed —
2398    /// the spec names it as gating blue's whole "stronger than Ruby's
2399    /// metaprogramming" claim, because a macro that cannot read a schema cannot
2400    /// generate code from one.
2401    #[test]
2402    fn a_macro_can_read_a_declared_build_input() {
2403        let src = decl_line() + "input(\"schema\")";
2404        let out = with_schema(&src).expect("run");
2405        assert!(
2406            matches!(out.value, Value::Str(ref s) if &**s == "3"),
2407            "got {:?}",
2408            out.value
2409        );
2410    }
2411
2412    /// **An undeclared input is an error, not a file read and not nil.**
2413    /// Returning nil is how a macro generates an empty table and nobody notices
2414    /// until runtime.
2415    #[test]
2416    fn an_undeclared_input_is_an_error() {
2417        let err = with_schema("input(\"not_declared\")").expect_err("must fail");
2418        let msg = err.to_string();
2419        assert!(msg.contains("not_declared"), "must name it: {msg}");
2420        assert!(msg.contains("definput"), "and say how to declare it: {msg}");
2421    }
2422
2423    /// **There is no path-based read at all.** The capability is the absence of
2424    /// the primitive, not a check inside one — so this is an unbound symbol.
2425    ///
2426    /// Holds for the DEFAULT surface — the one every embedder gets. The `sys`
2427    /// cargo feature (CLI only) is the one declared exception: it is the
2428    /// operator's own trusted host surface, and is asserted in
2429    /// `sys_read_file_is_the_trusted_cli_only_exception` below.
2430    #[cfg(not(feature = "sys"))]
2431    #[test]
2432    fn there_is_no_ambient_file_read() {
2433        for attempt in [
2434            "read_file(\"/etc/passwd\")",
2435            "File(\"/etc/passwd\")",
2436            "slurp(\"/etc/passwd\")",
2437            "open(\"/etc/passwd\")",
2438        ] {
2439            let err = with_schema(attempt).expect_err("must not resolve");
2440            assert!(
2441                err.to_string().contains("unbound"),
2442                "{attempt} must be UNBOUND — a capability removed by absence, \
2443                 not guarded by a check: {err}"
2444            );
2445        }
2446    }
2447
2448    /// With the `sys` feature compiled in, `read_file` IS bound — that is the
2449    /// point of the feature. The doctrine does not move: this is the operator's
2450    /// own machine (the CLI), not an embedder's sandbox. Pin the boundary so a
2451    /// future default-build change is heard, and assert that `input()` still
2452    /// works beside it.
2453    #[cfg(feature = "sys")]
2454    #[test]
2455    fn sys_read_file_is_the_trusted_cli_only_exception() {
2456        let err = with_schema("definitely_not_a_primitive(\"x\")").expect_err("must not resolve");
2457        assert!(err.to_string().contains("unbound"), "{err}");
2458        assert!(
2459            with_schema("read_file(\"/etc/passwd\")").is_ok(),
2460            "with `sys` on, read_file is the trusted CLI surface"
2461        );
2462        let out = with_schema("input(\"schema\")").expect("run");
2463        assert!(
2464            matches!(out.value, Value::Str(ref s) if &**s == "3"),
2465            "input() still binds beside the sys surface: {:?}",
2466            out.value
2467        );
2468    }
2469
2470    /// Anti-vacuity: with no inputs supplied at all, even a declared name fails
2471    /// — so the success above is the binding's doing.
2472    #[test]
2473    fn a_declared_input_with_no_bytes_supplied_fails() {
2474        let src = decl_line() + "input(\"schema\")";
2475        assert!(run(&src).is_err(), "no bytes were supplied");
2476    }
2477}
2478
2479#[cfg(test)]
2480mod tier2_tests {
2481    use super::*;
2482    use crate::inputs::{Declaration, Inputs};
2483
2484    /// **The Tier-2 conversion §V.6.3 said was gated: a macro that emits real
2485    /// declarations FROM A SCHEMA.**
2486    ///
2487    /// `theory/BLUE.md` §VI OPEN #6 states the blocker plainly — "tenet 2
2488    /// installs a `NoLoader`, so a macro cannot read a schema — which gates
2489    /// every Tier-2 conversion in §V.6 and therefore blue's whole 'stronger than
2490    /// Ruby's metaprogramming' claim."
2491    ///
2492    /// Here the schema supplies a *value the generated code depends on*, read at
2493    /// expansion time. Ruby and Elixir can both do this — with the whole
2494    /// filesystem open. blue does it through a name bound to a content hash.
2495    #[test]
2496    fn a_macro_generates_code_from_a_schema() {
2497        let schema = b"7";
2498        let mut inputs = Inputs::new();
2499        inputs
2500            .bind(
2501                &Declaration {
2502                    name: "arity".to_string(),
2503                    hash: Inputs::hash_of(schema),
2504                },
2505                schema.to_vec(),
2506            )
2507            .expect("bind");
2508
2509        // The macro reads the input at EXPANSION time and splices the value it
2510        // found into the code it emits.
2511        let mut src = String::from("definput(\"arity\", \"");
2512        src.push_str(&Inputs::hash_of(schema));
2513        src.push_str("\")\n");
2514        src.push_str(
2515            "defmacro from_schema()\n  quote\n    unquote(to_int(input(\"arity\")))\n  end\nend\n\
2516             from_schema() * 6",
2517        );
2518
2519        let out = run_with_inputs(&src, inputs).expect("run");
2520        assert!(
2521            matches!(out.value, Value::Int(42)),
2522            "the schema's 7 must reach the generated code: got {:?}",
2523            out.value
2524        );
2525    }
2526}