Skip to main content

axon_frontend/
module_resolver.rs

1//! §Fase 115.a — Phase 0 of the Epistemic Module System: dependency discovery.
2//!
3//! Builds the module dependency DAG for a multi-file AXON project and
4//! topologically sorts it (Kahn), refusing cycles (`axon-T955`).
5//!
6//! # Design (D115.1)
7//!
8//! - **In-memory-first.** The resolver operates over a [`ModuleSet`] — a
9//!   deterministic map from [`ModulePath`] to source text. The filesystem
10//!   walk ([`ModuleSet::from_entry_file`]) is one constructor on top; the
11//!   enterprise bundle path and the LSP feed sources directly
12//!   ([`ModuleSet::from_memory`]) and never touch a disk.
13//! - **Lexer-true scanning.** [`scan_imports`] tokenizes with the real AXON
14//!   lexer and walks tokens — no AST, and crucially no regex: discovery can
15//!   never recognize a different import grammar than the parser does (the
16//!   drift class the retired Python EMS's regex scanner invited).
17//! - **Lenient scan, authoritative parse.** A malformed import statement is
18//!   *skipped* by the scanner — the parser owns the canonical diagnostic.
19//!   The scanner's only job is to know which files to load.
20//! - **Deterministic everywhere.** `BTreeMap`/`BTreeSet` ordering, and the
21//!   Kahn ready-queue pops the smallest module path first, so the
22//!   topological order is a pure function of the module set (§4.4 of the
23//!   EMS paper — the property the enterprise `ir_sha256` dedupe anchor
24//!   relies on).
25//!
26//! # Refusal posture (D115.9)
27//!
28//! Two import forms parse but are **refused** downstream (`axon-T953`, in
29//! the type-checker's module mode): the non-selective `import a.b` (name
30//! pollution — `#include` wearing a module system's clothes) and the
31//! `@scope`-prefixed form (reserved for a future package registry). The
32//! resolver records them (so the diagnostics can fire with real locations)
33//! but neither loads files nor contributes DAG edges for them.
34
35use std::collections::{BTreeMap, BTreeSet, VecDeque};
36use std::fmt;
37use std::path::{Path, PathBuf};
38
39use crate::lexer::Lexer;
40use crate::tokens::TokenType;
41
42/// Hard ceiling on the number of modules a single project may load.
43/// Fail-closed guard against runaway transitive graphs; generous by an
44/// order of magnitude over any real deployment seen to date.
45pub const MAX_MODULES: usize = 512;
46
47// ════════════════════════════════════════════════════════════════════
48//  ModulePath
49// ════════════════════════════════════════════════════════════════════
50
51/// A dotted module path: `axon.security` ⇔ `["axon", "security"]` ⇔
52/// `<modules-root>/axon/security.axon` (D115.8).
53#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
54pub struct ModulePath(pub Vec<String>);
55
56impl ModulePath {
57    /// The dotted display form (`axon.security`).
58    pub fn dotted(&self) -> String {
59        self.0.join(".")
60    }
61
62    /// The root-relative file this path resolves to (`axon/security.axon`).
63    pub fn relative_file(&self) -> PathBuf {
64        let mut p = PathBuf::new();
65        for part in &self.0 {
66            p.push(part);
67        }
68        p.set_extension("axon");
69        p
70    }
71
72    /// Whether this is the reserved `@scope` form (first segment keeps its
73    /// literal `@` prefix, exactly as the parser stores it).
74    pub fn is_scoped(&self) -> bool {
75        self.0.first().map(|s| s.starts_with('@')).unwrap_or(false)
76    }
77
78    /// Build from a root-relative file path (`axon/security.axon` →
79    /// `axon.security`). Returns `None` when a segment is not a valid
80    /// module identifier (`[A-Za-z_][A-Za-z0-9_]*`) or the extension is
81    /// not `.axon`.
82    pub fn from_relative_file(rel: &str) -> Option<ModulePath> {
83        let normalized = rel.replace('\\', "/");
84        let stripped = normalized.strip_suffix(".axon")?;
85        if stripped.is_empty() {
86            return None;
87        }
88        let segments: Vec<String> = stripped.split('/').map(str::to_string).collect();
89        if segments.iter().all(|s| is_module_ident(s)) {
90            Some(ModulePath(segments))
91        } else {
92            None
93        }
94    }
95}
96
97impl fmt::Display for ModulePath {
98    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
99        f.write_str(&self.dotted())
100    }
101}
102
103fn is_module_ident(s: &str) -> bool {
104    let mut chars = s.chars();
105    match chars.next() {
106        Some(c) if c.is_ascii_alphabetic() || c == '_' => {}
107        _ => return false,
108    }
109    chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
110}
111
112// ════════════════════════════════════════════════════════════════════
113//  Scanned imports (Phase 0 sees imports only — no AST)
114// ════════════════════════════════════════════════════════════════════
115
116/// One `import` statement as seen by the Phase-0 token scan.
117#[derive(Debug, Clone)]
118pub struct ScannedImport {
119    pub module_path: ModulePath,
120    /// The `{…}` selector names. Empty ⇔ the non-selective form.
121    pub names: Vec<String>,
122    /// `@allow_downgrade` ECC valve present (§115.c).
123    pub allow_downgrade: bool,
124    /// Whether the `{…}` selector was present at all.
125    pub selective: bool,
126    pub line: u32,
127    pub column: u32,
128}
129
130/// Extract every `import` statement from `source` via the real lexer.
131///
132/// Lenient by design: a *malformed* import is skipped (the parser owns
133/// the canonical error); a source that does not lex returns the lexer's
134/// error verbatim (nothing downstream could load such a module anyway).
135pub fn scan_imports(source: &str, filename: &str) -> Result<Vec<ScannedImport>, String> {
136    let tokens = Lexer::new(source, filename)
137        .tokenize()
138        .map_err(|e| format!("{}:{}:{} {}", filename, e.line, e.column, e.message))?;
139
140    let toks: Vec<_> = tokens
141        .into_iter()
142        .filter(|t| !is_comment(&t.ttype))
143        .collect();
144
145    let mut out = Vec::new();
146    let mut i = 0usize;
147    while i < toks.len() {
148        if toks[i].ttype != TokenType::Import {
149            i += 1;
150            continue;
151        }
152        let (line, column) = (toks[i].line, toks[i].column);
153        i += 1;
154
155        // ── path: [@]ident (. ident)* ────────────────────────────
156        let mut parts: Vec<String> = Vec::new();
157        let scoped = i < toks.len() && toks[i].ttype == TokenType::At;
158        if scoped {
159            i += 1;
160            match toks.get(i) {
161                Some(t) if t.ttype == TokenType::Identifier => {
162                    parts.push(format!("@{}", t.value));
163                    i += 1;
164                }
165                _ => continue, // malformed — parser will refuse
166            }
167        } else {
168            match toks.get(i) {
169                Some(t) if t.ttype == TokenType::Identifier => {
170                    parts.push(t.value.clone());
171                    i += 1;
172                }
173                _ => continue,
174            }
175        }
176        while i < toks.len() && toks[i].ttype == TokenType::Dot {
177            // `a.b.{X}` — the dot immediately before the selector brace
178            // terminates the path (mirror of `parse_import`).
179            if toks.get(i + 1).map(|t| &t.ttype) == Some(&TokenType::LBrace) {
180                i += 1;
181                break;
182            }
183            match toks.get(i + 1) {
184                Some(t) if t.ttype == TokenType::Identifier => {
185                    parts.push(t.value.clone());
186                    i += 2;
187                }
188                _ => break, // malformed tail — parser will refuse
189            }
190        }
191
192        // ── selector: { A, B } ───────────────────────────────────
193        let mut names = Vec::new();
194        let mut selective = false;
195        if i < toks.len() && toks[i].ttype == TokenType::LBrace {
196            selective = true;
197            i += 1;
198            loop {
199                match toks.get(i) {
200                    Some(t) if t.ttype == TokenType::Identifier => {
201                        names.push(t.value.clone());
202                        i += 1;
203                    }
204                    _ => break,
205                }
206                if toks.get(i).map(|t| &t.ttype) == Some(&TokenType::Comma) {
207                    i += 1;
208                    continue;
209                }
210                break;
211            }
212            if toks.get(i).map(|t| &t.ttype) == Some(&TokenType::RBrace) {
213                i += 1;
214            }
215        }
216
217        // ── §115.c valve: @allow_downgrade ───────────────────────
218        let mut allow_downgrade = false;
219        if toks.get(i).map(|t| &t.ttype) == Some(&TokenType::At)
220            && toks
221                .get(i + 1)
222                .map(|t| t.ttype == TokenType::Identifier && t.value == "allow_downgrade")
223                .unwrap_or(false)
224        {
225            allow_downgrade = true;
226            i += 2;
227        }
228
229        out.push(ScannedImport {
230            module_path: ModulePath(parts),
231            names,
232            allow_downgrade,
233            selective,
234            line,
235            column,
236        });
237    }
238    Ok(out)
239}
240
241fn is_comment(tt: &TokenType) -> bool {
242    matches!(
243        tt,
244        TokenType::LineComment
245            | TokenType::BlockComment
246            | TokenType::DocLineComment
247            | TokenType::DocBlockComment
248            | TokenType::InnerDocLineComment
249            | TokenType::InnerDocBlockComment
250    )
251}
252
253// ════════════════════════════════════════════════════════════════════
254//  Errors
255// ════════════════════════════════════════════════════════════════════
256
257/// A Phase-0 resolution failure. Rendered by the CLI in the house
258/// `error [line N]:` shape against the *importing* file.
259#[derive(Debug, Clone)]
260pub struct ModuleError {
261    pub code: &'static str,
262    pub message: String,
263    /// Display path (or bundle key) of the file the diagnostic anchors to.
264    pub origin: String,
265    pub line: u32,
266    pub column: u32,
267}
268
269impl ModuleError {
270    fn new(code: &'static str, message: String, origin: &str, line: u32, column: u32) -> Self {
271        ModuleError {
272            code,
273            message,
274            origin: origin.to_string(),
275            line,
276            column,
277        }
278    }
279}
280
281impl fmt::Display for ModuleError {
282    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
283        write!(
284            f,
285            "{}:{}:{} {} {}",
286            self.origin, self.line, self.column, self.code, self.message
287        )
288    }
289}
290
291// ════════════════════════════════════════════════════════════════════
292//  ModuleSet
293// ════════════════════════════════════════════════════════════════════
294
295/// One loaded module: its display origin (path or bundle key) + source.
296#[derive(Debug, Clone)]
297pub struct LoadedModule {
298    pub origin: String,
299    pub source: String,
300}
301
302/// The complete, deterministic set of modules for one compilation:
303/// the entry plus every transitively imported module.
304#[derive(Debug)]
305pub struct ModuleSet {
306    pub entry: ModulePath,
307    modules: BTreeMap<ModulePath, LoadedModule>,
308}
309
310impl ModuleSet {
311    /// Walk the filesystem from `entry_file`, loading every transitively
312    /// imported module under `modules_root` (default: the entry file's
313    /// directory — D115.8).
314    ///
315    /// Only **selective, unscoped** imports load files; the refused forms
316    /// (D115.9) surface later with real locations, so an unresolvable
317    /// `@scope` path can never abort the load of an otherwise-valid
318    /// project.
319    pub fn from_entry_file(
320        entry_file: &Path,
321        modules_root: Option<&Path>,
322    ) -> Result<ModuleSet, ModuleError> {
323        let entry_origin = entry_file.display().to_string();
324        let source = std::fs::read_to_string(entry_file).map_err(|e| {
325            ModuleError::new(
326                "axon-T953",
327                format!("cannot read entry file: {e}"),
328                &entry_origin,
329                0,
330                0,
331            )
332        })?;
333
334        let stem = entry_file
335            .file_stem()
336            .map(|s| s.to_string_lossy().into_owned())
337            .unwrap_or_else(|| "main".to_string());
338        let entry_path = ModulePath(vec![stem]);
339
340        let root: PathBuf = match modules_root {
341            Some(r) => r.to_path_buf(),
342            None => entry_file
343                .parent()
344                .map(|p| p.to_path_buf())
345                .unwrap_or_else(|| PathBuf::from(".")),
346        };
347
348        let mut modules = BTreeMap::new();
349        modules.insert(
350            entry_path.clone(),
351            LoadedModule {
352                origin: entry_origin.clone(),
353                source,
354            },
355        );
356
357        // BFS over selective, unscoped imports.
358        let mut queue: VecDeque<ModulePath> = VecDeque::new();
359        queue.push_back(entry_path.clone());
360        while let Some(current) = queue.pop_front() {
361            let loaded = &modules[&current];
362            let origin = loaded.origin.clone();
363            let imports = scan_imports(&loaded.source, &origin).map_err(|msg| {
364                ModuleError::new("axon-T953", format!("lex error during discovery: {msg}"), &origin, 0, 0)
365            })?;
366            for imp in imports {
367                if !imp.selective || imp.module_path.is_scoped() {
368                    continue; // refused later with a real location (D115.9)
369                }
370                if modules.contains_key(&imp.module_path) {
371                    continue;
372                }
373                if modules.len() >= MAX_MODULES {
374                    return Err(ModuleError::new(
375                        "axon-T953",
376                        format!(
377                            "module ceiling exceeded: a project may load at most {MAX_MODULES} modules"
378                        ),
379                        &origin,
380                        imp.line,
381                        imp.column,
382                    ));
383                }
384                let file = root.join(imp.module_path.relative_file());
385                let dep_source = std::fs::read_to_string(&file).map_err(|_| {
386                    ModuleError::new(
387                        "axon-T953",
388                        format!(
389                            "module '{}' not found: searched {}",
390                            imp.module_path,
391                            file.display()
392                        ),
393                        &origin,
394                        imp.line,
395                        imp.column,
396                    )
397                })?;
398                modules.insert(
399                    imp.module_path.clone(),
400                    LoadedModule {
401                        origin: file.display().to_string(),
402                        source: dep_source,
403                    },
404                );
405                queue.push_back(imp.module_path);
406            }
407        }
408
409        Ok(ModuleSet {
410            entry: entry_path,
411            modules,
412        })
413    }
414
415    /// Build from an in-memory bundle: root-relative file paths → sources.
416    /// `entry` names one of the keys. Every file must be **reachable** from
417    /// the entry through selective, unscoped imports — a bundle carrying
418    /// dead files is refused rather than silently shipping them (imports
419    /// are static; unreachable means unreferenced, and an artifact should
420    /// not quietly contain source nobody asked to link).
421    pub fn from_memory(
422        files: &BTreeMap<String, String>,
423        entry: &str,
424    ) -> Result<ModuleSet, ModuleError> {
425        if files.len() > MAX_MODULES {
426            return Err(ModuleError::new(
427                "axon-T953",
428                format!("bundle exceeds the {MAX_MODULES}-module ceiling"),
429                entry,
430                0,
431                0,
432            ));
433        }
434
435        // Map every bundle key to a ModulePath up front (validates keys).
436        let mut by_path: BTreeMap<ModulePath, (String, String)> = BTreeMap::new();
437        for (key, source) in files {
438            let mp = ModulePath::from_relative_file(key).ok_or_else(|| {
439                ModuleError::new(
440                    "axon-T953",
441                    format!(
442                        "bundle file '{key}' is not a valid module path: segments must be \
443                         identifiers and the extension must be .axon"
444                    ),
445                    key,
446                    0,
447                    0,
448                )
449            })?;
450            if by_path
451                .insert(mp.clone(), (key.clone(), source.clone()))
452                .is_some()
453            {
454                return Err(ModuleError::new(
455                    "axon-T953",
456                    format!("bundle files collide on module path '{mp}'"),
457                    key,
458                    0,
459                    0,
460                ));
461            }
462        }
463
464        let entry_path = ModulePath::from_relative_file(entry).ok_or_else(|| {
465            ModuleError::new(
466                "axon-T953",
467                format!("bundle entry '{entry}' is not a valid module path"),
468                entry,
469                0,
470                0,
471            )
472        })?;
473        if !by_path.contains_key(&entry_path) {
474            return Err(ModuleError::new(
475                "axon-T953",
476                format!("bundle entry '{entry}' is not among the bundle files"),
477                entry,
478                0,
479                0,
480            ));
481        }
482
483        // Reachability from the entry (selective, unscoped imports only).
484        let mut reached: BTreeSet<ModulePath> = BTreeSet::new();
485        reached.insert(entry_path.clone());
486        let mut queue: VecDeque<ModulePath> = VecDeque::new();
487        queue.push_back(entry_path.clone());
488        while let Some(current) = queue.pop_front() {
489            let (origin, source) = &by_path[&current];
490            let imports = scan_imports(source, origin).map_err(|msg| {
491                ModuleError::new("axon-T953", format!("lex error during discovery: {msg}"), origin, 0, 0)
492            })?;
493            for imp in imports {
494                if !imp.selective || imp.module_path.is_scoped() {
495                    continue;
496                }
497                if !by_path.contains_key(&imp.module_path) {
498                    return Err(ModuleError::new(
499                        "axon-T953",
500                        format!(
501                            "module '{}' not found in bundle (expected file '{}')",
502                            imp.module_path,
503                            imp.module_path.relative_file().display()
504                        ),
505                        origin,
506                        imp.line,
507                        imp.column,
508                    ));
509                }
510                if reached.insert(imp.module_path.clone()) {
511                    queue.push_back(imp.module_path);
512                }
513            }
514        }
515        let dead: Vec<String> = by_path
516            .keys()
517            .filter(|p| !reached.contains(*p))
518            .map(|p| p.relative_file().display().to_string())
519            .collect();
520        if !dead.is_empty() {
521            return Err(ModuleError::new(
522                "axon-T953",
523                format!(
524                    "bundle contains files unreachable from the entry: {}",
525                    dead.join(", ")
526                ),
527                entry,
528                0,
529                0,
530            ));
531        }
532
533        let modules = by_path
534            .into_iter()
535            .map(|(mp, (origin, source))| (mp, LoadedModule { origin, source }))
536            .collect();
537        Ok(ModuleSet {
538            entry: entry_path,
539            modules,
540        })
541    }
542
543    pub fn get(&self, path: &ModulePath) -> Option<&LoadedModule> {
544        self.modules.get(path)
545    }
546
547    /// Deterministic iteration over (path, module).
548    pub fn iter(&self) -> impl Iterator<Item = (&ModulePath, &LoadedModule)> {
549        self.modules.iter()
550    }
551
552    pub fn len(&self) -> usize {
553        self.modules.len()
554    }
555
556    pub fn is_empty(&self) -> bool {
557        self.modules.is_empty()
558    }
559}
560
561// ════════════════════════════════════════════════════════════════════
562//  ModuleGraph — DAG + Kahn + cycle refusal (axon-T955)
563// ════════════════════════════════════════════════════════════════════
564
565/// The resolved dependency graph: every module's scanned imports plus the
566/// deterministic topological order (dependencies first, entry last).
567#[derive(Debug)]
568pub struct ModuleGraph {
569    /// Topological order, dependencies before dependents. With no cycles
570    /// this always contains every module of the set exactly once.
571    pub order: Vec<ModulePath>,
572    /// Every scanned import per module — including the refused forms, so
573    /// downstream diagnostics fire with real locations.
574    pub imports: BTreeMap<ModulePath, Vec<ScannedImport>>,
575}
576
577impl ModuleGraph {
578    /// Build + topologically sort. Refuses cycles with `axon-T955`,
579    /// naming the full cycle path.
580    pub fn build(set: &ModuleSet) -> Result<ModuleGraph, ModuleError> {
581        let mut imports: BTreeMap<ModulePath, Vec<ScannedImport>> = BTreeMap::new();
582        // dependency edges: module → set of modules it imports (resolvable
583        // forms only), restricted to modules present in the set.
584        let mut deps: BTreeMap<ModulePath, BTreeSet<ModulePath>> = BTreeMap::new();
585
586        for (path, module) in set.iter() {
587            let scanned = scan_imports(&module.source, &module.origin).map_err(|msg| {
588                ModuleError::new("axon-T953", format!("lex error during discovery: {msg}"), &module.origin, 0, 0)
589            })?;
590            let mut dep_set: BTreeSet<ModulePath> = BTreeSet::new();
591            for imp in &scanned {
592                if imp.selective && !imp.module_path.is_scoped() && set.get(&imp.module_path).is_some()
593                {
594                    // Self-import is a 1-cycle; keep the edge so Kahn
595                    // refuses it with the honest diagnostic.
596                    dep_set.insert(imp.module_path.clone());
597                }
598            }
599            imports.insert(path.clone(), scanned);
600            deps.insert(path.clone(), dep_set);
601        }
602
603        // Kahn, smallest-path-first for determinism.
604        let mut in_degree: BTreeMap<&ModulePath, usize> =
605            deps.iter().map(|(p, d)| (p, d.len())).collect();
606        let mut dependents: BTreeMap<&ModulePath, Vec<&ModulePath>> = BTreeMap::new();
607        for (p, dset) in &deps {
608            for d in dset {
609                dependents.entry(d).or_default().push(p);
610            }
611        }
612
613        let mut ready: BTreeSet<&ModulePath> = in_degree
614            .iter()
615            .filter(|(_, deg)| **deg == 0)
616            .map(|(p, _)| *p)
617            .collect();
618        let mut order: Vec<ModulePath> = Vec::with_capacity(deps.len());
619        while let Some(&next) = ready.iter().next() {
620            ready.remove(next);
621            order.push(next.clone());
622            if let Some(deps_of_next) = dependents.get(next) {
623                for &dependent in deps_of_next {
624                    let deg = in_degree.get_mut(dependent).expect("known module");
625                    *deg -= 1;
626                    if *deg == 0 {
627                        ready.insert(dependent);
628                    }
629                }
630            }
631        }
632
633        if order.len() != deps.len() {
634            // Cycle: walk dependency edges among the unsorted remainder
635            // from the smallest leftover node until a repeat, then trim to
636            // the cycle proper.
637            let leftover: BTreeSet<&ModulePath> = deps
638                .keys()
639                .filter(|p| !order.contains(*p))
640                .collect();
641            let start = *leftover.iter().next().expect("non-empty leftover");
642            let mut path_walk: Vec<&ModulePath> = vec![start];
643            let mut seen: BTreeMap<&ModulePath, usize> = BTreeMap::new();
644            seen.insert(start, 0);
645            let mut current = start;
646            let cycle_text = loop {
647                let next = deps[current]
648                    .iter()
649                    .find(|d| leftover.contains(*d))
650                    .expect("a leftover node always has a leftover dependency");
651                if let Some(&idx) = seen.get(next) {
652                    let mut cyc: Vec<String> =
653                        path_walk[idx..].iter().map(|p| p.dotted()).collect();
654                    cyc.push(next.dotted());
655                    break cyc.join(" → ");
656                }
657                seen.insert(next, path_walk.len());
658                path_walk.push(next);
659                current = next;
660            };
661            let origin = set
662                .get(start)
663                .map(|m| m.origin.clone())
664                .unwrap_or_default();
665            return Err(ModuleError::new(
666                "axon-T955",
667                format!(
668                    "import cycle detected: {cycle_text}. Cognitive modules must form a DAG — \
669                     a persona cannot depend on an anchor that depends on that persona's \
670                     definition. Break the cycle by moving the shared definitions into a \
671                     module both sides import."
672                ),
673                &origin,
674                0,
675                0,
676            ));
677        }
678
679        Ok(ModuleGraph { order, imports })
680    }
681
682    /// The resolvable dependency paths of `module` (deterministic order).
683    pub fn dependencies_of(&self, module: &ModulePath) -> Vec<&ModulePath> {
684        let mut out: Vec<&ModulePath> = Vec::new();
685        if let Some(imps) = self.imports.get(module) {
686            let mut seen = BTreeSet::new();
687            for imp in imps {
688                if imp.selective && !imp.module_path.is_scoped() && seen.insert(&imp.module_path) {
689                    out.push(&imp.module_path);
690                }
691            }
692        }
693        out
694    }
695}
696
697// ════════════════════════════════════════════════════════════════════
698//  Unit tests (integration suite: tests/fase115_a_module_resolver.rs)
699// ════════════════════════════════════════════════════════════════════
700
701#[cfg(test)]
702mod tests {
703    use super::*;
704
705    fn set_of(pairs: &[(&str, &str)], entry: &str) -> ModuleSet {
706        let files: BTreeMap<String, String> = pairs
707            .iter()
708            .map(|(k, v)| (k.to_string(), v.to_string()))
709            .collect();
710        ModuleSet::from_memory(&files, entry).expect("valid set")
711    }
712
713    #[test]
714    fn scan_finds_selective_import() {
715        let imps = scan_imports("import axon.security.{A, B}\n", "t.axon").unwrap();
716        assert_eq!(imps.len(), 1);
717        assert_eq!(imps[0].module_path.dotted(), "axon.security");
718        assert_eq!(imps[0].names, vec!["A", "B"]);
719        assert!(imps[0].selective);
720        assert!(!imps[0].allow_downgrade);
721    }
722
723    #[test]
724    fn scan_finds_allow_downgrade_valve() {
725        let imps = scan_imports("import a.b.{X} @allow_downgrade\n", "t.axon").unwrap();
726        assert!(imps[0].allow_downgrade);
727    }
728
729    #[test]
730    fn scan_flags_non_selective_and_scoped() {
731        // NB: the scope segment must not collide with a language keyword
732        // (`scope` is one) — the scanner mirrors the parser's grammar,
733        // which requires an Identifier after `@`.
734        let imps = scan_imports("import a.b\nimport @myscope.pkg.{X}\n", "t.axon").unwrap();
735        assert_eq!(imps.len(), 2);
736        assert!(!imps[0].selective);
737        assert!(imps[1].module_path.is_scoped());
738    }
739
740    #[test]
741    fn kahn_orders_dependencies_first() {
742        let set = set_of(
743            &[
744                ("main.axon", "import lib.a.{X}\n"),
745                ("lib/a.axon", "import lib.b.{Y}\n"),
746                ("lib/b.axon", "persona Y { domain: [\"d\"] }\n"),
747            ],
748            "main.axon",
749        );
750        let g = ModuleGraph::build(&set).unwrap();
751        let pos = |d: &str| g.order.iter().position(|p| p.dotted() == d).unwrap();
752        assert!(pos("lib.b") < pos("lib.a"));
753        assert!(pos("lib.a") < pos("main"));
754    }
755
756    #[test]
757    fn diamond_resolves_once_deterministically() {
758        let set = set_of(
759            &[
760                ("main.axon", "import b.{X}\nimport c.{Y}\n"),
761                ("b.axon", "import d.{Z}\n"),
762                ("c.axon", "import d.{Z}\n"),
763                ("d.axon", "anchor Z { require: source_citation }\n"),
764            ],
765            "main.axon",
766        );
767        let g = ModuleGraph::build(&set).unwrap();
768        assert_eq!(g.order.len(), 4);
769        assert_eq!(g.order.first().unwrap().dotted(), "d");
770        assert_eq!(g.order.last().unwrap().dotted(), "main");
771    }
772
773    #[test]
774    fn cycle_is_refused_with_named_path() {
775        let set = set_of(
776            &[
777                ("main.axon", "import a.{X}\n"),
778                ("a.axon", "import b.{Y}\n"),
779                ("b.axon", "import a.{X}\n"),
780            ],
781            "main.axon",
782        );
783        let err = ModuleGraph::build(&set).unwrap_err();
784        assert_eq!(err.code, "axon-T955");
785        assert!(err.message.contains("a → b → a") || err.message.contains("b → a → b"));
786    }
787
788    #[test]
789    fn self_import_is_a_cycle() {
790        let set = set_of(&[("main.axon", "import main.{X}\n")], "main.axon");
791        let err = ModuleGraph::build(&set).unwrap_err();
792        assert_eq!(err.code, "axon-T955");
793    }
794
795    #[test]
796    fn bundle_missing_module_is_refused() {
797        let files: BTreeMap<String, String> =
798            [("main.axon".to_string(), "import gone.{X}\n".to_string())].into();
799        let err = ModuleSet::from_memory(&files, "main.axon").unwrap_err();
800        assert_eq!(err.code, "axon-T953");
801        assert!(err.message.contains("gone"));
802    }
803
804    #[test]
805    fn bundle_dead_file_is_refused() {
806        let files: BTreeMap<String, String> = [
807            ("main.axon".to_string(), "persona P { domain: [\"x\"] }\n".to_string()),
808            ("dead.axon".to_string(), "persona Q { domain: [\"y\"] }\n".to_string()),
809        ]
810        .into();
811        let err = ModuleSet::from_memory(&files, "main.axon").unwrap_err();
812        assert!(err.message.contains("unreachable"));
813        assert!(err.message.contains("dead.axon"));
814    }
815}