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#[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#[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#[derive(Debug, Clone)]
44pub enum WildcardResolution {
45 Resolved(HashSet<String>),
47 Unknown,
49}
50
51#[derive(Debug, Default)]
53pub struct ModuleGraph {
54 modules: HashMap<PathBuf, ModuleInfo>,
55 _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 declarations: HashMap<String, DefSite>,
76 exports: HashSet<String>,
81 own_exports: HashSet<String>,
84 selective_re_exports: HashMap<String, Vec<PathBuf>>,
91 wildcard_re_export_paths: Vec<PathBuf>,
95 selective_import_names: HashSet<String>,
97 imports: Vec<ImportRef>,
99 has_unresolved_wildcard_import: bool,
101 has_unresolved_selective_import: bool,
105 type_declarations: Vec<SNode>,
108 callable_declarations: Vec<SNode>,
111 load_error: Option<ModuleLoadError>,
117}
118
119#[derive(Debug, Clone)]
125pub struct ModuleLoadError {
126 pub message: String,
128 pub span: Span,
130}
131
132#[derive(Debug, Clone)]
135pub struct ImportCompileFailure {
136 pub import_raw_path: String,
138 pub import_span: Span,
140 pub module_path: PathBuf,
142 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#[derive(Debug, Clone, PartialEq, Eq)]
156pub struct ModuleImport {
157 pub raw_path: String,
159 pub resolved_path: Option<PathBuf>,
161 pub selective_names: Option<Vec<String>>,
163}
164
165pub 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
177pub fn build(files: &[PathBuf]) -> ModuleGraph {
183 build_inner(files, None).graph
184}
185
186pub 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 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 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
267pub const MODULE_GRAPH_JOBS_ENV: &str = "HARN_MODULE_GRAPH_JOBS";
270
271fn 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
325fn 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 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 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 pub fn contains_module(&self, path: &Path) -> bool {
378 self.modules.contains_key(path) || self.modules.contains_key(&normalize_path(path))
379 }
380
381 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 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 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 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 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 #[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 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 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 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 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 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 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 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 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 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 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 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 for source in ¤t.wildcard_re_export_paths {
814 if let Some(def) = self.definition_of_inner(source, name, visited) {
815 return Some(def);
816 }
817 }
818
819 for import in ¤t.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 for import in ¤t.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 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 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 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 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 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#[derive(Debug, Clone, PartialEq, Eq)]
963pub struct ReExportConflict {
964 pub name: String,
965 pub sources: Vec<PathBuf>,
966}
967
968#[derive(Debug, Clone, PartialEq, Eq)]
971pub struct NonExportedImport {
972 pub name: String,
974 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 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
1031fn 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, ¶m_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 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
1270pub 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 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 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 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 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 assert!(imported.contains("mid_fn"));
1486 assert!(!imported.contains("leaf_fn"));
1487
1488 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 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 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 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 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 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 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}