Skip to main content

harn_modules/
lib.rs

1use std::collections::{HashMap, HashSet};
2use std::path::{Component, Path, PathBuf};
3
4use harn_lexer::Span;
5use harn_parser::{BindingPattern, Node, Parser, SNode};
6use serde::Deserialize;
7
8pub mod asset_paths;
9pub mod fingerprint;
10pub mod personas;
11mod stdlib;
12
13/// Kind of symbol that can be exported by a module.
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
15pub enum DefKind {
16    Function,
17    Pipeline,
18    Tool,
19    Skill,
20    Struct,
21    Enum,
22    Interface,
23    Type,
24    Variable,
25    Parameter,
26}
27
28/// A resolved definition site within a module.
29#[derive(Debug, Clone)]
30pub struct DefSite {
31    pub name: String,
32    pub file: PathBuf,
33    pub kind: DefKind,
34    pub span: Span,
35}
36
37/// Wildcard import resolution status for a single importing module.
38#[derive(Debug, Clone)]
39pub enum WildcardResolution {
40    /// Resolved all wildcard imports and can expose wildcard exports.
41    Resolved(HashSet<String>),
42    /// At least one wildcard import could not be resolved.
43    Unknown,
44}
45
46/// Parsed information for a set of module files.
47#[derive(Debug, Default)]
48pub struct ModuleGraph {
49    modules: HashMap<PathBuf, ModuleInfo>,
50}
51
52#[derive(Debug, Clone)]
53pub struct ParsedModuleSource {
54    pub source: String,
55    pub program: Vec<SNode>,
56}
57
58#[derive(Debug, Default)]
59pub struct ModuleGraphBuild {
60    pub graph: ModuleGraph,
61    pub parsed_sources: HashMap<PathBuf, ParsedModuleSource>,
62}
63
64#[derive(Debug, Default)]
65struct ModuleInfo {
66    /// All declarations visible in this module (for local symbol lookup and
67    /// go-to-definition resolution).
68    declarations: HashMap<String, DefSite>,
69    /// Names exported by this module after re-export resolution. Equal to
70    /// [`own_exports`] union the keys of [`selective_re_exports`] union the
71    /// transitive exports of [`wildcard_re_export_paths`]. Populated in
72    /// `build()` after all modules are loaded.
73    exports: HashSet<String>,
74    /// Names declared locally and exported by this module — i.e. `pub fn`,
75    /// `pub struct`, etc., or every `fn` under the no-`pub fn` fallback.
76    own_exports: HashSet<String>,
77    /// Selective re-exports introduced by `pub import { name } from "..."`.
78    /// Maps the re-exported name to every canonical source module path it
79    /// could originate from. Multiple entries per name indicate a conflict
80    /// (`pub import { foo } from "a"` and `pub import { foo } from "b"`)
81    /// and are surfaced by [`ModuleGraph::re_export_conflicts`]. Lookup
82    /// callers (e.g. go-to-definition) follow the first recorded source.
83    selective_re_exports: HashMap<String, Vec<PathBuf>>,
84    /// Wildcard re-exports introduced by `pub import "..."`. Each entry is
85    /// the canonical path of a module whose entire public export surface
86    /// this module re-exports.
87    wildcard_re_export_paths: Vec<PathBuf>,
88    /// Names introduced by selective imports across this module.
89    selective_import_names: HashSet<String>,
90    /// Import references encountered in this file.
91    imports: Vec<ImportRef>,
92    /// True when at least one wildcard import could not be resolved.
93    has_unresolved_wildcard_import: bool,
94    /// True when at least one selective import could not be resolved
95    /// (importing file path missing). Prevents `imported_names_for_file`
96    /// from returning a partial answer when any import is broken.
97    has_unresolved_selective_import: bool,
98    /// Top-level type-like declarations that can be imported into a caller's
99    /// static type environment.
100    type_declarations: Vec<SNode>,
101    /// Top-level callable declarations whose signatures can be imported into
102    /// a caller's static type environment.
103    callable_declarations: Vec<SNode>,
104    /// Set when this module's own source failed to lex or parse. The module is
105    /// still recorded in the graph (with an otherwise-empty surface) so that
106    /// importers can be told their target is broken — instead of silently
107    /// seeing zero exports and mislabeling the imported symbol as "undefined"
108    /// at the call site.
109    load_error: Option<ModuleLoadError>,
110}
111
112/// A lex/parse failure captured while loading a module into the graph.
113///
114/// Retained so that `harn check <consumer>` can surface the real error in an
115/// imported file rather than downgrading its exports to "undefined" at the
116/// consumer's call site.
117#[derive(Debug, Clone)]
118pub struct ModuleLoadError {
119    /// Rendered lex/parse error message (includes the failing line:column).
120    pub message: String,
121    /// Span of the failure within the imported module's own source.
122    pub span: Span,
123}
124
125/// A consumer import whose resolved target module failed to compile. Reported
126/// by [`ModuleGraph::import_compile_failures`].
127#[derive(Debug, Clone)]
128pub struct ImportCompileFailure {
129    /// The import path exactly as written in the consumer.
130    pub import_raw_path: String,
131    /// Span of the consumer's `import` statement.
132    pub import_span: Span,
133    /// Canonical path of the broken imported module.
134    pub module_path: PathBuf,
135    /// The imported module's real lex/parse error.
136    pub error: ModuleLoadError,
137}
138
139#[derive(Debug, Clone)]
140struct ImportRef {
141    raw_path: String,
142    path: Option<PathBuf>,
143    selective_names: Option<HashSet<String>>,
144    import_span: Span,
145}
146
147/// Public import edge summary for static module graph consumers.
148#[derive(Debug, Clone, PartialEq, Eq)]
149pub struct ModuleImport {
150    /// The import string as written in source.
151    pub raw_path: String,
152    /// Resolved module path when the import could be resolved.
153    pub resolved_path: Option<PathBuf>,
154    /// `None` for wildcard imports; otherwise the selected names.
155    pub selective_names: Option<Vec<String>>,
156}
157
158#[derive(Debug, Default, Deserialize)]
159struct PackageManifest {
160    #[serde(default)]
161    exports: HashMap<String, String>,
162}
163
164/// Return the source for a resolved module path.
165///
166/// Real paths are read from disk. `<std>/<module>` virtual paths are backed by
167/// the embedded stdlib source table, so callers can parse resolved stdlib
168/// modules without knowing about the stdlib mirror layout.
169pub fn read_module_source(path: &Path) -> Option<String> {
170    if let Some(stdlib_module) = stdlib_module_from_path(path) {
171        return stdlib::get_stdlib_source(stdlib_module).map(ToString::to_string);
172    }
173    std::fs::read_to_string(path).ok()
174}
175
176/// Build a module graph from a set of files.
177///
178/// Files referenced via `import` statements are loaded recursively so the
179/// graph contains every module reachable from the seed set. Cycles and
180/// already-loaded files are skipped via a visited set.
181pub fn build(files: &[PathBuf]) -> ModuleGraph {
182    build_inner(files, None).graph
183}
184
185/// Build a module graph while retaining parsed sources for the seed files.
186///
187/// Imported-only modules still participate in the graph, but their ASTs are
188/// dropped after graph extraction so callers do not pay extra peak memory for
189/// parsed sources they will not reuse.
190pub fn build_with_parsed_sources(files: &[PathBuf]) -> ModuleGraphBuild {
191    let parsed_source_targets = files.iter().map(|file| normalize_path(file)).collect();
192    build_inner(files, Some(&parsed_source_targets))
193}
194
195fn build_inner(
196    files: &[PathBuf],
197    parsed_source_targets: Option<&HashSet<PathBuf>>,
198) -> ModuleGraphBuild {
199    let mut modules: HashMap<PathBuf, ModuleInfo> = HashMap::new();
200    let mut parsed_sources: HashMap<PathBuf, ParsedModuleSource> = HashMap::new();
201    let mut seen: HashSet<PathBuf> = HashSet::new();
202    let mut wave: Vec<PathBuf> = Vec::new();
203    for file in files {
204        let canonical = normalize_path(file);
205        if seen.insert(canonical.clone()) {
206            wave.push(canonical);
207        }
208    }
209    // Breadth-first over import waves. Every path in a wave is new (the
210    // `seen` set dedupes before enqueue), and `load_module` is a pure
211    // read+lex+parse+extract, so each wave loads in parallel; discovery of
212    // the next wave stays sequential to keep the dedup deterministic. A
213    // whole-tree seed set arrives as one large first wave, which is where
214    // nearly all the parse work is, so the serial-BFS tail on deep import
215    // chains does not matter in practice.
216    while !wave.is_empty() {
217        let loaded = load_wave(&wave);
218        let mut next_wave: Vec<PathBuf> = Vec::new();
219        for (path, (module, parsed)) in wave.drain(..).zip(loaded) {
220            let retain_parsed_source =
221                parsed_source_targets.is_some_and(|targets| targets.contains(&path));
222            if retain_parsed_source {
223                if let Some(parsed) = parsed {
224                    parsed_sources.insert(path.clone(), parsed);
225                }
226            }
227            // Enqueue resolved import targets so the whole reachable graph is
228            // discovered without the caller having to pre-walk imports.
229            //
230            // `resolve_import_path` returns paths as `base.join(import)` —
231            // i.e. with `..` segments preserved rather than collapsed. If we
232            // dedupe on those raw forms, two files that import each other
233            // across sibling dirs (`lib/context/` ↔ `lib/runtime/`) produce a
234            // different path spelling on every cycle — `.../context/../runtime/`,
235            // then `.../context/../runtime/../context/`, and so on — each of
236            // which is treated as a new file. The walk only terminates when
237            // `path.exists()` starts failing at the filesystem's `PATH_MAX`,
238            // which is 1024 on macOS but 4096 on Linux. Linux therefore
239            // re-parses the same handful of files thousands of times, balloons
240            // RSS into the multi-GB range, and gets SIGKILL'd by CI runners.
241            // Canonicalize once here so `seen` dedupes by the underlying file,
242            // not by its path spelling.
243            for import in &module.imports {
244                if let Some(import_path) = &import.path {
245                    let canonical = normalize_path(import_path);
246                    if seen.insert(canonical.clone()) {
247                        next_wave.push(canonical);
248                    }
249                }
250            }
251            modules.insert(path, module);
252        }
253        wave = next_wave;
254    }
255    resolve_re_exports(&mut modules);
256    ModuleGraphBuild {
257        graph: ModuleGraph { modules },
258        parsed_sources,
259    }
260}
261
262/// Environment override for the graph-build worker-pool size. `1` forces the
263/// serial walk; unset defaults to the machine's available parallelism.
264pub const MODULE_GRAPH_JOBS_ENV: &str = "HARN_MODULE_GRAPH_JOBS";
265
266/// Load one BFS wave of module paths, in parallel when the wave is large
267/// enough to pay for the threads. Results are index-aligned with `paths`.
268fn load_wave(paths: &[PathBuf]) -> Vec<(ModuleInfo, Option<ParsedModuleSource>)> {
269    const MIN_PARALLEL_WAVE: usize = 8;
270    let configured = std::env::var(MODULE_GRAPH_JOBS_ENV)
271        .ok()
272        .and_then(|value| value.parse::<usize>().ok())
273        .filter(|&jobs| jobs > 0);
274    let workers = configured
275        .unwrap_or_else(|| {
276            std::thread::available_parallelism()
277                .map(std::num::NonZeroUsize::get)
278                .unwrap_or(1)
279        })
280        .min(paths.len());
281    if workers <= 1 || paths.len() < MIN_PARALLEL_WAVE {
282        return paths.iter().map(|path| load_module(path)).collect();
283    }
284    let next = std::sync::atomic::AtomicUsize::new(0);
285    let mut produced: Vec<(usize, (ModuleInfo, Option<ParsedModuleSource>))> =
286        std::thread::scope(|scope| {
287            let handles: Vec<_> = (0..workers)
288                .map(|_| {
289                    scope.spawn(|| {
290                        let mut local = Vec::new();
291                        loop {
292                            let index = next.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
293                            let Some(path) = paths.get(index) else {
294                                break;
295                            };
296                            local.push((index, load_module(path)));
297                        }
298                        local
299                    })
300                })
301                .collect();
302            handles
303                .into_iter()
304                .flat_map(|handle| match handle.join() {
305                    Ok(local) => local,
306                    Err(panic) => std::panic::resume_unwind(panic),
307                })
308                .collect()
309        });
310    produced.sort_unstable_by_key(|(index, _)| *index);
311    produced.into_iter().map(|(_, loaded)| loaded).collect()
312}
313
314/// Iteratively expand each module's `exports` set to include the transitive
315/// public surface of its `pub import "..."` re-export targets. Cycles are
316/// safe because the loop only adds names — once no module's set grows in a
317/// pass, the fixpoint is reached.
318fn resolve_re_exports(modules: &mut HashMap<PathBuf, ModuleInfo>) {
319    let keys: Vec<PathBuf> = modules.keys().cloned().collect();
320    loop {
321        let mut changed = false;
322        for path in &keys {
323            // Snapshot the wildcard target list and gather the union of
324            // their current exports without holding a mutable borrow.
325            let wildcard_paths = modules
326                .get(path)
327                .map(|m| m.wildcard_re_export_paths.clone())
328                .unwrap_or_default();
329            if wildcard_paths.is_empty() {
330                continue;
331            }
332            let mut additions: Vec<String> = Vec::new();
333            for src in &wildcard_paths {
334                let src_canonical = normalize_path(src);
335                if let Some(src_module) = modules.get(src).or_else(|| modules.get(&src_canonical)) {
336                    additions.extend(src_module.exports.iter().cloned());
337                }
338            }
339            if let Some(module) = modules.get_mut(path) {
340                for name in additions {
341                    if module.exports.insert(name) {
342                        changed = true;
343                    }
344                }
345            }
346        }
347        if !changed {
348            break;
349        }
350    }
351}
352
353/// Resolve an import string relative to the importing file.
354///
355/// Returns the path as-constructed (not canonicalized) so callers that
356/// compare against their own `PathBuf::join` result get matching values.
357/// The module graph canonicalizes internally via `normalize_path` when
358/// keying modules, so call-site canonicalization is not required for
359/// dedup.
360///
361/// `std/<module>` imports resolve to a virtual path (`<std>/<module>`)
362/// backed by the embedded stdlib sources in [`stdlib`]. This lets the
363/// module graph model stdlib symbols even though they have no on-disk
364/// location.
365pub fn resolve_import_path(current_file: &Path, import_path: &str) -> Option<PathBuf> {
366    if let Some(module) = import_path
367        .strip_prefix("std/")
368        .or_else(|| (import_path == "observability").then_some("observability"))
369    {
370        if stdlib::get_stdlib_source(module).is_some() {
371            return Some(stdlib::stdlib_virtual_path(module));
372        }
373        return None;
374    }
375
376    let base = current_file.parent().unwrap_or(Path::new("."));
377    let mut file_path = base.join(import_path);
378    if !file_path.exists() && file_path.extension().is_none() {
379        file_path.set_extension("harn");
380    }
381    if file_path.exists() {
382        return Some(file_path);
383    }
384
385    if let Some(path) = resolve_package_import(base, import_path) {
386        return Some(path);
387    }
388
389    None
390}
391
392fn resolve_package_import(base: &Path, import_path: &str) -> Option<PathBuf> {
393    for anchor in base.ancestors() {
394        let packages_root = anchor.join(".harn/packages");
395        if !packages_root.is_dir() {
396            if anchor.join(".git").exists() {
397                break;
398            }
399            continue;
400        }
401        if let Some(path) = resolve_from_packages_root(&packages_root, import_path) {
402            return Some(path);
403        }
404        if anchor.join(".git").exists() {
405            break;
406        }
407    }
408    None
409}
410
411fn resolve_from_packages_root(packages_root: &Path, import_path: &str) -> Option<PathBuf> {
412    let safe_import_path = safe_package_relative_path(import_path)?;
413    let package_name = package_name_from_relative_path(&safe_import_path)?;
414    let package_root = packages_root.join(package_name);
415
416    let pkg_path = packages_root.join(&safe_import_path);
417    if let Some(path) = finalize_package_target(&package_root, &pkg_path) {
418        return Some(path);
419    }
420
421    let export_name = export_name_from_relative_path(&safe_import_path)?;
422    let manifest_path = packages_root.join(package_name).join("harn.toml");
423    let manifest = read_package_manifest(&manifest_path)?;
424    let rel_path = manifest.exports.get(export_name)?;
425    let safe_export_path = safe_package_relative_path(rel_path)?;
426    finalize_package_target(&package_root, &package_root.join(safe_export_path))
427}
428
429fn read_package_manifest(path: &Path) -> Option<PackageManifest> {
430    let content = std::fs::read_to_string(path).ok()?;
431    toml::from_str::<PackageManifest>(&content).ok()
432}
433
434fn safe_package_relative_path(raw: &str) -> Option<PathBuf> {
435    if raw.is_empty() || raw.contains('\\') {
436        return None;
437    }
438    let mut out = PathBuf::new();
439    let mut saw_component = false;
440    for component in Path::new(raw).components() {
441        match component {
442            Component::Normal(part) => {
443                saw_component = true;
444                out.push(part);
445            }
446            Component::CurDir => {}
447            Component::ParentDir | Component::RootDir | Component::Prefix(_) => return None,
448        }
449    }
450    saw_component.then_some(out)
451}
452
453fn package_name_from_relative_path(path: &Path) -> Option<&str> {
454    match path.components().next()? {
455        Component::Normal(name) => name.to_str(),
456        _ => None,
457    }
458}
459
460fn export_name_from_relative_path(path: &Path) -> Option<&str> {
461    let mut components = path.components();
462    components.next()?;
463    let rest = components.as_path();
464    if rest.as_os_str().is_empty() {
465        None
466    } else {
467        rest.to_str()
468    }
469}
470
471fn path_is_within(root: &Path, path: &Path) -> bool {
472    let Ok(root) = root.canonicalize() else {
473        return false;
474    };
475    let Ok(path) = path.canonicalize() else {
476        return false;
477    };
478    path == root || path.starts_with(root)
479}
480
481fn target_within_package_root(package_root: &Path, path: PathBuf) -> Option<PathBuf> {
482    path_is_within(package_root, &path).then_some(path)
483}
484
485fn finalize_package_target(package_root: &Path, path: &Path) -> Option<PathBuf> {
486    if path.is_dir() {
487        let lib = path.join("lib.harn");
488        if lib.exists() {
489            return target_within_package_root(package_root, lib);
490        }
491        return target_within_package_root(package_root, path.to_path_buf());
492    }
493    if path.exists() {
494        return target_within_package_root(package_root, path.to_path_buf());
495    }
496    if path.extension().is_none() {
497        let mut with_ext = path.to_path_buf();
498        with_ext.set_extension("harn");
499        if with_ext.exists() {
500            return target_within_package_root(package_root, with_ext);
501        }
502    }
503    None
504}
505
506impl ModuleGraph {
507    /// Sorted list of every module path discovered by [`build`]. Includes
508    /// `<std>/<name>` virtual paths for stdlib modules reached transitively.
509    /// Callers that want only real-disk modules can filter for paths whose
510    /// string form does not start with `<std>/`.
511    pub fn module_paths(&self) -> Vec<PathBuf> {
512        let mut paths: Vec<PathBuf> = self.modules.keys().cloned().collect();
513        paths.sort();
514        paths
515    }
516
517    /// True when `path` (or its canonical form) was discovered during the
518    /// module-graph walk.
519    pub fn contains_module(&self, path: &Path) -> bool {
520        self.modules.contains_key(path) || self.modules.contains_key(&normalize_path(path))
521    }
522
523    /// Collect every name used in selective imports from all files.
524    pub fn all_selective_import_names(&self) -> HashSet<&str> {
525        let mut names = HashSet::new();
526        for module in self.modules.values() {
527            for name in &module.selective_import_names {
528                names.insert(name.as_str());
529            }
530        }
531        names
532    }
533
534    /// Files that directly import `target`. Resolves `target` to a
535    /// canonical path before lookup so callers can pass either spelling.
536    pub fn importers_of(&self, target: &Path) -> Vec<PathBuf> {
537        let target = normalize_path(target);
538        let mut out: Vec<PathBuf> = self
539            .modules
540            .iter()
541            .filter(|(_, info)| {
542                info.imports.iter().any(|import| {
543                    import
544                        .path
545                        .as_ref()
546                        .is_some_and(|p| normalize_path(p) == target)
547                })
548            })
549            .map(|(path, _)| path.clone())
550            .collect();
551        out.sort();
552        out
553    }
554
555    /// Import edges declared by `file`, sorted by raw path and selected names.
556    pub fn imports_for_module(&self, file: &Path) -> Vec<ModuleImport> {
557        let file = normalize_path(file);
558        let Some(module) = self.modules.get(&file) else {
559            return Vec::new();
560        };
561        let mut imports: Vec<ModuleImport> = module
562            .imports
563            .iter()
564            .map(|import| {
565                let mut selective_names = import
566                    .selective_names
567                    .as_ref()
568                    .map(|names| names.iter().cloned().collect::<Vec<_>>());
569                if let Some(names) = selective_names.as_mut() {
570                    names.sort();
571                }
572                ModuleImport {
573                    raw_path: import.raw_path.clone(),
574                    resolved_path: import.path.as_ref().map(|path| normalize_path(path)),
575                    selective_names,
576                }
577            })
578            .collect();
579        imports.sort_by(|left, right| {
580            left.raw_path
581                .cmp(&right.raw_path)
582                .then_with(|| left.selective_names.cmp(&right.selective_names))
583                .then_with(|| left.resolved_path.cmp(&right.resolved_path))
584        });
585        imports
586    }
587
588    /// Exported symbol names for `file`, sorted alphabetically.
589    pub fn exports_for_module(&self, file: &Path) -> Vec<String> {
590        let file = normalize_path(file);
591        let Some(module) = self.modules.get(&file) else {
592            return Vec::new();
593        };
594        let mut exports: Vec<String> = module.exports.iter().cloned().collect();
595        exports.sort();
596        exports
597    }
598
599    /// Resolve wildcard imports for `file`.
600    ///
601    /// Returns `Unknown` when any wildcard import cannot be resolved, because
602    /// callers should conservatively disable wildcard-import-sensitive checks.
603    pub fn wildcard_exports_for(&self, file: &Path) -> WildcardResolution {
604        let file = normalize_path(file);
605        let Some(module) = self.modules.get(&file) else {
606            return WildcardResolution::Unknown;
607        };
608        if module.has_unresolved_wildcard_import {
609            return WildcardResolution::Unknown;
610        }
611
612        let mut names = HashSet::new();
613        for import in module
614            .imports
615            .iter()
616            .filter(|import| import.selective_names.is_none())
617        {
618            let Some(import_path) = &import.path else {
619                return WildcardResolution::Unknown;
620            };
621            let imported = self.modules.get(import_path).or_else(|| {
622                let normalized = normalize_path(import_path);
623                self.modules.get(&normalized)
624            });
625            let Some(imported) = imported else {
626                return WildcardResolution::Unknown;
627            };
628            names.extend(imported.exports.iter().cloned());
629        }
630        WildcardResolution::Resolved(names)
631    }
632
633    /// Collect every statically callable/referenceable name introduced into
634    /// `file` by its imports.
635    ///
636    /// Returns `Some` only when **every** import (wildcard or selective) in
637    /// `file` is fully resolvable via the graph. Returns `None` when any
638    /// import is unresolved, so callers can fall back to conservative
639    /// behavior instead of emitting spurious "undefined name" errors.
640    ///
641    /// The returned set contains:
642    /// - all public exports from wildcard-imported modules (transitively
643    ///   following `pub import` re-export chains), and
644    /// - selectively imported names that the target module actually exports
645    ///   (its `pub` surface or re-exports) — matching what the VM accepts at
646    ///   runtime. A name that exists only privately in the target is NOT
647    ///   importable.
648    ///
649    /// Every import in `file` whose resolved target module failed to lex or
650    /// parse. Lets `harn check <file>` surface the real error inside the
651    /// imported module (anchored at the consumer's `import` statement) instead
652    /// of downgrading the imported symbols to "undefined" at their call sites.
653    #[must_use]
654    pub fn import_compile_failures(&self, file: &Path) -> Vec<ImportCompileFailure> {
655        let file = normalize_path(file);
656        let Some(module) = self.modules.get(&file) else {
657            return Vec::new();
658        };
659        let mut failures = Vec::new();
660        for import in &module.imports {
661            let Some(import_path) = &import.path else {
662                continue;
663            };
664            let Some(target) = self
665                .modules
666                .get(import_path)
667                .or_else(|| self.modules.get(&normalize_path(import_path)))
668            else {
669                continue;
670            };
671            if let Some(error) = &target.load_error {
672                failures.push(ImportCompileFailure {
673                    import_raw_path: import.raw_path.clone(),
674                    import_span: import.import_span,
675                    module_path: normalize_path(import_path),
676                    error: error.clone(),
677                });
678            }
679        }
680        failures
681    }
682
683    pub fn imported_names_for_file(&self, file: &Path) -> Option<HashSet<String>> {
684        let file = normalize_path(file);
685        let module = self.modules.get(&file)?;
686        if module.has_unresolved_wildcard_import || module.has_unresolved_selective_import {
687            return None;
688        }
689
690        let mut names = HashSet::new();
691        for import in &module.imports {
692            let import_path = import.path.as_ref()?;
693            let imported = self
694                .modules
695                .get(import_path)
696                .or_else(|| self.modules.get(&normalize_path(import_path)))?;
697            // The target parsed nothing (lex/parse failure). Fall back to the
698            // conservative `None` answer so the cross-module undefined-name
699            // check stays silent — the real error is surfaced separately by
700            // `import_compile_failures`, not mislabeled as an undefined symbol
701            // at this consumer's call site.
702            if imported.load_error.is_some() {
703                return None;
704            }
705            match &import.selective_names {
706                None => {
707                    names.extend(imported.exports.iter().cloned());
708                }
709                Some(selective) => {
710                    // A selectively imported name is in scope when it exists in
711                    // the target module (as a declaration or a re-export). The
712                    // stricter "must be `pub`" check is reported precisely at
713                    // the import site by the `HARN-IMP-002` preflight scan
714                    // (`scan_selective_import_visibility`) and enforced at load
715                    // time, so it is intentionally *not* duplicated here —
716                    // otherwise a private import would surface both an
717                    // import-site and a redundant call-site error.
718                    for name in selective {
719                        if imported.declarations.contains_key(name)
720                            || imported.exports.contains(name)
721                        {
722                            names.insert(name.clone());
723                        }
724                    }
725                }
726            }
727        }
728        Some(names)
729    }
730
731    /// Collect type / struct / enum / interface declarations made visible to
732    /// `file` by its imports. Returns `None` when any import is unresolved so
733    /// callers can fall back to conservative behavior.
734    pub fn imported_type_declarations_for_file(&self, file: &Path) -> Option<Vec<SNode>> {
735        let file = normalize_path(file);
736        let module = self.modules.get(&file)?;
737        if module.has_unresolved_wildcard_import || module.has_unresolved_selective_import {
738            return None;
739        }
740
741        let mut decls = Vec::new();
742        for import in &module.imports {
743            let import_path = import.path.as_ref()?;
744            let imported = self
745                .modules
746                .get(import_path)
747                .or_else(|| self.modules.get(&normalize_path(import_path)))?;
748            // The target parsed nothing (lex/parse failure). Fall back to the
749            // conservative `None` answer so the cross-module undefined-name
750            // check stays silent — the real error is surfaced separately by
751            // `import_compile_failures`, not mislabeled as an undefined symbol
752            // at this consumer's call site.
753            if imported.load_error.is_some() {
754                return None;
755            }
756            let names_to_collect: Vec<String> = match &import.selective_names {
757                None => imported.exports.iter().cloned().collect(),
758                Some(selective) => selective.iter().cloned().collect(),
759            };
760            for name in &names_to_collect {
761                let mut visited = HashSet::new();
762                if let Some(decl) = self.find_exported_type_decl(import_path, name, &mut visited) {
763                    decls.push(decl);
764                }
765            }
766            // Every type alias / struct / enum / interface declared in the
767            // imported module is visible to the *typechecker*, `pub` or not:
768            // an imported fn's signature may reference a module-private alias
769            // ("options: PickKeysOptions"), and without its definition the
770            // caller sees only a phantom `Named(...)` and skips contract
771            // checks. This visibility is typing-only — name-level import
772            // privacy is still enforced by `non_exported_selective_imports`
773            // and the runtime loader, which reject importing a non-`pub`
774            // type by name.
775            for ty_decl in &imported.type_declarations {
776                if type_decl_name(ty_decl).is_some() {
777                    decls.push(ty_decl.clone());
778                }
779            }
780        }
781        Some(decls)
782    }
783
784    /// Collect callable declarations made visible to `file` by its imports.
785    /// Only signatures are consumed by the type checker; imported bodies
786    /// remain owned by their defining modules.
787    pub fn imported_callable_declarations_for_file(&self, file: &Path) -> Option<Vec<SNode>> {
788        let file = normalize_path(file);
789        let module = self.modules.get(&file)?;
790        if module.has_unresolved_wildcard_import || module.has_unresolved_selective_import {
791            return None;
792        }
793
794        let mut decls = Vec::new();
795        for import in &module.imports {
796            let import_path = import.path.as_ref()?;
797            let imported = self
798                .modules
799                .get(import_path)
800                .or_else(|| self.modules.get(&normalize_path(import_path)))?;
801            // The target parsed nothing (lex/parse failure). Fall back to the
802            // conservative `None` answer so the cross-module undefined-name
803            // check stays silent — the real error is surfaced separately by
804            // `import_compile_failures`, not mislabeled as an undefined symbol
805            // at this consumer's call site.
806            if imported.load_error.is_some() {
807                return None;
808            }
809            let selective_import = import.selective_names.is_some();
810            let names_to_collect: Vec<String> = match &import.selective_names {
811                None => imported.exports.iter().cloned().collect(),
812                Some(selective) => selective.iter().cloned().collect(),
813            };
814            for name in &names_to_collect {
815                if selective_import || imported.own_exports.contains(name) {
816                    if let Some(decl) = imported
817                        .callable_declarations
818                        .iter()
819                        .find(|decl| callable_decl_name(decl) == Some(name.as_str()))
820                    {
821                        decls.push(decl.clone());
822                        continue;
823                    }
824                }
825                let mut visited = HashSet::new();
826                if let Some(decl) =
827                    self.find_exported_callable_decl(import_path, name, &mut visited)
828                {
829                    decls.push(decl);
830                }
831            }
832        }
833        Some(decls)
834    }
835
836    /// Walk a module's local type declarations and re-export chains to find
837    /// the SNode for an exported type/struct/enum/interface named `name`.
838    fn find_exported_type_decl(
839        &self,
840        path: &Path,
841        name: &str,
842        visited: &mut HashSet<PathBuf>,
843    ) -> Option<SNode> {
844        let canonical = normalize_path(path);
845        if !visited.insert(canonical.clone()) {
846            return None;
847        }
848        let module = self
849            .modules
850            .get(&canonical)
851            .or_else(|| self.modules.get(path))?;
852        for decl in &module.type_declarations {
853            if type_decl_name(decl) == Some(name) && module.own_exports.contains(name) {
854                return Some(decl.clone());
855            }
856        }
857        if let Some(sources) = module.selective_re_exports.get(name) {
858            for source in sources {
859                if let Some(decl) = self.find_exported_type_decl(source, name, visited) {
860                    return Some(decl);
861                }
862            }
863        }
864        for source in &module.wildcard_re_export_paths {
865            if let Some(decl) = self.find_exported_type_decl(source, name, visited) {
866                return Some(decl);
867            }
868        }
869        None
870    }
871
872    fn find_exported_callable_decl(
873        &self,
874        path: &Path,
875        name: &str,
876        visited: &mut HashSet<PathBuf>,
877    ) -> Option<SNode> {
878        let canonical = normalize_path(path);
879        if !visited.insert(canonical.clone()) {
880            return None;
881        }
882        let module = self
883            .modules
884            .get(&canonical)
885            .or_else(|| self.modules.get(path))?;
886        for decl in &module.callable_declarations {
887            if callable_decl_name(decl) == Some(name) && module.own_exports.contains(name) {
888                return Some(decl.clone());
889            }
890        }
891        if let Some(sources) = module.selective_re_exports.get(name) {
892            for source in sources {
893                if let Some(decl) = self.find_exported_callable_decl(source, name, visited) {
894                    return Some(decl);
895                }
896            }
897        }
898        for source in &module.wildcard_re_export_paths {
899            if let Some(decl) = self.find_exported_callable_decl(source, name, visited) {
900                return Some(decl);
901            }
902        }
903        None
904    }
905
906    /// Find the definition of `name` visible from `file`.
907    ///
908    /// Recurses through `pub import` re-export chains so go-to-definition
909    /// lands on the symbol's actual declaration site instead of the facade
910    /// module that forwarded it.
911    pub fn definition_of(&self, file: &Path, name: &str) -> Option<DefSite> {
912        let mut visited = HashSet::new();
913        self.definition_of_inner(file, name, &mut visited)
914    }
915
916    /// Sorted names of every declaration recorded for `file` (functions,
917    /// pipelines, tools, structs, ...). Used by the check-result cache to
918    /// key the cross-file lint-exemption subset that applies to this file.
919    pub fn declared_names_for_file(&self, file: &Path) -> Option<Vec<&str>> {
920        let module = self.modules.get(&normalize_path(file))?;
921        let mut names: Vec<&str> = module.declarations.keys().map(String::as_str).collect();
922        names.sort_unstable();
923        Some(names)
924    }
925
926    fn definition_of_inner(
927        &self,
928        file: &Path,
929        name: &str,
930        visited: &mut HashSet<PathBuf>,
931    ) -> Option<DefSite> {
932        let file = normalize_path(file);
933        if !visited.insert(file.clone()) {
934            return None;
935        }
936        let current = self.modules.get(&file)?;
937
938        if let Some(local) = current.declarations.get(name) {
939            return Some(local.clone());
940        }
941
942        // `pub import { name } from "..."` — follow the first recorded
943        // source. Conflicting re-exports surface separately as
944        // diagnostics; here we just pick a canonical destination so
945        // go-to-definition lands somewhere useful.
946        if let Some(sources) = current.selective_re_exports.get(name) {
947            for source in sources {
948                if let Some(def) = self.definition_of_inner(source, name, visited) {
949                    return Some(def);
950                }
951            }
952        }
953
954        // `pub import "..."` — chase each wildcard re-export source.
955        for source in &current.wildcard_re_export_paths {
956            if let Some(def) = self.definition_of_inner(source, name, visited) {
957                return Some(def);
958            }
959        }
960
961        // Private selective imports.
962        for import in &current.imports {
963            let Some(selective_names) = &import.selective_names else {
964                continue;
965            };
966            if !selective_names.contains(name) {
967                continue;
968            }
969            if let Some(path) = &import.path {
970                if let Some(def) = self.definition_of_inner(path, name, visited) {
971                    return Some(def);
972                }
973            }
974        }
975
976        // Private wildcard imports.
977        for import in &current.imports {
978            if import.selective_names.is_some() {
979                continue;
980            }
981            if let Some(path) = &import.path {
982                if let Some(def) = self.definition_of_inner(path, name, visited) {
983                    return Some(def);
984                }
985            }
986        }
987
988        None
989    }
990
991    /// Diagnostics for re-export conflicts inside `file`. Each diagnostic
992    /// names the conflicting symbol and the modules that contributed it,
993    /// so check-time errors can be precise.
994    pub fn re_export_conflicts(&self, file: &Path) -> Vec<ReExportConflict> {
995        let file = normalize_path(file);
996        let Some(module) = self.modules.get(&file) else {
997            return Vec::new();
998        };
999
1000        // Build, for each re-exported name, the set of source modules it
1001        // could resolve to. Names that resolve to more than one source are
1002        // ambiguous and reported.
1003        let mut sources: HashMap<String, Vec<PathBuf>> = HashMap::new();
1004
1005        for (name, srcs) in &module.selective_re_exports {
1006            sources
1007                .entry(name.clone())
1008                .or_default()
1009                .extend(srcs.iter().cloned());
1010        }
1011        for src in &module.wildcard_re_export_paths {
1012            let canonical = normalize_path(src);
1013            let Some(src_module) = self
1014                .modules
1015                .get(&canonical)
1016                .or_else(|| self.modules.get(src))
1017            else {
1018                continue;
1019            };
1020            for name in &src_module.exports {
1021                sources
1022                    .entry(name.clone())
1023                    .or_default()
1024                    .push(canonical.clone());
1025            }
1026        }
1027
1028        // A re-export that collides with a locally exported declaration is
1029        // also an error: the facade module cannot expose two different
1030        // bindings under the same name.
1031        for name in &module.own_exports {
1032            if let Some(entry) = sources.get_mut(name) {
1033                entry.push(file.clone());
1034            }
1035        }
1036
1037        let mut conflicts = Vec::new();
1038        for (name, mut srcs) in sources {
1039            srcs.sort();
1040            srcs.dedup();
1041            if srcs.len() > 1 {
1042                conflicts.push(ReExportConflict {
1043                    name,
1044                    sources: srcs,
1045                });
1046            }
1047        }
1048        conflicts.sort_by(|a, b| a.name.cmp(&b.name));
1049        conflicts
1050    }
1051
1052    /// Selective imports in `file` that name a symbol the target module
1053    /// declares but does not export — a non-`pub` function in a module that
1054    /// has opted into explicit exports by marking at least one function `pub`.
1055    ///
1056    /// Such names are private: importing them by name is no more valid than a
1057    /// wildcard import reaching them, and matches the strict visibility of
1058    /// TypeScript, Rust, and Go. This is the single source of truth for that
1059    /// determination — the CLI maps the result onto import spans and emits
1060    /// `HARN-IMP-002`, and the runtime loader enforces the same rule. A module
1061    /// that marks nothing `pub` exports nothing, so selectively importing any
1062    /// name it declares is flagged.
1063    pub fn non_exported_selective_imports(&self, file: &Path) -> Vec<NonExportedImport> {
1064        let file = normalize_path(file);
1065        let Some(module) = self.modules.get(&file) else {
1066            return Vec::new();
1067        };
1068
1069        let mut out = Vec::new();
1070        for import in &module.imports {
1071            let Some(selective) = &import.selective_names else {
1072                continue;
1073            };
1074            let Some(import_path) = &import.path else {
1075                continue;
1076            };
1077            let Some(target) = self
1078                .modules
1079                .get(import_path)
1080                .or_else(|| self.modules.get(&normalize_path(import_path)))
1081            else {
1082                continue;
1083            };
1084            for name in selective {
1085                // Declared in the target but absent from its export surface
1086                // (and not a re-export, which lives in `exports`, not
1087                // `declarations`).
1088                if target.declarations.contains_key(name) && !target.exports.contains(name) {
1089                    out.push(NonExportedImport {
1090                        name: name.clone(),
1091                        module: import.raw_path.clone(),
1092                    });
1093                }
1094            }
1095        }
1096        out.sort_by(|a, b| (&a.name, &a.module).cmp(&(&b.name, &b.module)));
1097        out.dedup();
1098        out
1099    }
1100}
1101
1102/// A duplicate or ambiguous re-export inside a single module. Reported by
1103/// [`ModuleGraph::re_export_conflicts`].
1104#[derive(Debug, Clone, PartialEq, Eq)]
1105pub struct ReExportConflict {
1106    pub name: String,
1107    pub sources: Vec<PathBuf>,
1108}
1109
1110/// A selective import of a name the target module declares but does not
1111/// export. Reported by [`ModuleGraph::non_exported_selective_imports`].
1112#[derive(Debug, Clone, PartialEq, Eq)]
1113pub struct NonExportedImport {
1114    /// The non-exported name the import requested.
1115    pub name: String,
1116    /// The module path exactly as written in the import statement.
1117    pub module: String,
1118}
1119
1120fn load_module(path: &Path) -> (ModuleInfo, Option<ParsedModuleSource>) {
1121    let Some(source) = read_module_source(path) else {
1122        return (ModuleInfo::default(), None);
1123    };
1124    let mut lexer = harn_lexer::Lexer::new(&source);
1125    let tokens = match lexer.tokenize() {
1126        Ok(tokens) => tokens,
1127        Err(error) => {
1128            let module = ModuleInfo {
1129                load_error: Some(ModuleLoadError {
1130                    message: error.to_string(),
1131                    span: error.span(),
1132                }),
1133                ..ModuleInfo::default()
1134            };
1135            return (module, None);
1136        }
1137    };
1138    let mut parser = Parser::new(tokens);
1139    let program = match parser.parse() {
1140        Ok(program) => program,
1141        Err(error) => {
1142            let module = ModuleInfo {
1143                load_error: Some(ModuleLoadError {
1144                    message: error.to_string(),
1145                    span: error.span(),
1146                }),
1147                ..ModuleInfo::default()
1148            };
1149            return (module, None);
1150        }
1151    };
1152
1153    let mut module = ModuleInfo::default();
1154    for node in &program {
1155        collect_module_info(path, node, &mut module);
1156        collect_type_declarations(node, &mut module.type_declarations);
1157        collect_callable_declarations(node, &mut module.callable_declarations);
1158    }
1159    // Seed the transitive `exports` set from local exports plus selective
1160    // re-export names. Wildcard re-exports are folded in by
1161    // [`resolve_re_exports`] after every module has been loaded.
1162    module.exports.extend(module.own_exports.iter().cloned());
1163    module
1164        .exports
1165        .extend(module.selective_re_exports.keys().cloned());
1166    let parsed = ParsedModuleSource { source, program };
1167    (module, Some(parsed))
1168}
1169
1170/// Extract the stdlib module name when `path` is a `<std>/<name>`
1171/// virtual path, otherwise `None`.
1172fn stdlib_module_from_path(path: &Path) -> Option<&str> {
1173    let s = path.to_str()?;
1174    s.strip_prefix("<std>/")
1175}
1176
1177fn collect_module_info(file: &Path, snode: &SNode, module: &mut ModuleInfo) {
1178    match &snode.node {
1179        Node::FnDecl {
1180            name,
1181            params,
1182            is_pub,
1183            ..
1184        } => {
1185            if *is_pub {
1186                module.own_exports.insert(name.clone());
1187            }
1188            module.declarations.insert(
1189                name.clone(),
1190                decl_site(file, snode.span, name, DefKind::Function),
1191            );
1192            for param_name in params.iter().map(|param| param.name.clone()) {
1193                module.declarations.insert(
1194                    param_name.clone(),
1195                    decl_site(file, snode.span, &param_name, DefKind::Parameter),
1196                );
1197            }
1198        }
1199        Node::Pipeline { name, is_pub, .. } => {
1200            if *is_pub {
1201                module.own_exports.insert(name.clone());
1202            }
1203            module.declarations.insert(
1204                name.clone(),
1205                decl_site(file, snode.span, name, DefKind::Pipeline),
1206            );
1207        }
1208        Node::ToolDecl { name, is_pub, .. } => {
1209            if *is_pub {
1210                module.own_exports.insert(name.clone());
1211            }
1212            module.declarations.insert(
1213                name.clone(),
1214                decl_site(file, snode.span, name, DefKind::Tool),
1215            );
1216        }
1217        Node::SkillDecl { name, is_pub, .. } => {
1218            if *is_pub {
1219                module.own_exports.insert(name.clone());
1220            }
1221            module.declarations.insert(
1222                name.clone(),
1223                decl_site(file, snode.span, name, DefKind::Skill),
1224            );
1225        }
1226        Node::StructDecl { name, is_pub, .. } => {
1227            if *is_pub {
1228                module.own_exports.insert(name.clone());
1229            }
1230            module.declarations.insert(
1231                name.clone(),
1232                decl_site(file, snode.span, name, DefKind::Struct),
1233            );
1234        }
1235        Node::EnumDecl { name, is_pub, .. } => {
1236            if *is_pub {
1237                module.own_exports.insert(name.clone());
1238            }
1239            module.declarations.insert(
1240                name.clone(),
1241                decl_site(file, snode.span, name, DefKind::Enum),
1242            );
1243        }
1244        Node::InterfaceDecl { name, .. } => {
1245            module.own_exports.insert(name.clone());
1246            module.declarations.insert(
1247                name.clone(),
1248                decl_site(file, snode.span, name, DefKind::Interface),
1249            );
1250        }
1251        Node::TypeDecl { name, is_pub, .. } => {
1252            if *is_pub {
1253                module.own_exports.insert(name.clone());
1254            }
1255            module.declarations.insert(
1256                name.clone(),
1257                decl_site(file, snode.span, name, DefKind::Type),
1258            );
1259        }
1260        Node::LetBinding {
1261            pattern, is_pub, ..
1262        }
1263        | Node::ConstBinding {
1264            pattern, is_pub, ..
1265        } => {
1266            for name in pattern_names(pattern) {
1267                // A top-level `pub const`/`pub let` exports its (identifier)
1268                // binding as part of the module's public value surface, on the
1269                // same footing as `pub fn`.
1270                if *is_pub {
1271                    module.own_exports.insert(name.clone());
1272                }
1273                module.declarations.insert(
1274                    name.clone(),
1275                    decl_site(file, snode.span, &name, DefKind::Variable),
1276                );
1277            }
1278        }
1279        Node::ImportDecl { path, is_pub } => {
1280            let import_path = resolve_import_path(file, path);
1281            if import_path.is_none() {
1282                module.has_unresolved_wildcard_import = true;
1283            }
1284            if *is_pub {
1285                if let Some(resolved) = &import_path {
1286                    module
1287                        .wildcard_re_export_paths
1288                        .push(normalize_path(resolved));
1289                }
1290            }
1291            module.imports.push(ImportRef {
1292                raw_path: path.clone(),
1293                path: import_path,
1294                selective_names: None,
1295                import_span: snode.span,
1296            });
1297        }
1298        Node::SelectiveImport {
1299            names,
1300            path,
1301            is_pub,
1302        } => {
1303            let import_path = resolve_import_path(file, path);
1304            if import_path.is_none() {
1305                module.has_unresolved_selective_import = true;
1306            }
1307            if *is_pub {
1308                if let Some(resolved) = &import_path {
1309                    let canonical = normalize_path(resolved);
1310                    for name in names {
1311                        module
1312                            .selective_re_exports
1313                            .entry(name.clone())
1314                            .or_default()
1315                            .push(canonical.clone());
1316                    }
1317                }
1318            }
1319            let names: HashSet<String> = names.iter().cloned().collect();
1320            module.selective_import_names.extend(names.iter().cloned());
1321            module.imports.push(ImportRef {
1322                raw_path: path.clone(),
1323                path: import_path,
1324                selective_names: Some(names),
1325                import_span: snode.span,
1326            });
1327        }
1328        Node::AttributedDecl { inner, .. } => {
1329            collect_module_info(file, inner, module);
1330        }
1331        _ => {}
1332    }
1333}
1334
1335fn collect_type_declarations(snode: &SNode, decls: &mut Vec<SNode>) {
1336    match &snode.node {
1337        Node::TypeDecl { .. }
1338        | Node::StructDecl { .. }
1339        | Node::EnumDecl { .. }
1340        | Node::InterfaceDecl { .. } => decls.push(snode.clone()),
1341        Node::AttributedDecl { inner, .. } => collect_type_declarations(inner, decls),
1342        _ => {}
1343    }
1344}
1345
1346fn collect_callable_declarations(snode: &SNode, decls: &mut Vec<SNode>) {
1347    match &snode.node {
1348        Node::FnDecl { .. } | Node::Pipeline { .. } | Node::ToolDecl { .. } => {
1349            decls.push(snode.clone());
1350        }
1351        Node::AttributedDecl { inner, .. } => collect_callable_declarations(inner, decls),
1352        _ => {}
1353    }
1354}
1355
1356fn type_decl_name(snode: &SNode) -> Option<&str> {
1357    match &snode.node {
1358        Node::TypeDecl { name, .. }
1359        | Node::StructDecl { name, .. }
1360        | Node::EnumDecl { name, .. }
1361        | Node::InterfaceDecl { name, .. } => Some(name.as_str()),
1362        _ => None,
1363    }
1364}
1365
1366fn callable_decl_name(snode: &SNode) -> Option<&str> {
1367    match &snode.node {
1368        Node::FnDecl { name, .. } | Node::Pipeline { name, .. } | Node::ToolDecl { name, .. } => {
1369            Some(name.as_str())
1370        }
1371        Node::AttributedDecl { inner, .. } => callable_decl_name(inner),
1372        _ => None,
1373    }
1374}
1375
1376fn decl_site(file: &Path, span: Span, name: &str, kind: DefKind) -> DefSite {
1377    DefSite {
1378        name: name.to_string(),
1379        file: file.to_path_buf(),
1380        kind,
1381        span,
1382    }
1383}
1384
1385fn pattern_names(pattern: &BindingPattern) -> Vec<String> {
1386    match pattern {
1387        BindingPattern::Identifier(name) => vec![name.clone()],
1388        BindingPattern::Dict(fields) => fields
1389            .iter()
1390            .filter_map(|field| field.alias.as_ref().or(Some(&field.key)).cloned())
1391            .collect(),
1392        BindingPattern::List(elements) => elements
1393            .iter()
1394            .map(|element| element.name.clone())
1395            .collect(),
1396        BindingPattern::Pair(a, b) => vec![a.clone(), b.clone()],
1397    }
1398}
1399
1400fn normalize_path(path: &Path) -> PathBuf {
1401    canonical_path(path)
1402}
1403
1404/// Canonicalize `path`, memoized process-wide.
1405///
1406/// Module-graph construction and every per-file graph query canonicalize
1407/// paths to dedupe import-edge spellings, and the check preflight scan
1408/// canonicalizes each visited module per checked file. `Path::canonicalize`
1409/// resolves every component through the kernel, so a whole-tree `harn check`
1410/// used to spend the bulk of its wall clock in path-resolution syscalls
1411/// (`getattrlist` dominated system time). One positive-result memo removes
1412/// the `O(files x import closure)` repetition; failed canonicalizations are
1413/// not memoized (mirroring the bytecode cache's `canonicalize_cached`) so a
1414/// file that appears later still resolves correctly in long-lived processes.
1415/// `<std>/` virtual paths pass through untouched.
1416pub fn canonical_path(path: &Path) -> PathBuf {
1417    use std::sync::OnceLock;
1418    if stdlib_module_from_path(path).is_some() {
1419        return path.to_path_buf();
1420    }
1421    static MEMO: OnceLock<std::sync::Mutex<HashMap<PathBuf, PathBuf>>> = OnceLock::new();
1422    let memo = MEMO.get_or_init(|| std::sync::Mutex::new(HashMap::new()));
1423    if let Some(hit) = memo
1424        .lock()
1425        .expect("canonical path memo lock poisoned")
1426        .get(path)
1427        .cloned()
1428    {
1429        return hit;
1430    }
1431    match path.canonicalize() {
1432        Ok(canonical) => {
1433            memo.lock()
1434                .expect("canonical path memo lock poisoned")
1435                .insert(path.to_path_buf(), canonical.clone());
1436            canonical
1437        }
1438        Err(_) => path.to_path_buf(),
1439    }
1440}
1441
1442#[cfg(test)]
1443mod tests {
1444    use super::*;
1445    use std::fs;
1446
1447    fn write_file(dir: &Path, name: &str, contents: &str) -> PathBuf {
1448        let path = dir.join(name);
1449        fs::write(&path, contents).unwrap();
1450        path
1451    }
1452
1453    #[test]
1454    fn wave_parallel_build_matches_serial_semantics() {
1455        // Seed enough files to cross MIN_PARALLEL_WAVE so `load_wave` takes
1456        // the threaded path, and verify the graph resolves exactly as the
1457        // serial walk always did: every seed sees the shared module's export
1458        // and the shared module knows all its importers.
1459        let tmp = tempfile::tempdir().unwrap();
1460        let root = tmp.path();
1461        write_file(root, "shared.harn", "pub fn shared_fn() { 1 }\n");
1462        let seeds: Vec<PathBuf> = (0..12)
1463            .map(|i| {
1464                write_file(
1465                    root,
1466                    &format!("mod{i}.harn"),
1467                    &format!(
1468                        "import {{ shared_fn }} from \"./shared\"\npub fn f{i}() {{ shared_fn() }}\n"
1469                    ),
1470                )
1471            })
1472            .collect();
1473
1474        let graph = build(&seeds);
1475        for seed in &seeds {
1476            let names = graph
1477                .imported_names_for_file(seed)
1478                .expect("seed imports should resolve");
1479            assert!(names.contains("shared_fn"));
1480        }
1481        let importers = graph.importers_of(&root.join("shared.harn"));
1482        assert_eq!(importers.len(), seeds.len());
1483    }
1484
1485    #[test]
1486    fn pub_const_and_let_are_exported() {
1487        let tmp = tempfile::tempdir().unwrap();
1488        let root = tmp.path();
1489        write_file(
1490            root,
1491            "consts.harn",
1492            "pub const MAX = 3\npub let SEED = 7\nconst PRIVATE = 9\n",
1493        );
1494        let consumer = write_file(
1495            root,
1496            "use.harn",
1497            "import { MAX, SEED } from \"./consts\"\nMAX\n",
1498        );
1499
1500        let graph = build(std::slice::from_ref(&consumer));
1501        let names = graph
1502            .imported_names_for_file(&consumer)
1503            .expect("imports resolve");
1504        assert!(names.contains("MAX"), "pub const should be importable");
1505        assert!(names.contains("SEED"), "pub let should be importable");
1506        // A private const stays out of the export surface.
1507        let consts_exports = graph.exports_for_module(&root.join("consts.harn"));
1508        assert!(consts_exports.contains(&"MAX".to_string()));
1509        assert!(consts_exports.contains(&"SEED".to_string()));
1510        assert!(!consts_exports.contains(&"PRIVATE".to_string()));
1511    }
1512
1513    #[test]
1514    fn import_compile_failures_point_at_broken_module() {
1515        let tmp = tempfile::tempdir().unwrap();
1516        let root = tmp.path();
1517        // A syntax error makes the whole library fail to parse.
1518        write_file(
1519            root,
1520            "lib.harn",
1521            "pub fn ok() { 1 }\npub fn broken( {\n  2\n}\n",
1522        );
1523        let consumer = write_file(
1524            root,
1525            "main.harn",
1526            "import { ok } from \"./lib\"\npipeline test(task) { ok() }\n",
1527        );
1528
1529        let graph = build(std::slice::from_ref(&consumer));
1530        let failures = graph.import_compile_failures(&consumer);
1531        assert_eq!(failures.len(), 1, "the broken import should be reported");
1532        assert_eq!(failures[0].import_raw_path, "./lib");
1533        assert!(
1534            failures[0]
1535                .module_path
1536                .to_string_lossy()
1537                .ends_with("lib.harn"),
1538            "failure must name the imported module, not the consumer"
1539        );
1540
1541        // The consumer's undefined-name check falls back to conservative
1542        // `None` rather than flagging `ok` as undefined at its call site.
1543        assert!(
1544            graph.imported_names_for_file(&consumer).is_none(),
1545            "a broken import target should suppress the call-site undefined check"
1546        );
1547    }
1548
1549    #[test]
1550    fn importers_of_finds_direct_dependents() {
1551        let tmp = tempfile::tempdir().unwrap();
1552        let root = tmp.path();
1553        let leaf = write_file(root, "leaf.harn", "pub fn leaf() { 1 }\n");
1554        write_file(root, "a.harn", "import \"./leaf\"\nleaf()\n");
1555        write_file(root, "b.harn", "import { leaf } from \"./leaf\"\nleaf()\n");
1556        let entry = write_file(root, "entry.harn", "import \"./a\"\nimport \"./b\"\n");
1557
1558        let graph = build(std::slice::from_ref(&entry));
1559        let importers = graph.importers_of(&leaf);
1560        let names: Vec<String> = importers
1561            .iter()
1562            .map(|p| p.file_name().unwrap().to_string_lossy().into_owned())
1563            .collect();
1564        assert!(names.contains(&"a.harn".to_string()));
1565        assert!(names.contains(&"b.harn".to_string()));
1566        assert!(!names.contains(&"entry.harn".to_string()));
1567    }
1568
1569    #[test]
1570    fn recursive_build_loads_transitively_imported_modules() {
1571        let tmp = tempfile::tempdir().unwrap();
1572        let root = tmp.path();
1573        write_file(root, "leaf.harn", "pub fn leaf_fn() { 1 }\n");
1574        write_file(
1575            root,
1576            "mid.harn",
1577            "import \"./leaf\"\npub fn mid_fn() { leaf_fn() }\n",
1578        );
1579        let entry = write_file(root, "entry.harn", "import \"./mid\"\nmid_fn()\n");
1580
1581        let graph = build(std::slice::from_ref(&entry));
1582        let imported = graph
1583            .imported_names_for_file(&entry)
1584            .expect("entry imports should resolve");
1585        // Wildcard import of mid exposes mid_fn (pub) but not leaf_fn.
1586        assert!(imported.contains("mid_fn"));
1587        assert!(!imported.contains("leaf_fn"));
1588
1589        // The transitively loaded module is known to the graph even though
1590        // the seed only included entry.harn.
1591        let leaf_path = root.join("leaf.harn");
1592        assert!(graph.definition_of(&leaf_path, "leaf_fn").is_some());
1593    }
1594
1595    #[test]
1596    fn imported_names_returns_none_when_import_unresolved() {
1597        let tmp = tempfile::tempdir().unwrap();
1598        let root = tmp.path();
1599        let entry = write_file(root, "entry.harn", "import \"./does_not_exist\"\n");
1600
1601        let graph = build(std::slice::from_ref(&entry));
1602        assert!(graph.imported_names_for_file(&entry).is_none());
1603    }
1604
1605    #[test]
1606    fn selective_imports_contribute_only_requested_names() {
1607        let tmp = tempfile::tempdir().unwrap();
1608        let root = tmp.path();
1609        write_file(root, "util.harn", "pub fn a() { 1 }\npub fn b() { 2 }\n");
1610        let entry = write_file(root, "entry.harn", "import { a } from \"./util\"\n");
1611
1612        let graph = build(std::slice::from_ref(&entry));
1613        let imported = graph
1614            .imported_names_for_file(&entry)
1615            .expect("entry imports should resolve");
1616        assert!(imported.contains("a"));
1617        assert!(!imported.contains("b"));
1618    }
1619
1620    #[test]
1621    fn non_exported_selective_import_is_flagged_when_module_has_pub() {
1622        let tmp = tempfile::tempdir().unwrap();
1623        let root = tmp.path();
1624        write_file(root, "lib.harn", "pub fn api() { 1 }\nfn helper() { 2 }\n");
1625        let entry = write_file(root, "entry.harn", "import { helper } from \"./lib\"\n");
1626
1627        let graph = build(std::slice::from_ref(&entry));
1628        let offenders = graph.non_exported_selective_imports(&entry);
1629        assert_eq!(offenders.len(), 1);
1630        assert_eq!(offenders[0].name, "helper");
1631        assert_eq!(offenders[0].module, "./lib");
1632
1633        // Importing the `pub` name is fine.
1634        let entry_ok = write_file(root, "entry_ok.harn", "import { api } from \"./lib\"\n");
1635        let graph_ok = build(std::slice::from_ref(&entry_ok));
1636        assert!(graph_ok
1637            .non_exported_selective_imports(&entry_ok)
1638            .is_empty());
1639    }
1640
1641    #[test]
1642    fn selective_import_from_zero_pub_module_is_flagged() {
1643        let tmp = tempfile::tempdir().unwrap();
1644        let root = tmp.path();
1645        // A module with no `pub` markers exports nothing — Harn has no
1646        // "public-by-default" fallback — so selectively importing any of its
1647        // functions is flagged just like importing a private name.
1648        write_file(root, "util.harn", "fn a() { 1 }\nfn b() { 2 }\n");
1649        let entry = write_file(root, "entry.harn", "import { a } from \"./util\"\n");
1650
1651        let graph = build(std::slice::from_ref(&entry));
1652        let offenders = graph.non_exported_selective_imports(&entry);
1653        assert_eq!(offenders.len(), 1);
1654        assert_eq!(offenders[0].name, "a");
1655        assert_eq!(offenders[0].module, "./util");
1656    }
1657
1658    #[test]
1659    fn stdlib_imports_resolve_to_embedded_sources() {
1660        let tmp = tempfile::tempdir().unwrap();
1661        let root = tmp.path();
1662        let entry = write_file(root, "entry.harn", "import \"std/math\"\nclamp(5, 0, 10)\n");
1663
1664        let graph = build(std::slice::from_ref(&entry));
1665        let imported = graph
1666            .imported_names_for_file(&entry)
1667            .expect("std/math should resolve");
1668        // `clamp` is defined in stdlib_math.harn as `pub fn clamp(...)`.
1669        assert!(imported.contains("clamp"));
1670    }
1671
1672    #[test]
1673    fn stdlib_internal_imports_resolve_without_leaking_to_callers() {
1674        let tmp = tempfile::tempdir().unwrap();
1675        let root = tmp.path();
1676        let entry = write_file(
1677            root,
1678            "entry.harn",
1679            "import { process_run } from \"std/runtime\"\nprocess_run([\"echo\", \"ok\"])\n",
1680        );
1681
1682        let graph = build(std::slice::from_ref(&entry));
1683        let entry_imports = graph
1684            .imported_names_for_file(&entry)
1685            .expect("std/runtime should resolve");
1686        assert!(entry_imports.contains("process_run"));
1687        assert!(
1688            !entry_imports.contains("filter_nil"),
1689            "private std/runtime dependency leaked to caller"
1690        );
1691
1692        let runtime_path = stdlib::stdlib_virtual_path("runtime");
1693        let runtime_imports = graph
1694            .imported_names_for_file(&runtime_path)
1695            .expect("std/runtime internal imports should resolve");
1696        assert!(runtime_imports.contains("filter_nil"));
1697    }
1698
1699    #[test]
1700    fn runtime_stdlib_import_surface_resolves_to_embedded_sources() {
1701        let tmp = tempfile::tempdir().unwrap();
1702        let entry_path = write_file(tmp.path(), "entry.harn", "");
1703
1704        for source in harn_stdlib::STDLIB_SOURCES {
1705            let import_path = format!("std/{}", source.module);
1706            assert!(
1707                resolve_import_path(&entry_path, &import_path).is_some(),
1708                "{import_path} should resolve in the module graph"
1709            );
1710        }
1711    }
1712
1713    #[test]
1714    fn stdlib_imports_expose_type_declarations() {
1715        let tmp = tempfile::tempdir().unwrap();
1716        let root = tmp.path();
1717        let entry = write_file(
1718            root,
1719            "entry.harn",
1720            "import \"std/triggers\"\nlet provider = \"github\"\n",
1721        );
1722
1723        let graph = build(std::slice::from_ref(&entry));
1724        let decls = graph
1725            .imported_type_declarations_for_file(&entry)
1726            .expect("std/triggers type declarations should resolve");
1727        let names: HashSet<String> = decls
1728            .iter()
1729            .filter_map(type_decl_name)
1730            .map(ToString::to_string)
1731            .collect();
1732        assert!(names.contains("TriggerEvent"));
1733        assert!(names.contains("ProviderPayload"));
1734        assert!(names.contains("SignatureStatus"));
1735    }
1736
1737    #[test]
1738    fn stdlib_imports_expose_callable_declarations() {
1739        let tmp = tempfile::tempdir().unwrap();
1740        let root = tmp.path();
1741        let entry = write_file(
1742            root,
1743            "entry.harn",
1744            "import { select_from } from \"std/tui\"\nlet item = \"alpha\"\n",
1745        );
1746
1747        let graph = build(std::slice::from_ref(&entry));
1748        let decls = graph
1749            .imported_callable_declarations_for_file(&entry)
1750            .expect("std/tui callable declarations should resolve");
1751        let names: HashSet<String> = decls
1752            .iter()
1753            .filter_map(callable_decl_name)
1754            .map(ToString::to_string)
1755            .collect();
1756        assert!(names.contains("select_from"));
1757    }
1758
1759    #[test]
1760    fn stdlib_llm_catalog_exposes_routing_routes() {
1761        let tmp = tempfile::tempdir().unwrap();
1762        let root = tmp.path();
1763        let entry = write_file(
1764            root,
1765            "entry.harn",
1766            "import { routing_routes } from \"std/llm/catalog\"\nrouting_routes()\n",
1767        );
1768
1769        let graph = build(std::slice::from_ref(&entry));
1770        let imported = graph
1771            .imported_names_for_file(&entry)
1772            .expect("std/llm/catalog should resolve");
1773        assert!(imported.contains("routing_routes"));
1774        let decls = graph
1775            .imported_callable_declarations_for_file(&entry)
1776            .expect("std/llm/catalog callable declarations should resolve");
1777        let names: HashSet<String> = decls
1778            .iter()
1779            .filter_map(callable_decl_name)
1780            .map(ToString::to_string)
1781            .collect();
1782        assert!(names.contains("routing_routes"));
1783    }
1784
1785    #[test]
1786    fn package_export_map_resolves_declared_module() {
1787        let tmp = tempfile::tempdir().unwrap();
1788        let root = tmp.path();
1789        let packages = root.join(".harn/packages/acme/runtime");
1790        fs::create_dir_all(&packages).unwrap();
1791        fs::write(
1792            root.join(".harn/packages/acme/harn.toml"),
1793            "[exports]\ncapabilities = \"runtime/capabilities.harn\"\n",
1794        )
1795        .unwrap();
1796        fs::write(
1797            packages.join("capabilities.harn"),
1798            "pub fn exported_capability() { 1 }\n",
1799        )
1800        .unwrap();
1801        let entry = write_file(
1802            root,
1803            "entry.harn",
1804            "import \"acme/capabilities\"\nexported_capability()\n",
1805        );
1806
1807        let graph = build(std::slice::from_ref(&entry));
1808        let imported = graph
1809            .imported_names_for_file(&entry)
1810            .expect("package export should resolve");
1811        assert!(imported.contains("exported_capability"));
1812    }
1813
1814    #[test]
1815    fn package_direct_import_cannot_escape_packages_root() {
1816        let tmp = tempfile::tempdir().unwrap();
1817        let root = tmp.path();
1818        fs::create_dir_all(root.join(".harn/packages/acme")).unwrap();
1819        fs::write(root.join("secret.harn"), "pub fn leaked() { 1 }\n").unwrap();
1820        let entry = write_file(root, "entry.harn", "");
1821
1822        let resolved = resolve_import_path(&entry, "acme/../../secret");
1823        assert!(resolved.is_none(), "package import escaped package root");
1824    }
1825
1826    #[test]
1827    fn package_export_map_cannot_escape_package_root() {
1828        let tmp = tempfile::tempdir().unwrap();
1829        let root = tmp.path();
1830        fs::create_dir_all(root.join(".harn/packages/acme")).unwrap();
1831        fs::write(root.join("secret.harn"), "pub fn leaked() { 1 }\n").unwrap();
1832        fs::write(
1833            root.join(".harn/packages/acme/harn.toml"),
1834            "[exports]\nleak = \"../../secret.harn\"\n",
1835        )
1836        .unwrap();
1837        let entry = write_file(root, "entry.harn", "");
1838
1839        let resolved = resolve_import_path(&entry, "acme/leak");
1840        assert!(resolved.is_none(), "package export escaped package root");
1841    }
1842
1843    #[test]
1844    fn package_export_map_allows_symlinked_path_dependencies() {
1845        let tmp = tempfile::tempdir().unwrap();
1846        let root = tmp.path();
1847        let source = root.join("source-package");
1848        fs::create_dir_all(source.join("runtime")).unwrap();
1849        fs::write(
1850            source.join("harn.toml"),
1851            "[exports]\ncapabilities = \"runtime/capabilities.harn\"\n",
1852        )
1853        .unwrap();
1854        fs::write(
1855            source.join("runtime/capabilities.harn"),
1856            "pub fn exported_capability() { 1 }\n",
1857        )
1858        .unwrap();
1859        fs::create_dir_all(root.join(".harn/packages")).unwrap();
1860        #[cfg(unix)]
1861        std::os::unix::fs::symlink(&source, root.join(".harn/packages/acme")).unwrap();
1862        #[cfg(windows)]
1863        std::os::windows::fs::symlink_dir(&source, root.join(".harn/packages/acme")).unwrap();
1864        let entry = write_file(root, "entry.harn", "");
1865
1866        let resolved = resolve_import_path(&entry, "acme/capabilities")
1867            .expect("symlinked package export should resolve");
1868        assert!(resolved.ends_with("runtime/capabilities.harn"));
1869    }
1870
1871    #[test]
1872    fn package_imports_resolve_from_nested_package_module() {
1873        let tmp = tempfile::tempdir().unwrap();
1874        let root = tmp.path();
1875        fs::create_dir_all(root.join(".git")).unwrap();
1876        fs::create_dir_all(root.join(".harn/packages/acme")).unwrap();
1877        fs::create_dir_all(root.join(".harn/packages/shared")).unwrap();
1878        fs::write(
1879            root.join(".harn/packages/shared/lib.harn"),
1880            "pub fn shared_helper() { 1 }\n",
1881        )
1882        .unwrap();
1883        fs::write(
1884            root.join(".harn/packages/acme/lib.harn"),
1885            "import \"shared\"\npub fn use_shared() { shared_helper() }\n",
1886        )
1887        .unwrap();
1888        let entry = write_file(root, "entry.harn", "import \"acme\"\nuse_shared()\n");
1889
1890        let graph = build(std::slice::from_ref(&entry));
1891        let imported = graph
1892            .imported_names_for_file(&entry)
1893            .expect("nested package import should resolve");
1894        assert!(imported.contains("use_shared"));
1895        let acme_path = root.join(".harn/packages/acme/lib.harn");
1896        let acme_imports = graph
1897            .imported_names_for_file(&acme_path)
1898            .expect("package module imports should resolve");
1899        assert!(acme_imports.contains("shared_helper"));
1900    }
1901
1902    #[test]
1903    fn unknown_stdlib_import_is_unresolved() {
1904        let tmp = tempfile::tempdir().unwrap();
1905        let root = tmp.path();
1906        let entry = write_file(root, "entry.harn", "import \"std/does_not_exist\"\n");
1907
1908        let graph = build(std::slice::from_ref(&entry));
1909        assert!(
1910            graph.imported_names_for_file(&entry).is_none(),
1911            "unknown std module should fail resolution and disable strict check"
1912        );
1913    }
1914
1915    #[test]
1916    fn import_cycles_do_not_loop_forever() {
1917        let tmp = tempfile::tempdir().unwrap();
1918        let root = tmp.path();
1919        write_file(root, "a.harn", "import \"./b\"\npub fn a_fn() { 1 }\n");
1920        write_file(root, "b.harn", "import \"./a\"\npub fn b_fn() { 1 }\n");
1921        let entry = root.join("a.harn");
1922
1923        // Just ensuring this terminates and yields sensible names.
1924        let graph = build(std::slice::from_ref(&entry));
1925        let imported = graph
1926            .imported_names_for_file(&entry)
1927            .expect("cyclic imports still resolve to known exports");
1928        assert!(imported.contains("b_fn"));
1929    }
1930
1931    #[test]
1932    fn pub_import_selective_re_exports_named_symbols() {
1933        let tmp = tempfile::tempdir().unwrap();
1934        let root = tmp.path();
1935        write_file(
1936            root,
1937            "src.harn",
1938            "pub fn alpha() { 1 }\npub fn beta() { 2 }\n",
1939        );
1940        write_file(root, "facade.harn", "pub import { alpha } from \"./src\"\n");
1941        let entry = write_file(root, "entry.harn", "import \"./facade\"\nalpha()\n");
1942
1943        let graph = build(std::slice::from_ref(&entry));
1944        let imported = graph
1945            .imported_names_for_file(&entry)
1946            .expect("entry should resolve");
1947        assert!(imported.contains("alpha"), "selective re-export missing");
1948        assert!(
1949            !imported.contains("beta"),
1950            "non-listed name leaked through facade"
1951        );
1952
1953        let facade_path = root.join("facade.harn");
1954        let def = graph
1955            .definition_of(&facade_path, "alpha")
1956            .expect("definition_of should chase re-export");
1957        assert!(def.file.ends_with("src.harn"));
1958    }
1959
1960    #[test]
1961    fn pub_import_wildcard_re_exports_full_surface() {
1962        let tmp = tempfile::tempdir().unwrap();
1963        let root = tmp.path();
1964        write_file(
1965            root,
1966            "src.harn",
1967            "pub fn alpha() { 1 }\npub fn beta() { 2 }\n",
1968        );
1969        write_file(root, "facade.harn", "pub import \"./src\"\n");
1970        let entry = write_file(root, "entry.harn", "import \"./facade\"\nalpha()\n");
1971
1972        let graph = build(std::slice::from_ref(&entry));
1973        let imported = graph
1974            .imported_names_for_file(&entry)
1975            .expect("entry should resolve");
1976        assert!(imported.contains("alpha"));
1977        assert!(imported.contains("beta"));
1978    }
1979
1980    #[test]
1981    fn pub_import_chain_resolves_definition_to_origin() {
1982        let tmp = tempfile::tempdir().unwrap();
1983        let root = tmp.path();
1984        write_file(root, "inner.harn", "pub fn deep() { 1 }\n");
1985        write_file(
1986            root,
1987            "middle.harn",
1988            "pub import { deep } from \"./inner\"\n",
1989        );
1990        write_file(
1991            root,
1992            "outer.harn",
1993            "pub import { deep } from \"./middle\"\n",
1994        );
1995        let entry = write_file(
1996            root,
1997            "entry.harn",
1998            "import { deep } from \"./outer\"\ndeep()\n",
1999        );
2000
2001        let graph = build(std::slice::from_ref(&entry));
2002        let def = graph
2003            .definition_of(&entry, "deep")
2004            .expect("definition_of should follow chain");
2005        assert!(def.file.ends_with("inner.harn"));
2006
2007        let imported = graph
2008            .imported_names_for_file(&entry)
2009            .expect("entry should resolve");
2010        assert!(imported.contains("deep"));
2011    }
2012
2013    #[test]
2014    fn duplicate_pub_import_reports_re_export_conflict() {
2015        let tmp = tempfile::tempdir().unwrap();
2016        let root = tmp.path();
2017        write_file(root, "a.harn", "pub fn shared() { 1 }\n");
2018        write_file(root, "b.harn", "pub fn shared() { 2 }\n");
2019        let facade = write_file(
2020            root,
2021            "facade.harn",
2022            "pub import { shared } from \"./a\"\npub import { shared } from \"./b\"\n",
2023        );
2024
2025        let graph = build(std::slice::from_ref(&facade));
2026        let conflicts = graph.re_export_conflicts(&facade);
2027        assert_eq!(
2028            conflicts.len(),
2029            1,
2030            "expected exactly one re-export conflict, got {conflicts:?}"
2031        );
2032        assert_eq!(conflicts[0].name, "shared");
2033        assert_eq!(conflicts[0].sources.len(), 2);
2034    }
2035
2036    #[test]
2037    fn cross_directory_cycle_does_not_explode_module_count() {
2038        // Regression: two files in sibling directories that import each
2039        // other produced a fresh path spelling on every round-trip
2040        // (`../runtime/../context/../runtime/...`), and `build()`'s
2041        // `seen` set deduped on the raw spelling rather than the
2042        // canonical path. The walk only terminated when `PATH_MAX` was
2043        // hit — 1024 on macOS, 4096 on Linux — so Linux re-parsed the
2044        // same pair thousands of times until it ran out of memory.
2045        let tmp = tempfile::tempdir().unwrap();
2046        let root = tmp.path();
2047        let context = root.join("context");
2048        let runtime = root.join("runtime");
2049        fs::create_dir_all(&context).unwrap();
2050        fs::create_dir_all(&runtime).unwrap();
2051        write_file(
2052            &context,
2053            "a.harn",
2054            "import \"../runtime/b\"\npub fn a_fn() { 1 }\n",
2055        );
2056        write_file(
2057            &runtime,
2058            "b.harn",
2059            "import \"../context/a\"\npub fn b_fn() { 1 }\n",
2060        );
2061        let entry = context.join("a.harn");
2062
2063        let graph = build(std::slice::from_ref(&entry));
2064        // The graph should contain exactly the two real files, keyed by
2065        // their canonical paths. Pre-fix this was thousands of entries.
2066        assert_eq!(
2067            graph.modules.len(),
2068            2,
2069            "cross-directory cycle loaded {} modules, expected 2",
2070            graph.modules.len()
2071        );
2072        let imported = graph
2073            .imported_names_for_file(&entry)
2074            .expect("cyclic imports still resolve to known exports");
2075        assert!(imported.contains("b_fn"));
2076    }
2077}