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