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