Skip to main content

shape_vm/
bundle_compiler.rs

1//! Bundle compiler for producing distributable .shapec packages
2//!
3//! Takes a ProjectRoot and compiles all .shape files into a PackageBundle.
4
5use crate::bytecode;
6use crate::compiler::BytecodeCompiler;
7use crate::module_resolution::annotate_program_native_abi_package_key;
8use sha2::{Digest, Sha256};
9use shape_ast::parser::parse_program;
10use shape_runtime::module_manifest::ModuleManifest;
11use shape_runtime::package_bundle::{
12    BundleMetadata, BundledModule, BundledNativeDependencyScope, PackageBundle,
13};
14use shape_runtime::project::ProjectRoot;
15use std::collections::{HashMap, HashSet, VecDeque};
16use std::path::{Path, PathBuf};
17use std::time::SystemTime;
18
19/// Compiles an entire Shape project into a PackageBundle.
20pub struct BundleCompiler;
21
22impl BundleCompiler {
23    /// Compile all .shape files in a project to a PackageBundle.
24    pub fn compile(project: &ProjectRoot) -> Result<PackageBundle, String> {
25        let root = &project.root_path;
26
27        // 1. Discover all .shape files
28        let shape_files = discover_shape_files(root, project)?;
29
30        if shape_files.is_empty() {
31            return Err("No .shape files found in project".to_string());
32        }
33
34        // 2. Compile each file
35        let mut modules = Vec::new();
36        let mut all_sources = String::new();
37        let mut docs: HashMap<String, Vec<shape_runtime::doc_extract::DocItem>> = HashMap::new();
38        // Collect content-addressed programs alongside modules (avoids deserialize roundtrip)
39        let mut compiled_programs: Vec<(String, Vec<String>, Option<bytecode::Program>)> =
40            Vec::new();
41
42        let mut loader = shape_runtime::module_loader::ModuleLoader::new();
43        loader.set_project_root(root, &project.resolved_module_paths());
44        let dependency_paths: HashMap<String, PathBuf> = project
45            .config
46            .dependencies
47            .iter()
48            .filter_map(|(name, spec)| match spec {
49                shape_runtime::project::DependencySpec::Detailed(detail) => {
50                    detail.path.as_ref().map(|path| {
51                        let dep_path = root.join(path);
52                        let canonical = dep_path.canonicalize().unwrap_or(dep_path);
53                        (name.clone(), canonical)
54                    })
55                }
56                _ => None,
57            })
58            .collect();
59        if !dependency_paths.is_empty() {
60            loader.set_dependency_paths(dependency_paths);
61        }
62        let known_bindings = Vec::new();
63        let native_resolution_context =
64            shape_runtime::native_resolution::resolve_native_dependencies_for_project(
65                project,
66                &root.join("shape.lock"),
67                project.config.build.external.mode,
68            )
69            .map_err(|e| format!("Failed to resolve native dependencies for bundle: {}", e))?;
70        let root_package_key =
71            shape_runtime::project::normalize_package_identity(root, &project.config).2;
72
73        for (file_path, module_path) in &shape_files {
74            let source = std::fs::read_to_string(file_path)
75                .map_err(|e| format!("Failed to read '{}': {}", file_path.display(), e))?;
76
77            // Hash individual source
78            let mut hasher = Sha256::new();
79            hasher.update(source.as_bytes());
80            let source_hash = format!("{:x}", hasher.finalize());
81
82            // Accumulate for combined hash
83            all_sources.push_str(&source);
84
85            // Parse
86            let mut ast = parse_program(&source)
87                .map_err(|e| format!("Failed to parse '{}': {}", file_path.display(), e))?;
88            annotate_program_native_abi_package_key(&mut ast, Some(root_package_key.as_str()));
89
90            // Extract documentation from source + AST (must use original AST)
91            let module_docs = shape_runtime::doc_extract::extract_docs_from_ast(&source, &ast);
92            if !module_docs.is_empty() {
93                docs.insert(module_path.clone(), module_docs);
94            }
95
96            // Collect export names from AST (must use original AST)
97            let export_names = collect_export_names(&ast);
98
99            // Build module graph and compile via graph pipeline
100            let (graph, stdlib_names, prelude_imports) =
101                crate::module_resolution::build_graph_and_stdlib_names(&ast, &mut loader, &[])
102                    .map_err(|e| {
103                        format!(
104                            "Failed to build module graph for '{}': {}",
105                            file_path.display(),
106                            e
107                        )
108                    })?;
109
110            let mut compiler = BytecodeCompiler::new();
111            compiler.stdlib_function_names = stdlib_names;
112            compiler.register_known_bindings(&known_bindings);
113            compiler.native_resolution_context = Some(native_resolution_context.clone());
114            compiler.set_source_dir(root.clone());
115            let bytecode = compiler
116                .compile_with_graph_and_prelude(&ast, graph, &prelude_imports)
117                .map_err(|e| format!("Failed to compile '{}': {}", file_path.display(), e))?;
118
119            // Extract content-addressed program BEFORE serializing (avoid roundtrip)
120            let content_addressed = bytecode.content_addressed.clone();
121
122            // Serialize bytecode to MessagePack
123            let bytecode_bytes = rmp_serde::to_vec(&bytecode).map_err(|e| {
124                format!(
125                    "Failed to serialize bytecode for '{}': {}",
126                    file_path.display(),
127                    e
128                )
129            })?;
130
131            compiled_programs.push((module_path.clone(), export_names.clone(), content_addressed));
132
133            modules.push(BundledModule {
134                module_path: module_path.clone(),
135                bytecode_bytes,
136                export_names,
137                source_hash,
138            });
139        }
140
141        // 3. Compute combined source hash
142        let mut hasher = Sha256::new();
143        hasher.update(all_sources.as_bytes());
144        let source_hash = format!("{:x}", hasher.finalize());
145
146        // 4. Collect dependency versions
147        let mut dependencies = HashMap::new();
148        for (name, spec) in &project.config.dependencies {
149            let version = match spec {
150                shape_runtime::project::DependencySpec::Version(v) => v.clone(),
151                shape_runtime::project::DependencySpec::Detailed(d) => {
152                    d.version.clone().unwrap_or_else(|| "local".to_string())
153                }
154            };
155            dependencies.insert(name.clone(), version);
156        }
157
158        let native_dependency_scopes = collect_native_dependency_scopes(root, &project.config)
159            .map_err(|e| {
160                format!(
161                    "Failed to collect transitive native dependency scopes for bundle: {}",
162                    e
163                )
164            })?;
165        let native_portable = native_dependency_scopes
166            .iter()
167            .all(native_dependency_scope_is_portable);
168
169        // 5. Read README.md if present
170        let readme = ["README.md", "readme.md", "Readme.md"]
171            .iter()
172            .map(|name| root.join(name))
173            .find(|p| p.is_file())
174            .and_then(|p| std::fs::read_to_string(p).ok());
175
176        // 6. Build metadata
177        let built_at = SystemTime::now()
178            .duration_since(SystemTime::UNIX_EPOCH)
179            .map(|d| d.as_secs())
180            .unwrap_or(0);
181
182        let metadata = BundleMetadata {
183            name: project.config.project.name.clone(),
184            version: project.config.project.version.clone(),
185            compiler_version: env!("CARGO_PKG_VERSION").to_string(),
186            source_hash,
187            bundle_kind: "portable-bytecode".to_string(),
188            build_host: format!("{}-{}", std::env::consts::ARCH, std::env::consts::OS),
189            native_portable,
190            entry_module: project
191                .config
192                .project
193                .entry
194                .as_ref()
195                .map(|e| path_to_module_path(Path::new(e), root)),
196            built_at,
197            readme,
198        };
199
200        // 7. Extract content-addressed blobs and build manifests (from in-memory programs)
201        let mut blob_store: HashMap<[u8; 32], Vec<u8>> = HashMap::new();
202        let mut manifests: Vec<ModuleManifest> = Vec::new();
203
204        for (module_path, export_names, content_addressed) in &compiled_programs {
205            if let Some(ca) = content_addressed {
206                // Extract blobs into blob_store
207                for (hash, blob) in &ca.function_store {
208                    if let Ok(blob_bytes) = rmp_serde::to_vec(blob) {
209                        blob_store.insert(hash.0, blob_bytes);
210                    }
211                }
212
213                // Build manifest for this module
214                let mut manifest =
215                    ModuleManifest::new(module_path.clone(), metadata.version.clone());
216
217                // Map export names to their function hashes
218                for export_name in export_names {
219                    for (hash, blob) in &ca.function_store {
220                        if blob.name == *export_name {
221                            manifest.add_export(export_name.clone(), hash.0);
222                            break;
223                        }
224                    }
225                }
226
227                // Collect type schemas referenced by function blobs
228                let mut seen_schemas = std::collections::HashSet::new();
229                for (_hash, blob) in &ca.function_store {
230                    for schema_name in &blob.type_schemas {
231                        if seen_schemas.insert(schema_name.clone()) {
232                            let schema_hash = Sha256::digest(schema_name.as_bytes());
233                            let mut hash_bytes = [0u8; 32];
234                            hash_bytes.copy_from_slice(&schema_hash);
235                            manifest.add_type_schema(schema_name.clone(), hash_bytes);
236                        }
237                    }
238                }
239
240                // Build transitive dependency closure for each export
241                for (_export_name, export_hash) in &manifest.exports {
242                    let mut closure = Vec::new();
243                    let mut visited = std::collections::HashSet::new();
244                    let mut queue = vec![*export_hash];
245                    while let Some(h) = queue.pop() {
246                        if !visited.insert(h) {
247                            continue;
248                        }
249                        if let Some(blob) = ca.function_store.get(&crate::bytecode::FunctionHash(h))
250                        {
251                            for dep in &blob.dependencies {
252                                closure.push(dep.0);
253                                queue.push(dep.0);
254                            }
255                        }
256                    }
257                    closure.sort();
258                    closure.dedup();
259                    manifest.dependency_closure.insert(*export_hash, closure);
260                }
261
262                manifest.finalize();
263                manifests.push(manifest);
264            }
265        }
266
267        Ok(PackageBundle {
268            metadata,
269            modules,
270            dependencies,
271            blob_store,
272            manifests,
273            native_dependency_scopes,
274            docs,
275        })
276    }
277}
278
279fn merge_native_scope(
280    scopes: &mut HashMap<String, BundledNativeDependencyScope>,
281    scope: BundledNativeDependencyScope,
282) {
283    if let Some(existing) = scopes.get_mut(&scope.package_key) {
284        existing.dependencies.extend(scope.dependencies);
285        return;
286    }
287    scopes.insert(scope.package_key.clone(), scope);
288}
289
290fn collect_native_dependency_scopes(
291    root_path: &Path,
292    project: &shape_runtime::project::ShapeProject,
293) -> Result<Vec<BundledNativeDependencyScope>, String> {
294    let (root_name, root_version, root_key) =
295        shape_runtime::project::normalize_package_identity(root_path, project);
296
297    let mut queue: VecDeque<(
298        PathBuf,
299        shape_runtime::project::ShapeProject,
300        String,
301        String,
302        String,
303    )> = VecDeque::new();
304    queue.push_back((
305        root_path.to_path_buf(),
306        project.clone(),
307        root_name,
308        root_version,
309        root_key,
310    ));
311
312    let mut scopes_by_key: HashMap<String, BundledNativeDependencyScope> = HashMap::new();
313    let mut visited_roots: HashSet<PathBuf> = HashSet::new();
314
315    while let Some((package_root, package, package_name, package_version, package_key)) =
316        queue.pop_front()
317    {
318        let canonical_root = package_root
319            .canonicalize()
320            .unwrap_or_else(|_| package_root.clone());
321        if !visited_roots.insert(canonical_root.clone()) {
322            continue;
323        }
324
325        let native_deps = package.native_dependencies().map_err(|e| {
326            format!(
327                "invalid [native-dependencies] in package '{}': {}",
328                package_name, e
329            )
330        })?;
331        if !native_deps.is_empty() {
332            merge_native_scope(
333                &mut scopes_by_key,
334                BundledNativeDependencyScope {
335                    package_name: package_name.clone(),
336                    package_version: package_version.clone(),
337                    package_key: package_key.clone(),
338                    dependencies: native_deps,
339                },
340            );
341        }
342
343        if package.dependencies.is_empty() {
344            continue;
345        }
346
347        let Some(resolver) =
348            shape_runtime::dependency_resolver::DependencyResolver::new(canonical_root.clone())
349        else {
350            continue;
351        };
352        let resolved = resolver.resolve(&package.dependencies).map_err(|e| {
353            format!(
354                "failed to resolve dependencies for package '{}': {}",
355                package_name, e
356            )
357        })?;
358
359        for resolved_dep in resolved {
360            if resolved_dep
361                .path
362                .extension()
363                .is_some_and(|ext| ext == "shapec")
364            {
365                let bundle = shape_runtime::package_bundle::PackageBundle::read_from_file(
366                    &resolved_dep.path,
367                )
368                .map_err(|e| {
369                    format!(
370                        "failed to read dependency bundle '{}': {}",
371                        resolved_dep.path.display(),
372                        e
373                    )
374                })?;
375                for scope in bundle.native_dependency_scopes {
376                    merge_native_scope(&mut scopes_by_key, scope);
377                }
378                continue;
379            }
380
381            let dep_root = resolved_dep.path;
382            let dep_toml = dep_root.join("shape.toml");
383            let dep_source = match std::fs::read_to_string(&dep_toml) {
384                Ok(content) => content,
385                Err(_) => continue,
386            };
387            let dep_project = shape_runtime::project::parse_shape_project_toml(&dep_source)
388                .map_err(|err| {
389                    format!(
390                        "failed to parse dependency project '{}': {}",
391                        dep_toml.display(),
392                        err
393                    )
394                })?;
395            let (dep_name, dep_version, dep_key) =
396                shape_runtime::project::normalize_package_identity_with_fallback(
397                    &dep_root,
398                    &dep_project,
399                    &resolved_dep.name,
400                    &resolved_dep.version,
401                );
402            queue.push_back((dep_root, dep_project, dep_name, dep_version, dep_key));
403        }
404    }
405
406    let mut scopes: Vec<_> = scopes_by_key.into_values().collect();
407    scopes.sort_by(|a, b| a.package_key.cmp(&b.package_key));
408    Ok(scopes)
409}
410
411fn native_spec_is_portable(spec: &shape_runtime::project::NativeDependencySpec) -> bool {
412    use shape_runtime::project::{NativeDependencyProvider, NativeDependencySpec};
413
414    match spec {
415        NativeDependencySpec::Simple(value) => !is_path_like_native_spec(value),
416        NativeDependencySpec::Detailed(detail) => {
417            if matches!(
418                spec.provider_for_host(),
419                NativeDependencyProvider::Path | NativeDependencyProvider::Vendored
420            ) {
421                return false;
422            }
423            for target in detail.targets.values() {
424                if target
425                    .resolve()
426                    .as_deref()
427                    .is_some_and(is_path_like_native_spec)
428                {
429                    return false;
430                }
431            }
432            for value in [&detail.path, &detail.linux, &detail.macos, &detail.windows] {
433                if value.as_deref().is_some_and(is_path_like_native_spec) {
434                    return false;
435                }
436            }
437            true
438        }
439    }
440}
441
442fn native_dependency_scope_is_portable(scope: &BundledNativeDependencyScope) -> bool {
443    scope.dependencies.values().all(native_spec_is_portable)
444}
445
446fn is_path_like_native_spec(spec: &str) -> bool {
447    let path = Path::new(spec);
448    path.is_absolute()
449        || spec.starts_with("./")
450        || spec.starts_with("../")
451        || spec.contains('/')
452        || spec.contains('\\')
453        || (spec.len() >= 2 && spec.as_bytes()[1] == b':')
454}
455
456/// Discover all .shape files in the project, returning (file_path, module_path) pairs.
457fn discover_shape_files(
458    root: &Path,
459    project: &ProjectRoot,
460) -> Result<Vec<(PathBuf, String)>, String> {
461    let mut files = Vec::new();
462
463    // Search in project root
464    collect_shape_files(root, root, &mut files)?;
465
466    // Search in configured module paths
467    for module_path in project.resolved_module_paths() {
468        if module_path.exists() && module_path.is_dir() {
469            collect_shape_files(&module_path, &module_path, &mut files)?;
470        }
471    }
472
473    // Deduplicate by file path
474    files.sort_by(|a, b| a.0.cmp(&b.0));
475    files.dedup_by(|a, b| a.0 == b.0);
476
477    Ok(files)
478}
479
480/// Recursively collect .shape files from a directory.
481fn collect_shape_files(
482    dir: &Path,
483    base: &Path,
484    files: &mut Vec<(PathBuf, String)>,
485) -> Result<(), String> {
486    let entries = std::fs::read_dir(dir)
487        .map_err(|e| format!("Failed to read directory '{}': {}", dir.display(), e))?;
488
489    for entry in entries {
490        let entry = entry.map_err(|e| format!("Failed to read dir entry: {}", e))?;
491        let path = entry.path();
492        let file_name = entry.file_name().to_string_lossy().to_string();
493
494        // Skip hidden dirs and common non-source dirs
495        if file_name.starts_with('.') || file_name == "target" || file_name == "node_modules" {
496            continue;
497        }
498
499        if path.is_dir() {
500            collect_shape_files(&path, base, files)?;
501        } else if path.extension().and_then(|e| e.to_str()) == Some("shape") {
502            let module_path = path_to_module_path(&path, base);
503            files.push((path, module_path));
504        }
505    }
506
507    Ok(())
508}
509
510/// Convert a file path to a module path using :: separator.
511///
512/// Examples:
513/// - `src/main.shape` -> `src::main`
514/// - `utils/helpers.shape` -> `utils::helpers`
515/// - `utils/index.shape` -> `utils`
516fn path_to_module_path(path: &Path, base: &Path) -> String {
517    let relative = path.strip_prefix(base).unwrap_or(path);
518
519    let without_ext = relative.with_extension("");
520    let parts: Vec<&str> = without_ext
521        .components()
522        .filter_map(|c| match c {
523            std::path::Component::Normal(s) => s.to_str(),
524            _ => None,
525        })
526        .collect();
527
528    // If the last component is "index", drop it (index.shape -> parent name)
529    if parts.last() == Some(&"index") && parts.len() > 1 {
530        parts[..parts.len() - 1].join("::")
531    } else if parts.last() == Some(&"index") {
532        // Root index.shape
533        String::new()
534    } else {
535        parts.join("::")
536    }
537}
538
539/// Collect export names from a parsed AST.
540fn collect_export_names(program: &shape_ast::ast::Program) -> Vec<String> {
541    let mut names = Vec::new();
542
543    for item in &program.items {
544        match item {
545            shape_ast::ast::Item::Export(export, _) => match &export.item {
546                shape_ast::ast::ExportItem::Function(func) => {
547                    names.push(func.name.clone());
548                }
549                shape_ast::ast::ExportItem::BuiltinFunction(func) => {
550                    names.push(func.name.clone());
551                }
552                shape_ast::ast::ExportItem::BuiltinType(ty) => {
553                    names.push(ty.name.clone());
554                }
555                shape_ast::ast::ExportItem::Named(specs) => {
556                    for spec in specs {
557                        names.push(spec.alias.clone().unwrap_or_else(|| spec.name.clone()));
558                    }
559                }
560                shape_ast::ast::ExportItem::TypeAlias(alias) => {
561                    names.push(alias.name.clone());
562                }
563                shape_ast::ast::ExportItem::Enum(e) => {
564                    names.push(e.name.clone());
565                }
566                shape_ast::ast::ExportItem::Struct(s) => {
567                    names.push(s.name.clone());
568                }
569                shape_ast::ast::ExportItem::Interface(i) => {
570                    names.push(i.name.clone());
571                }
572                shape_ast::ast::ExportItem::Trait(t) => {
573                    names.push(t.name.clone());
574                }
575                shape_ast::ast::ExportItem::Annotation(annotation) => {
576                    names.push(annotation.name.clone());
577                }
578                shape_ast::ast::ExportItem::ForeignFunction(f) => {
579                    names.push(f.name.clone());
580                }
581            },
582            _ => {}
583        }
584    }
585
586    names.sort();
587    names.dedup();
588    names
589}
590
591#[cfg(test)]
592mod tests {
593    use super::*;
594
595    fn discover_system_library_alias() -> Option<String> {
596        let candidates = [
597            "libm.so.6",
598            "libc.so.6",
599            "libSystem.B.dylib",
600            "kernel32.dll",
601            "ucrtbase.dll",
602        ];
603        for candidate in candidates {
604            if unsafe { libloading::Library::new(candidate) }.is_ok() {
605                return Some(candidate.to_string());
606            }
607        }
608        None
609    }
610
611    #[test]
612    fn test_path_to_module_path_basic() {
613        let base = Path::new("/project");
614        assert_eq!(
615            path_to_module_path(Path::new("/project/main.shape"), base),
616            "main"
617        );
618        assert_eq!(
619            path_to_module_path(Path::new("/project/utils/helpers.shape"), base),
620            "utils::helpers"
621        );
622    }
623
624    #[test]
625    fn test_path_to_module_path_index() {
626        let base = Path::new("/project");
627        assert_eq!(
628            path_to_module_path(Path::new("/project/utils/index.shape"), base),
629            "utils"
630        );
631        assert_eq!(
632            path_to_module_path(Path::new("/project/index.shape"), base),
633            ""
634        );
635    }
636
637    #[test]
638    fn test_compile_temp_project() {
639        let tmp = tempfile::tempdir().expect("temp dir");
640        let root = tmp.path();
641
642        // Create shape.toml
643        std::fs::write(
644            root.join("shape.toml"),
645            r#"
646[project]
647name = "test-bundle"
648version = "0.1.0"
649"#,
650        )
651        .expect("write shape.toml");
652
653        // Create source files
654        std::fs::write(root.join("main.shape"), "pub fn run() { 42 }").expect("write main");
655        std::fs::create_dir_all(root.join("utils")).expect("create utils dir");
656        std::fs::write(root.join("utils/helpers.shape"), "pub fn helper() { 1 }")
657            .expect("write helpers");
658
659        let project =
660            shape_runtime::project::find_project_root(root).expect("should find project root");
661
662        let bundle = BundleCompiler::compile(&project).expect("compilation should succeed");
663
664        assert_eq!(bundle.metadata.name, "test-bundle");
665        assert_eq!(bundle.metadata.version, "0.1.0");
666        assert!(
667            bundle.modules.len() >= 2,
668            "should have at least 2 modules, got {}",
669            bundle.modules.len()
670        );
671
672        let main_mod = bundle.modules.iter().find(|m| m.module_path == "main");
673        assert!(main_mod.is_some(), "should have main module");
674
675        let helpers_mod = bundle
676            .modules
677            .iter()
678            .find(|m| m.module_path == "utils::helpers");
679        assert!(helpers_mod.is_some(), "should have utils::helpers module");
680    }
681
682    #[test]
683    fn test_compile_with_stdlib_imports() {
684        let tmp = tempfile::tempdir().expect("temp dir");
685        let root = tmp.path();
686
687        std::fs::write(
688            root.join("shape.toml"),
689            r#"
690[project]
691name = "test-stdlib-imports"
692version = "0.1.0"
693"#,
694        )
695        .expect("write shape.toml");
696
697        // Source file that uses stdlib imports — this previously failed because
698        // BundleCompiler didn't resolve imports before compilation.
699        std::fs::write(
700            root.join("main.shape"),
701            r#"
702from std::core::native use { ptr_new_cell }
703
704pub fn make_cell() {
705    let cell = ptr_new_cell()
706    cell
707}
708"#,
709        )
710        .expect("write main.shape");
711
712        let project =
713            shape_runtime::project::find_project_root(root).expect("should find project root");
714
715        let bundle = BundleCompiler::compile(&project)
716            .expect("compilation with stdlib imports should succeed");
717
718        assert_eq!(bundle.metadata.name, "test-stdlib-imports");
719        let main_mod = bundle.modules.iter().find(|m| m.module_path == "main");
720        assert!(main_mod.is_some(), "should have main module");
721    }
722
723    #[test]
724    fn test_compile_embeds_transitive_native_scopes_from_shapec_dependencies() {
725        let Some(alias) = discover_system_library_alias() else {
726            // Host test image does not expose a known system alias.
727            return;
728        };
729
730        let tmp = tempfile::tempdir().expect("temp dir");
731        let leaf_dir = tmp.path().join("leaf");
732        let mid_dir = tmp.path().join("mid");
733        std::fs::create_dir_all(&leaf_dir).expect("create leaf dir");
734        std::fs::create_dir_all(&mid_dir).expect("create mid dir");
735
736        std::fs::write(
737            leaf_dir.join("shape.toml"),
738            format!(
739                r#"
740[project]
741name = "leaf"
742version = "1.2.3"
743
744[native-dependencies]
745duckdb = {{ provider = "system", version = "1.0.0", linux = "{alias}", macos = "{alias}", windows = "{alias}" }}
746"#
747            ),
748        )
749        .expect("write leaf shape.toml");
750        std::fs::write(leaf_dir.join("main.shape"), "pub fn leaf_marker() { 1 }")
751            .expect("write leaf source");
752
753        let leaf_project = shape_runtime::project::find_project_root(&leaf_dir)
754            .expect("leaf project root should resolve");
755        let leaf_bundle = BundleCompiler::compile(&leaf_project).expect("compile leaf bundle");
756        let leaf_bundle_path = tmp.path().join("leaf.shapec");
757        leaf_bundle
758            .write_to_file(&leaf_bundle_path)
759            .expect("write leaf bundle");
760        assert!(
761            leaf_bundle
762                .native_dependency_scopes
763                .iter()
764                .any(|scope| scope.package_key == "leaf@1.2.3"
765                    && scope.dependencies.contains_key("duckdb")),
766            "leaf bundle should embed its native dependency scope"
767        );
768
769        std::fs::write(
770            mid_dir.join("shape.toml"),
771            r#"
772[project]
773name = "mid"
774version = "0.4.0"
775
776[dependencies]
777leaf = { path = "../leaf.shapec" }
778"#,
779        )
780        .expect("write mid shape.toml");
781        std::fs::write(mid_dir.join("main.shape"), "pub fn mid_marker() { 2 }")
782            .expect("write mid source");
783
784        let mid_project =
785            shape_runtime::project::find_project_root(&mid_dir).expect("mid project root");
786        let mid_bundle = BundleCompiler::compile(&mid_project).expect("compile mid bundle");
787
788        assert!(
789            mid_bundle
790                .native_dependency_scopes
791                .iter()
792                .any(|scope| scope.package_key == "leaf@1.2.3"
793                    && scope.dependencies.contains_key("duckdb")),
794            "mid bundle should preserve transitive native scopes from leaf.shapec"
795        );
796    }
797
798    #[test]
799    fn test_bundle_submodule_imports() {
800        // MED-24: Verify that bundling resolves submodule imports correctly.
801        let tmp = tempfile::tempdir().expect("temp dir");
802        let root = tmp.path();
803
804        std::fs::write(
805            root.join("shape.toml"),
806            r#"
807[project]
808name = "test-submod-imports"
809version = "0.1.0"
810"#,
811        )
812        .expect("write shape.toml");
813
814        std::fs::create_dir_all(root.join("utils")).expect("create utils dir");
815        std::fs::write(
816            root.join("utils/helpers.shape"),
817            "pub fn helper_val() -> int { 42 }",
818        )
819        .expect("write helpers");
820
821        std::fs::write(
822            root.join("main.shape"),
823            r#"
824from utils::helpers use { helper_val }
825
826pub fn run() -> int {
827    helper_val()
828}
829"#,
830        )
831        .expect("write main");
832
833        let project =
834            shape_runtime::project::find_project_root(root).expect("should find project root");
835        let bundle = BundleCompiler::compile(&project)
836            .expect("bundle with submodule imports should compile");
837        assert!(
838            bundle.modules.iter().any(|m| m.module_path == "main"),
839            "should have main module"
840        );
841    }
842
843    #[test]
844    fn test_bundle_chained_submodule_imports() {
845        // MED-24: Chained imports (main -> utils::math -> utils::constants).
846        let tmp = tempfile::tempdir().expect("temp dir");
847        let root = tmp.path();
848
849        std::fs::write(
850            root.join("shape.toml"),
851            r#"
852[project]
853name = "test-chained-imports"
854version = "0.1.0"
855"#,
856        )
857        .expect("write shape.toml");
858
859        std::fs::create_dir_all(root.join("utils")).expect("create utils dir");
860        std::fs::write(
861            root.join("utils/constants.shape"),
862            "pub fn pi() -> number { 3.14159 }",
863        )
864        .expect("write constants");
865
866        std::fs::write(
867            root.join("utils/math.shape"),
868            r#"
869from utils::constants use { pi }
870
871pub fn circle_area(r: number) -> number {
872    pi() * r * r
873}
874"#,
875        )
876        .expect("write math");
877
878        std::fs::write(
879            root.join("main.shape"),
880            r#"
881from utils::math use { circle_area }
882
883pub fn run() -> number {
884    circle_area(2.0)
885}
886"#,
887        )
888        .expect("write main");
889
890        let project =
891            shape_runtime::project::find_project_root(root).expect("should find project root");
892        let bundle =
893            BundleCompiler::compile(&project).expect("bundle with chained imports should compile");
894        assert!(
895            bundle.modules.iter().any(|m| m.module_path == "main"),
896            "should have main module"
897        );
898    }
899
900    #[test]
901    fn test_bundle_submodule_imports_with_shared_dependency() {
902        // MED-24: Two submodules import different names from the same module.
903        // Before the fix, the second import was silently skipped because
904        // `seen_paths` prevented re-processing the shared dependency.
905        let tmp = tempfile::tempdir().expect("temp dir");
906        let root = tmp.path();
907
908        std::fs::write(
909            root.join("shape.toml"),
910            r#"
911[project]
912name = "test-shared-dep"
913version = "0.1.0"
914"#,
915        )
916        .expect("write shape.toml");
917
918        std::fs::create_dir_all(root.join("lib")).expect("create lib dir");
919        std::fs::write(
920            root.join("lib/constants.shape"),
921            r#"
922pub fn pi() -> number { 3.14159 }
923pub fn e() -> number { 2.71828 }
924"#,
925        )
926        .expect("write constants");
927
928        std::fs::write(
929            root.join("lib/math.shape"),
930            r#"
931from lib::constants use { pi }
932
933pub fn circle_area(r: number) -> number {
934    pi() * r * r
935}
936"#,
937        )
938        .expect("write math");
939
940        std::fs::write(
941            root.join("lib/format.shape"),
942            r#"
943from lib::constants use { e }
944
945pub fn euler() -> number {
946    e()
947}
948"#,
949        )
950        .expect("write format");
951
952        std::fs::write(
953            root.join("main.shape"),
954            r#"
955from lib::math use { circle_area }
956from lib::format use { euler }
957
958pub fn run() -> number {
959    circle_area(1.0) + euler()
960}
961"#,
962        )
963        .expect("write main");
964
965        let project =
966            shape_runtime::project::find_project_root(root).expect("should find project root");
967        let bundle = BundleCompiler::compile(&project)
968            .expect("bundle with shared dependency should compile");
969        assert!(
970            bundle.modules.iter().any(|m| m.module_path == "main"),
971            "should have main module"
972        );
973    }
974}