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