Skip to main content

harn_modules/
lib.rs

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