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