Skip to main content

alba_core/
graph.rs

1//! Dependency-graph validation and subgraph extraction, the last piece a
2//! [`Project`] needs before an engine can schedule it: [`validate_graph`]
3//! checks that every `needs` entry — and the file's `default`, if it
4//! declares one — names a beam that actually exists, and that no beam
5//! (transitively) needs itself; [`execution_subgraph`] extracts the
6//! transitive closure of a target beam.
7//!
8//! [`validate_graph`] is called by [`crate::loader::load_project`] and
9//! [`crate::eval::load_str`] before either hands a [`Project`] back to its
10//! caller, so a `Project` that exists at all has already passed both
11//! checks — [`execution_subgraph`] leans on that invariant (see its own
12//! doc comment) rather than re-deriving it.
13//!
14//! ## Determinism
15//!
16//! Both functions build a `HashMap<&str, &Beam>` for O(1) id lookups, but
17//! never iterate it: every loop that could affect *which* error gets
18//! reported, or the DFS visiting order, walks [`Project::beams`] (a `Vec`,
19//! in the order beams were declared) instead. A `HashMap`'s iteration
20//! order is randomized per instance, so leaning on it here would make
21//! which of two independent problems gets reported change from run to
22//! run — exactly the class of defect this crate has already hit once.
23
24use std::collections::{HashMap, HashSet, VecDeque};
25
26use alba_syntax::Span;
27
28use crate::error::CoreError;
29use crate::eval::suggest;
30use crate::model::{Beam, BeamId, Project};
31
32/// Every beam id declared in `project`, in declaration order — the
33/// candidate list offered to [`suggest`] for an "unknown beam" error's
34/// "did you mean...?" help.
35fn all_ids(project: &Project) -> impl Iterator<Item = &str> {
36    project.beams.iter().map(|b| b.id.0.as_str())
37}
38
39/// An O(1) id-to-beam lookup for `project`, built fresh on every call.
40/// Shared by [`validate_graph`] and [`execution_subgraph`], which both
41/// need one, so the same map isn't built twice per call in a caller that
42/// validates and then immediately extracts a subgraph. Only ever used for
43/// lookups, never iterated — see the module doc comment's "Determinism"
44/// section for why that distinction matters here.
45fn by_id(project: &Project) -> HashMap<&str, &Beam> {
46    project.beams.iter().map(|b| (b.id.0.as_str(), b)).collect()
47}
48
49/// Builds the "unknown beam `name`" error `validate_graph` and
50/// `execution_subgraph` both need, with a "did you mean...?" suggestion
51/// attached when a close-enough candidate exists. `span` is `None` when
52/// the name came from outside the Beamfile and so has no declaration site
53/// to underline. The caller stamps the right `source_id` afterward (see
54/// [`CoreError::with_source_id`]) since this helper has no beam of its own
55/// to read one from.
56fn unknown_beam_error<'a>(
57    name: &str,
58    candidates: impl Iterator<Item = &'a str>,
59    span: Option<Span>,
60) -> CoreError {
61    let message = format!("unknown beam `{name}`");
62    let err = match span {
63        Some(span) => CoreError::new(message, span),
64        None => CoreError::unlocated(message),
65    };
66    match suggest(name, candidates) {
67        Some(candidate) => err.with_help(format!("did you mean `{candidate}`?")),
68        None => err,
69    }
70}
71
72/// Checks that every `needs` entry in `project` names a beam that actually
73/// exists, that `project`'s `default` (if it declares one) does too, and
74/// that no beam (transitively) needs itself.
75///
76/// The checks run as separate passes over [`Project::beams`], in
77/// declaration order: first every `needs` entry against the full set of
78/// declared ids, then the `default`, then cycle detection (which assumes
79/// every `needs` entry already resolves, since the first pass would have
80/// already returned an error otherwise). Each reports at the exact
81/// reference that does not resolve, and against whichever beam declared
82/// it — its own `source`, via [`CoreError::with_source_id`] — never the
83/// caller's; this function runs after every file has finished loading,
84/// when no `SourceIdScope` is active, so leaving `source_id` at its
85/// default would always point a multi-file project's error at the root
86/// file regardless of which imported file actually declared the problem.
87/// The `default` needs no such correction: only the root file's `default`
88/// is ever kept (see [`crate::loader::load_project`]), so it always
89/// belongs to the file `SourceId`'s own default already names.
90pub fn validate_graph(project: &Project) -> Result<(), CoreError> {
91    let by_id = by_id(project);
92
93    for beam in &project.beams {
94        for need in &beam.needs {
95            if !by_id.contains_key(need.value.0.as_str()) {
96                return Err(
97                    unknown_beam_error(&need.value.0, all_ids(project), Some(need.span))
98                        .with_source_id(beam.source),
99                );
100            }
101        }
102    }
103
104    if let Some(default) = &project.default
105        && !by_id.contains_key(default.value.0.as_str())
106    {
107        return Err(unknown_beam_error(
108            &default.value.0,
109            all_ids(project),
110            Some(default.span),
111        ));
112    }
113
114    for hook in &project.hooks {
115        let Some(&target) = by_id.get(hook.beam.value.0.as_str()) else {
116            return Err(unknown_beam_error(
117                &hook.beam.value.0,
118                all_ids(project),
119                Some(hook.beam.span),
120            )
121            .with_source_id(hook.source));
122        };
123        if target.params.len() > hook.arity {
124            let arguments = if hook.arity == 1 {
125                "argument"
126            } else {
127                "arguments"
128            };
129            let parameters = if target.params.len() == 1 {
130                "parameter"
131            } else {
132                "parameters"
133            };
134            return Err(CoreError::new(
135                format!(
136                    "hook `{}` passes {} {arguments}, but beam `{}` declares {} {parameters}",
137                    hook.name,
138                    hook.arity,
139                    target.id.0,
140                    target.params.len()
141                ),
142                hook.beam.span,
143            )
144            .with_source_id(hook.source));
145        }
146    }
147
148    let mut done: HashSet<&str> = HashSet::new();
149    for beam in &project.beams {
150        if done.contains(beam.id.0.as_str()) {
151            continue;
152        }
153        detect_cycle(beam, &by_id, &mut done)?;
154    }
155
156    Ok(())
157}
158
159/// One entry of [`detect_cycle`]'s explicit search stack: either a beam to
160/// descend into, or the marker that pops a beam back off the current path
161/// once its whole subtree has been explored (what returning from a
162/// recursive call would have done).
163enum Step<'a> {
164    Enter(&'a Beam),
165    Leave(&'a str),
166}
167
168/// Depth-first search from `root`, following `needs` edges, tracking the
169/// current branch in `path` (ids "on the stack" for this walk, not the
170/// whole project). `done` records beams whose subtree has already been
171/// fully explored (by this call or an earlier one from
172/// [`validate_graph`]'s outer loop), so a beam reachable from more than
173/// one root is never walked twice.
174///
175/// The search keeps its own heap-allocated stack rather than recursing per
176/// `needs` edge. Depth here is a property of the *Beamfile*, not of Alba: a
177/// chain of beams each needing the next, declared in that order, descends
178/// as deep as the chain is long. Recursion made that a stack overflow —
179/// which aborts the process outright rather than panicking, so a library
180/// would take its caller down with no diagnostic at all.
181///
182/// A cycle is detected the instant a beam's own id is already present in
183/// `path`: the reported cycle is `path[pos..]` (the loop itself, starting
184/// from where it closes) with that id appended once more to show it
185/// closing — not the full path from whatever root started this walk. For
186/// `build → codegen → build`, `path` at the point of detection is
187/// `["build", "codegen"]` and the beam being entered is `build` again, so
188/// `pos` is `0` and the reported cycle is exactly
189/// `["build", "codegen", "build"]`. A self-referencing beam (`a` needing
190/// `a`) falls out of the same logic: `path` is `["a"]`, the beam is `a`,
191/// `pos` is `0`, reported as `"a → a"`.
192///
193/// Visiting order matches what recursion produced, so which of several
194/// independent cycles gets reported does not change: a beam's `needs` are
195/// pushed in reverse so the first entry is the first one entered.
196///
197/// The error is stamped with the closing beam's own `span`/`source` (see
198/// [`validate_graph`]'s doc comment).
199fn detect_cycle<'a>(
200    root: &'a Beam,
201    by_id: &HashMap<&'a str, &'a Beam>,
202    done: &mut HashSet<&'a str>,
203) -> Result<(), CoreError> {
204    let mut path: Vec<&'a str> = Vec::new();
205    let mut stack: Vec<Step<'a>> = vec![Step::Enter(root)];
206
207    while let Some(step) = stack.pop() {
208        let beam = match step {
209            Step::Leave(id) => {
210                path.pop();
211                done.insert(id);
212                continue;
213            }
214            Step::Enter(beam) => beam,
215        };
216        let id = beam.id.0.as_str();
217
218        if let Some(pos) = path.iter().position(|&on_path| on_path == id) {
219            let mut cycle: Vec<&str> = path[pos..].to_vec();
220            cycle.push(id);
221            return Err(CoreError::new(
222                format!("dependency cycle: {}", cycle.join(" → ")),
223                beam.span,
224            )
225            .with_source_id(beam.source));
226        }
227
228        if done.contains(id) {
229            continue;
230        }
231
232        path.push(id);
233        stack.push(Step::Leave(id));
234        for need in beam.needs.iter().rev() {
235            // Safe to index directly: `validate_graph`'s first pass already
236            // rejected any `needs` entry that doesn't resolve, and
237            // `execution_subgraph` never calls this function.
238            stack.push(Step::Enter(by_id[need.value.0.as_str()]));
239        }
240    }
241
242    Ok(())
243}
244
245/// The transitive closure of `target` (itself included), unordered:
246/// scheduling order is the engine's job, not this crate's. A `needs`
247/// entry repeated within one beam (`needs [a, a]`) contributes `a` to the
248/// closure once, not twice.
249///
250/// An unknown `target` is an error, with the same "did you mean...?"
251/// treatment [`validate_graph`] gives an unknown `needs` entry — but with
252/// no span at all, since `target` is a plain caller-supplied [`BeamId`]
253/// (typically typed on a command line) with no declaration site of its own
254/// to point at. Underlining an arbitrary beam declaration instead would
255/// assert a source position that is not where the mistake is.
256///
257/// Every `needs` entry this walks is assumed to resolve: any [`Project`]
258/// a caller can actually hold came from [`crate::loader::load_project`] or
259/// [`crate::eval::load_str`], both of which run [`validate_graph`] first —
260/// so an unresolved `needs` entry here would mean that invariant was
261/// somehow bypassed. Rather than panicking on that (a library should not
262/// crash its caller over an invariant it cannot fully enforce, since
263/// `Project`'s fields are public), a `needs` entry that doesn't resolve is
264/// silently skipped instead of extended into.
265pub fn execution_subgraph(project: &Project, target: &BeamId) -> Result<Vec<BeamId>, CoreError> {
266    let by_id = by_id(project);
267
268    let Some(&start) = by_id.get(target.0.as_str()) else {
269        return Err(unknown_beam_error(&target.0, all_ids(project), None));
270    };
271
272    let mut seen: HashSet<&str> = HashSet::new();
273    let mut queue: VecDeque<&str> = VecDeque::new();
274    seen.insert(start.id.0.as_str());
275    queue.push_back(start.id.0.as_str());
276
277    let mut closure = Vec::new();
278    while let Some(id) = queue.pop_front() {
279        closure.push(BeamId(id.to_string()));
280        if let Some(&beam) = by_id.get(id) {
281            for need in &beam.needs {
282                if seen.insert(need.value.0.as_str()) {
283                    queue.push_back(need.value.0.as_str());
284                }
285            }
286        }
287    }
288
289    Ok(closure)
290}
291
292#[cfg(test)]
293mod tests {
294    use std::path::PathBuf;
295
296    use super::*;
297    use crate::eval::load_str;
298    use crate::loader::load_project;
299    use crate::model::SourceId;
300
301    /// Sorts a closure's ids into plain, comparable `String`s.
302    /// `execution_subgraph`'s result is unordered by contract (scheduling
303    /// order is the engine's job), so every test compares a sorted view
304    /// rather than depending on traversal order.
305    fn sorted(ids: Vec<BeamId>) -> Vec<String> {
306        let mut v: Vec<String> = ids.into_iter().map(|id| id.0).collect();
307        v.sort();
308        v
309    }
310
311    /// Writes `content` to `path`, creating any missing parent directories
312    /// first. Mirrors `tests/loader.rs`'s helper of the same name, for the
313    /// one test here that needs a real multi-file project on disk (to
314    /// check the `source_id` an imported file's own error carries).
315    fn write(path: PathBuf, content: &str) {
316        if let Some(parent) = path.parent() {
317            std::fs::create_dir_all(parent).unwrap();
318        }
319        std::fs::write(path, content).unwrap();
320    }
321
322    #[test]
323    fn unknown_need_suggests_closest() {
324        let err =
325            load_str("beam build { needs [tset] run \"x\" }\nbeam test { run \"y\" }").unwrap_err();
326        assert!(err.message.contains("unknown beam `tset`"));
327        assert_eq!(err.help.as_deref(), Some("did you mean `test`?"));
328    }
329
330    /// The caret lands on the entry that does not resolve, not on the beam
331    /// declaration that happens to contain it — every other diagnostic in
332    /// this workspace is token-precise, and a `needs` entry is a token.
333    #[test]
334    fn unknown_need_points_at_the_offending_entry() {
335        const SOURCE: &str = "beam build { needs [test, tset] run \"x\" }\nbeam test { run \"y\" }";
336
337        let err = load_str(SOURCE).unwrap_err();
338
339        let span = err.span.expect("a declared `needs` entry has a location");
340        assert_eq!(&SOURCE[span.start..span.end], "tset");
341    }
342
343    /// A namespaced entry is underlined whole, alias included.
344    #[test]
345    fn unknown_namespaced_need_points_at_the_whole_reference() {
346        const SOURCE: &str = "beam build { needs [api:tset] run \"x\" }";
347
348        let err = load_str(SOURCE).unwrap_err();
349
350        let span = err.span.expect("a declared `needs` entry has a location");
351        assert_eq!(&SOURCE[span.start..span.end], "api:tset");
352    }
353
354    /// `default` is part of the graph too: naming a beam that does not
355    /// exist must fail the load, not wait until a bare `alba` tries to run
356    /// it.
357    #[test]
358    fn unknown_default_is_rejected_at_its_own_span() {
359        const SOURCE: &str = "default biuld\nbeam build { run \"x\" }";
360
361        let err = load_str(SOURCE).unwrap_err();
362
363        assert!(err.message.contains("unknown beam `biuld`"));
364        assert_eq!(err.help.as_deref(), Some("did you mean `build`?"));
365        let span = err.span.expect("a declared `default` has a location");
366        assert_eq!(&SOURCE[span.start..span.end], "biuld");
367    }
368
369    /// A `default` that resolves is not disturbed by the check.
370    #[test]
371    fn a_default_naming_a_declared_beam_loads() {
372        let project = load_str("default build\nbeam build { run \"x\" }").unwrap();
373
374        assert_eq!(
375            project.default.as_ref().map(|d| d.value.0.as_str()),
376            Some("build")
377        );
378    }
379
380    #[test]
381    fn cycle_reports_exact_path() {
382        let err = load_str(
383            "
384beam build { needs [codegen] run \"a\" }
385beam codegen { needs [build] run \"b\" }
386",
387        )
388        .unwrap_err();
389        assert!(err.message.contains("build → codegen → build"));
390    }
391
392    #[test]
393    fn subgraph_is_transitive_closure() {
394        let project = load_str(
395            "
396beam a { run \"x\" }
397beam b { needs [a] run \"x\" }
398beam c { needs [b] run \"x\" }
399beam unrelated { run \"x\" }
400",
401        )
402        .unwrap();
403        let ids = execution_subgraph(&project, &BeamId("c".into())).unwrap();
404        assert_eq!(sorted(ids), vec!["a", "b", "c"]);
405    }
406
407    /// Edge case called out explicitly: a beam needing itself must report
408    /// `a → a`, not panic or loop forever.
409    #[test]
410    fn self_cycle_reports_itself() {
411        let err = load_str("beam a { needs [a] run \"x\" }").unwrap_err();
412        assert!(err.message.contains("a → a"));
413    }
414
415    /// `validate_graph` checks the whole project, not just whatever is
416    /// reachable from some particular target — a cycle nobody asked to run
417    /// is still a load error.
418    #[test]
419    fn cycle_unreachable_from_any_target_is_still_an_error() {
420        let err = load_str(
421            "
422beam main { run \"x\" }
423beam x { needs [y] run \"x\" }
424beam y { needs [x] run \"x\" }
425",
426        )
427        .unwrap_err();
428        assert!(err.message.contains("x → y → x"));
429    }
430
431    /// A `needs` entry repeated within one beam is not an error, and does
432    /// not duplicate the closure entry it names.
433    #[test]
434    fn duplicate_needs_do_not_duplicate_in_subgraph() {
435        let project = load_str(
436            "
437beam a { run \"x\" }
438beam b { needs [a, a] run \"x\" }
439",
440        )
441        .unwrap();
442        let ids = execution_subgraph(&project, &BeamId("b".into())).unwrap();
443        assert_eq!(sorted(ids), vec!["a", "b"]);
444    }
445
446    /// A target is supplied by whoever asked for the run, not written in
447    /// the Beamfile, so it has no location to underline — drawing a caret
448    /// at the first beam declaration asserted a source position that is
449    /// simply not where the mistake is. The "did you mean...?" help, which
450    /// is the useful half, survives.
451    #[test]
452    fn unknown_target_suggests_closest_without_a_source_location() {
453        let project = load_str("beam build { run \"x\" }").unwrap();
454        let err = execution_subgraph(&project, &BeamId("biuld".into())).unwrap_err();
455        assert!(err.message.contains("unknown beam `biuld`"));
456        assert_eq!(err.help.as_deref(), Some("did you mean `build`?"));
457        assert!(
458            err.span.is_none(),
459            "a target named outside the Beamfile has no span"
460        );
461    }
462
463    /// Guards against the class of bug this crate has already hit once:
464    /// which of two independent cycles gets reported must not depend on
465    /// `HashMap` iteration order, which is randomized per instance (a
466    /// fresh `by_id` map is built on every call). Run repeatedly in one
467    /// process so a flaky dependency on that order would actually surface.
468    #[test]
469    fn cycle_report_is_deterministic_across_runs() {
470        let src = "
471beam a1 { needs [a2] run \"x\" }
472beam a2 { needs [a1] run \"x\" }
473beam b1 { needs [b2] run \"x\" }
474beam b2 { needs [b1] run \"x\" }
475";
476        for _ in 0..20 {
477            let err = load_str(src).unwrap_err();
478            assert!(err.message.contains("a1 → a2 → a1"));
479        }
480    }
481
482    /// Distinguishes correct cycle-path slicing (`path[pos..]`) from the
483    /// bug it guards against (reporting the whole DFS path from whatever
484    /// root started the walk, `path[0..]`): a long, non-cyclic prefix
485    /// leads into the cycle, and none of that prefix's ids may appear in
486    /// the message — only the loop itself. `cycle_reports_exact_path`
487    /// alone doesn't distinguish these, because its DFS root is already
488    /// part of the cycle.
489    #[test]
490    fn cycle_message_excludes_the_non_cyclic_dfs_prefix() {
491        let err = load_str(
492            "
493beam root { needs [mid] run \"x\" }
494beam mid { needs [inner] run \"x\" }
495beam inner { needs [x] run \"x\" }
496beam x { needs [y] run \"x\" }
497beam y { needs [x] run \"x\" }
498",
499        )
500        .unwrap_err();
501        assert!(err.message.contains("x → y → x"));
502        assert!(!err.message.contains("root"));
503        assert!(!err.message.contains("mid"));
504        assert!(!err.message.contains("inner"));
505    }
506
507    /// `d` needs both `b` and `c`, which both need `a`: `a` must appear in
508    /// the closure exactly once, distinguishing the `seen`-guarded BFS
509    /// from one that pushes every `needs` entry unconditionally.
510    /// `duplicate_needs_do_not_duplicate_in_subgraph` only covers the easy
511    /// half (the same id repeated within one beam's own `needs` list) —
512    /// this covers two different beams sharing a dependency.
513    #[test]
514    fn diamond_shaped_needs_deduplicates_shared_dependency() {
515        let project = load_str(
516            "
517beam a { run \"x\" }
518beam b { needs [a] run \"x\" }
519beam c { needs [a] run \"x\" }
520beam d { needs [b, c] run \"x\" }
521",
522        )
523        .unwrap();
524        let ids = execution_subgraph(&project, &BeamId("d".into())).unwrap();
525        assert_eq!(ids.iter().filter(|id| id.0 == "a").count(), 1);
526        assert_eq!(sorted(ids), vec!["a", "b", "c", "d"]);
527    }
528
529    /// A five-level chain, to confirm the BFS actually walks multiple hops
530    /// to completion rather than stopping early — `subgraph_is_transitive_
531    /// closure`'s three levels leave that under-exercised.
532    #[test]
533    fn subgraph_walks_a_deep_chain_completely() {
534        let project = load_str(
535            "
536beam a { run \"x\" }
537beam b { needs [a] run \"x\" }
538beam c { needs [b] run \"x\" }
539beam d { needs [c] run \"x\" }
540beam e { needs [d] run \"x\" }
541",
542        )
543        .unwrap();
544        let ids = execution_subgraph(&project, &BeamId("e".into())).unwrap();
545        assert_eq!(sorted(ids), vec!["a", "b", "c", "d", "e"]);
546    }
547
548    /// A chain declared so that every beam needs the one declared *after*
549    /// it: nothing is marked done ahead of the walk, so cycle detection
550    /// descends the full depth in one go. A recursive walk aborts the
551    /// process here — a stack overflow is not a panic, so no caller can
552    /// catch it — which is why the search keeps its own heap-allocated
553    /// stack.
554    #[test]
555    fn cycle_detection_survives_a_very_deep_chain() {
556        const DEPTH: usize = 10_000;
557
558        let mut source = String::new();
559        for index in 0..DEPTH - 1 {
560            source.push_str(&format!(
561                "beam b{index} {{ needs [b{}] run \"x\" }}\n",
562                index + 1
563            ));
564        }
565        source.push_str(&format!("beam b{} {{ run \"x\" }}\n", DEPTH - 1));
566
567        let project = load_str(&source).unwrap();
568
569        assert_eq!(project.beams.len(), DEPTH);
570    }
571
572    /// The same depth, closed into a cycle by its last beam: the reported
573    /// path must still be exact rather than truncated or reordered by the
574    /// iterative walk.
575    #[test]
576    fn a_cycle_at_the_bottom_of_a_deep_chain_is_reported_exactly() {
577        const DEPTH: usize = 5_000;
578
579        let mut source = String::new();
580        for index in 0..DEPTH - 1 {
581            source.push_str(&format!(
582                "beam b{index} {{ needs [b{}] run \"x\" }}\n",
583                index + 1
584            ));
585        }
586        source.push_str(&format!(
587            "beam b{} {{ needs [b{}] run \"x\" }}\n",
588            DEPTH - 1,
589            DEPTH - 2
590        ));
591
592        let err = load_str(&source).unwrap_err();
593
594        assert!(
595            err.message
596                .contains(&format!("b{} → b{} → b{}", DEPTH - 2, DEPTH - 1, DEPTH - 2))
597        );
598        assert!(!err.message.contains("b0 "));
599    }
600
601    /// A cycle entirely among *imported*, namespaced beams. `detect_cycle` compares `BeamId.0` as
602    /// a plain string, so a `:`-namespaced id is no different from an
603    /// unnamespaced one by inspection — this pins that down with a real
604    /// multi-file fixture instead of leaving it unverified. `api/Beamfile`
605    /// is registered second (`SourceId(1)`), so the error must carry that
606    /// id, not the root's.
607    #[test]
608    fn cycle_among_imported_namespaced_beams_reports_qualified_path() {
609        let dir = tempfile::tempdir().unwrap();
610        write(
611            dir.path().join("Beamfile"),
612            "import \"api/Beamfile\" as api\nbeam all { needs [api:x] run \"echo\" }",
613        );
614        write(
615            dir.path().join("api/Beamfile"),
616            "beam x { needs [y] run \"x\" }\nbeam y { needs [x] run \"y\" }",
617        );
618
619        let err = load_project(&dir.path().join("Beamfile")).unwrap_err();
620
621        assert!(err.error.message.contains("api:x → api:y → api:x"));
622        assert_eq!(err.error.source_id, SourceId(1));
623    }
624
625    /// The `source_id` half of the wiring: `validate_graph` runs after
626    /// every file has finished loading, with no `SourceIdScope` active, so
627    /// it must read the offending beam's own `source` field rather than
628    /// defaulting to the root file's id. `api/Beamfile` here is registered
629    /// second (`SourceId(1)`); a bug that left `source_id` at its default
630    /// would report `SourceId(0)` (the root) instead.
631    #[test]
632    fn unknown_need_in_imported_file_reports_its_own_source_id() {
633        let dir = tempfile::tempdir().unwrap();
634        write(
635            dir.path().join("Beamfile"),
636            "import \"api/Beamfile\" as api\nbeam all { needs [api:build] run \"echo\" }",
637        );
638        write(
639            dir.path().join("api/Beamfile"),
640            "beam build { needs [tset] run \"x\" }\nbeam test { run \"y\" }",
641        );
642
643        let err = load_project(&dir.path().join("Beamfile")).unwrap_err();
644
645        assert!(err.error.message.contains("unknown beam `api:tset`"));
646        assert_eq!(err.error.help.as_deref(), Some("did you mean `api:test`?"));
647        assert_eq!(err.error.source_id, SourceId(1));
648    }
649}