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, should_include_item};
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 = crate::stdlib::core_binding_names();
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            // Inject stdlib prelude items
100            let mut stdlib_names = crate::module_resolution::prepend_prelude_items(&mut ast);
101
102            // Resolve explicit imports via ModuleLoader
103            stdlib_names.extend(resolve_and_inline_imports(&mut ast, &mut loader));
104
105            // Compile to bytecode with known bindings
106            let mut compiler = BytecodeCompiler::new();
107            compiler.stdlib_function_names = stdlib_names;
108            compiler.register_known_bindings(&known_bindings);
109            compiler.native_resolution_context = Some(native_resolution_context.clone());
110            compiler.set_source_dir(root.clone());
111            let bytecode = compiler
112                .compile(&ast)
113                .map_err(|e| format!("Failed to compile '{}': {}", file_path.display(), e))?;
114
115            // Extract content-addressed program BEFORE serializing (avoid roundtrip)
116            let content_addressed = bytecode.content_addressed.clone();
117
118            // Serialize bytecode to MessagePack
119            let bytecode_bytes = rmp_serde::to_vec(&bytecode).map_err(|e| {
120                format!(
121                    "Failed to serialize bytecode for '{}': {}",
122                    file_path.display(),
123                    e
124                )
125            })?;
126
127            compiled_programs.push((module_path.clone(), export_names.clone(), content_addressed));
128
129            modules.push(BundledModule {
130                module_path: module_path.clone(),
131                bytecode_bytes,
132                export_names,
133                source_hash,
134            });
135        }
136
137        // 3. Compute combined source hash
138        let mut hasher = Sha256::new();
139        hasher.update(all_sources.as_bytes());
140        let source_hash = format!("{:x}", hasher.finalize());
141
142        // 4. Collect dependency versions
143        let mut dependencies = HashMap::new();
144        for (name, spec) in &project.config.dependencies {
145            let version = match spec {
146                shape_runtime::project::DependencySpec::Version(v) => v.clone(),
147                shape_runtime::project::DependencySpec::Detailed(d) => {
148                    d.version.clone().unwrap_or_else(|| "local".to_string())
149                }
150            };
151            dependencies.insert(name.clone(), version);
152        }
153
154        let native_dependency_scopes = collect_native_dependency_scopes(root, &project.config)
155            .map_err(|e| {
156                format!(
157                    "Failed to collect transitive native dependency scopes for bundle: {}",
158                    e
159                )
160            })?;
161        let native_portable = native_dependency_scopes
162            .iter()
163            .all(native_dependency_scope_is_portable);
164
165        // 5. Read README.md if present
166        let readme = ["README.md", "readme.md", "Readme.md"]
167            .iter()
168            .map(|name| root.join(name))
169            .find(|p| p.is_file())
170            .and_then(|p| std::fs::read_to_string(p).ok());
171
172        // 6. Build metadata
173        let built_at = SystemTime::now()
174            .duration_since(SystemTime::UNIX_EPOCH)
175            .map(|d| d.as_secs())
176            .unwrap_or(0);
177
178        let metadata = BundleMetadata {
179            name: project.config.project.name.clone(),
180            version: project.config.project.version.clone(),
181            compiler_version: env!("CARGO_PKG_VERSION").to_string(),
182            source_hash,
183            bundle_kind: "portable-bytecode".to_string(),
184            build_host: format!("{}-{}", std::env::consts::ARCH, std::env::consts::OS),
185            native_portable,
186            entry_module: project
187                .config
188                .project
189                .entry
190                .as_ref()
191                .map(|e| path_to_module_path(Path::new(e), root)),
192            built_at,
193            readme,
194        };
195
196        // 7. Extract content-addressed blobs and build manifests (from in-memory programs)
197        let mut blob_store: HashMap<[u8; 32], Vec<u8>> = HashMap::new();
198        let mut manifests: Vec<ModuleManifest> = Vec::new();
199
200        for (module_path, export_names, content_addressed) in &compiled_programs {
201            if let Some(ca) = content_addressed {
202                // Extract blobs into blob_store
203                for (hash, blob) in &ca.function_store {
204                    if let Ok(blob_bytes) = rmp_serde::to_vec(blob) {
205                        blob_store.insert(hash.0, blob_bytes);
206                    }
207                }
208
209                // Build manifest for this module
210                let mut manifest =
211                    ModuleManifest::new(module_path.clone(), metadata.version.clone());
212
213                // Map export names to their function hashes
214                for export_name in export_names {
215                    for (hash, blob) in &ca.function_store {
216                        if blob.name == *export_name {
217                            manifest.add_export(export_name.clone(), hash.0);
218                            break;
219                        }
220                    }
221                }
222
223                // Collect type schemas referenced by function blobs
224                let mut seen_schemas = std::collections::HashSet::new();
225                for (_hash, blob) in &ca.function_store {
226                    for schema_name in &blob.type_schemas {
227                        if seen_schemas.insert(schema_name.clone()) {
228                            let schema_hash = Sha256::digest(schema_name.as_bytes());
229                            let mut hash_bytes = [0u8; 32];
230                            hash_bytes.copy_from_slice(&schema_hash);
231                            manifest.add_type_schema(schema_name.clone(), hash_bytes);
232                        }
233                    }
234                }
235
236                // Build transitive dependency closure for each export
237                for (_export_name, export_hash) in &manifest.exports {
238                    let mut closure = Vec::new();
239                    let mut visited = std::collections::HashSet::new();
240                    let mut queue = vec![*export_hash];
241                    while let Some(h) = queue.pop() {
242                        if !visited.insert(h) {
243                            continue;
244                        }
245                        if let Some(blob) = ca.function_store.get(&crate::bytecode::FunctionHash(h))
246                        {
247                            for dep in &blob.dependencies {
248                                closure.push(dep.0);
249                                queue.push(dep.0);
250                            }
251                        }
252                    }
253                    closure.sort();
254                    closure.dedup();
255                    manifest.dependency_closure.insert(*export_hash, closure);
256                }
257
258                manifest.finalize();
259                manifests.push(manifest);
260            }
261        }
262
263        Ok(PackageBundle {
264            metadata,
265            modules,
266            dependencies,
267            blob_store,
268            manifests,
269            native_dependency_scopes,
270            docs,
271        })
272    }
273}
274
275/// Resolve import statements in a program by loading modules and inlining their AST items.
276/// This replicates the logic from `BytecodeExecutor::append_imported_module_items` but
277/// takes a `ModuleLoader` directly, suitable for use outside the executor context.
278fn resolve_and_inline_imports(
279    ast: &mut shape_ast::Program,
280    loader: &mut shape_runtime::module_loader::ModuleLoader,
281) -> std::collections::HashSet<String> {
282    use shape_ast::ast::{ImportItems, Item};
283    let mut seen_paths = std::collections::HashSet::new();
284    let mut stdlib_names = std::collections::HashSet::new();
285
286    loop {
287        let mut module_items = Vec::new();
288        let mut found_new = false;
289
290        for item in &ast.items {
291            let Item::Import(import_stmt, _) = item else {
292                continue;
293            };
294            let module_path = import_stmt.from.as_str();
295            if module_path.is_empty() || !seen_paths.insert(module_path.to_string()) {
296                continue;
297            }
298            found_new = true;
299            let is_std = module_path.starts_with("std::");
300
301            // Load module (errors are non-fatal — module might resolve at runtime)
302            let _ = loader.load_module(module_path);
303
304            let named_filter: Option<std::collections::HashSet<&str>> = match &import_stmt.items {
305                ImportItems::Named(specs) => Some(specs.iter().map(|s| s.name.as_str()).collect()),
306                ImportItems::Namespace { .. } => None,
307            };
308
309            if let Some(module) = loader.get_module(module_path) {
310                let items = module.ast.items.clone();
311                if is_std {
312                    stdlib_names.extend(
313                        crate::module_resolution::collect_function_names_from_items(&items),
314                    );
315                }
316                if let Some(ref names) = named_filter {
317                    for ast_item in items {
318                        if should_include_item(&ast_item, names) {
319                            module_items.push(ast_item);
320                        }
321                    }
322                } else {
323                    module_items.extend(items);
324                }
325            }
326        }
327
328        if !module_items.is_empty() {
329            module_items.extend(std::mem::take(&mut ast.items));
330            ast.items = module_items;
331        }
332
333        if !found_new {
334            break;
335        }
336    }
337
338    stdlib_names
339}
340
341fn merge_native_scope(
342    scopes: &mut HashMap<String, BundledNativeDependencyScope>,
343    scope: BundledNativeDependencyScope,
344) {
345    if let Some(existing) = scopes.get_mut(&scope.package_key) {
346        existing.dependencies.extend(scope.dependencies);
347        return;
348    }
349    scopes.insert(scope.package_key.clone(), scope);
350}
351
352fn collect_native_dependency_scopes(
353    root_path: &Path,
354    project: &shape_runtime::project::ShapeProject,
355) -> Result<Vec<BundledNativeDependencyScope>, String> {
356    let (root_name, root_version, root_key) =
357        shape_runtime::project::normalize_package_identity(root_path, project);
358
359    let mut queue: VecDeque<(
360        PathBuf,
361        shape_runtime::project::ShapeProject,
362        String,
363        String,
364        String,
365    )> = VecDeque::new();
366    queue.push_back((
367        root_path.to_path_buf(),
368        project.clone(),
369        root_name,
370        root_version,
371        root_key,
372    ));
373
374    let mut scopes_by_key: HashMap<String, BundledNativeDependencyScope> = HashMap::new();
375    let mut visited_roots: HashSet<PathBuf> = HashSet::new();
376
377    while let Some((package_root, package, package_name, package_version, package_key)) =
378        queue.pop_front()
379    {
380        let canonical_root = package_root
381            .canonicalize()
382            .unwrap_or_else(|_| package_root.clone());
383        if !visited_roots.insert(canonical_root.clone()) {
384            continue;
385        }
386
387        let native_deps = package.native_dependencies().map_err(|e| {
388            format!(
389                "invalid [native-dependencies] in package '{}': {}",
390                package_name, e
391            )
392        })?;
393        if !native_deps.is_empty() {
394            merge_native_scope(
395                &mut scopes_by_key,
396                BundledNativeDependencyScope {
397                    package_name: package_name.clone(),
398                    package_version: package_version.clone(),
399                    package_key: package_key.clone(),
400                    dependencies: native_deps,
401                },
402            );
403        }
404
405        if package.dependencies.is_empty() {
406            continue;
407        }
408
409        let Some(resolver) =
410            shape_runtime::dependency_resolver::DependencyResolver::new(canonical_root.clone())
411        else {
412            continue;
413        };
414        let resolved = resolver.resolve(&package.dependencies).map_err(|e| {
415            format!(
416                "failed to resolve dependencies for package '{}': {}",
417                package_name, e
418            )
419        })?;
420
421        for resolved_dep in resolved {
422            if resolved_dep
423                .path
424                .extension()
425                .is_some_and(|ext| ext == "shapec")
426            {
427                let bundle = shape_runtime::package_bundle::PackageBundle::read_from_file(
428                    &resolved_dep.path,
429                )
430                .map_err(|e| {
431                    format!(
432                        "failed to read dependency bundle '{}': {}",
433                        resolved_dep.path.display(),
434                        e
435                    )
436                })?;
437                for scope in bundle.native_dependency_scopes {
438                    merge_native_scope(&mut scopes_by_key, scope);
439                }
440                continue;
441            }
442
443            let dep_root = resolved_dep.path;
444            let dep_toml = dep_root.join("shape.toml");
445            let dep_source = match std::fs::read_to_string(&dep_toml) {
446                Ok(content) => content,
447                Err(_) => continue,
448            };
449            let dep_project = shape_runtime::project::parse_shape_project_toml(&dep_source)
450                .map_err(|err| {
451                    format!(
452                        "failed to parse dependency project '{}': {}",
453                        dep_toml.display(),
454                        err
455                    )
456                })?;
457            let (dep_name, dep_version, dep_key) =
458                shape_runtime::project::normalize_package_identity_with_fallback(
459                    &dep_root,
460                    &dep_project,
461                    &resolved_dep.name,
462                    &resolved_dep.version,
463                );
464            queue.push_back((dep_root, dep_project, dep_name, dep_version, dep_key));
465        }
466    }
467
468    let mut scopes: Vec<_> = scopes_by_key.into_values().collect();
469    scopes.sort_by(|a, b| a.package_key.cmp(&b.package_key));
470    Ok(scopes)
471}
472
473fn native_spec_is_portable(spec: &shape_runtime::project::NativeDependencySpec) -> bool {
474    use shape_runtime::project::{NativeDependencyProvider, NativeDependencySpec};
475
476    match spec {
477        NativeDependencySpec::Simple(value) => !is_path_like_native_spec(value),
478        NativeDependencySpec::Detailed(detail) => {
479            if matches!(
480                spec.provider_for_host(),
481                NativeDependencyProvider::Path | NativeDependencyProvider::Vendored
482            ) {
483                return false;
484            }
485            for target in detail.targets.values() {
486                if target
487                    .resolve()
488                    .as_deref()
489                    .is_some_and(is_path_like_native_spec)
490                {
491                    return false;
492                }
493            }
494            for value in [&detail.path, &detail.linux, &detail.macos, &detail.windows] {
495                if value.as_deref().is_some_and(is_path_like_native_spec) {
496                    return false;
497                }
498            }
499            true
500        }
501    }
502}
503
504fn native_dependency_scope_is_portable(scope: &BundledNativeDependencyScope) -> bool {
505    scope.dependencies.values().all(native_spec_is_portable)
506}
507
508fn is_path_like_native_spec(spec: &str) -> bool {
509    let path = Path::new(spec);
510    path.is_absolute()
511        || spec.starts_with("./")
512        || spec.starts_with("../")
513        || spec.contains('/')
514        || spec.contains('\\')
515        || (spec.len() >= 2 && spec.as_bytes()[1] == b':')
516}
517
518/// Discover all .shape files in the project, returning (file_path, module_path) pairs.
519fn discover_shape_files(
520    root: &Path,
521    project: &ProjectRoot,
522) -> Result<Vec<(PathBuf, String)>, String> {
523    let mut files = Vec::new();
524
525    // Search in project root
526    collect_shape_files(root, root, &mut files)?;
527
528    // Search in configured module paths
529    for module_path in project.resolved_module_paths() {
530        if module_path.exists() && module_path.is_dir() {
531            collect_shape_files(&module_path, &module_path, &mut files)?;
532        }
533    }
534
535    // Deduplicate by file path
536    files.sort_by(|a, b| a.0.cmp(&b.0));
537    files.dedup_by(|a, b| a.0 == b.0);
538
539    Ok(files)
540}
541
542/// Recursively collect .shape files from a directory.
543fn collect_shape_files(
544    dir: &Path,
545    base: &Path,
546    files: &mut Vec<(PathBuf, String)>,
547) -> Result<(), String> {
548    let entries = std::fs::read_dir(dir)
549        .map_err(|e| format!("Failed to read directory '{}': {}", dir.display(), e))?;
550
551    for entry in entries {
552        let entry = entry.map_err(|e| format!("Failed to read dir entry: {}", e))?;
553        let path = entry.path();
554        let file_name = entry.file_name().to_string_lossy().to_string();
555
556        // Skip hidden dirs and common non-source dirs
557        if file_name.starts_with('.') || file_name == "target" || file_name == "node_modules" {
558            continue;
559        }
560
561        if path.is_dir() {
562            collect_shape_files(&path, base, files)?;
563        } else if path.extension().and_then(|e| e.to_str()) == Some("shape") {
564            let module_path = path_to_module_path(&path, base);
565            files.push((path, module_path));
566        }
567    }
568
569    Ok(())
570}
571
572/// Convert a file path to a module path using :: separator.
573///
574/// Examples:
575/// - `src/main.shape` -> `src::main`
576/// - `utils/helpers.shape` -> `utils::helpers`
577/// - `utils/index.shape` -> `utils`
578fn path_to_module_path(path: &Path, base: &Path) -> String {
579    let relative = path.strip_prefix(base).unwrap_or(path);
580
581    let without_ext = relative.with_extension("");
582    let parts: Vec<&str> = without_ext
583        .components()
584        .filter_map(|c| match c {
585            std::path::Component::Normal(s) => s.to_str(),
586            _ => None,
587        })
588        .collect();
589
590    // If the last component is "index", drop it (index.shape -> parent name)
591    if parts.last() == Some(&"index") && parts.len() > 1 {
592        parts[..parts.len() - 1].join("::")
593    } else if parts.last() == Some(&"index") {
594        // Root index.shape
595        String::new()
596    } else {
597        parts.join("::")
598    }
599}
600
601/// Collect export names from a parsed AST.
602fn collect_export_names(program: &shape_ast::ast::Program) -> Vec<String> {
603    let mut names = Vec::new();
604
605    for item in &program.items {
606        match item {
607            shape_ast::ast::Item::Export(export, _) => match &export.item {
608                shape_ast::ast::ExportItem::Function(func) => {
609                    names.push(func.name.clone());
610                }
611                shape_ast::ast::ExportItem::Named(specs) => {
612                    for spec in specs {
613                        names.push(spec.alias.clone().unwrap_or_else(|| spec.name.clone()));
614                    }
615                }
616                shape_ast::ast::ExportItem::TypeAlias(alias) => {
617                    names.push(alias.name.clone());
618                }
619                shape_ast::ast::ExportItem::Enum(e) => {
620                    names.push(e.name.clone());
621                }
622                shape_ast::ast::ExportItem::Struct(s) => {
623                    names.push(s.name.clone());
624                }
625                shape_ast::ast::ExportItem::Interface(i) => {
626                    names.push(i.name.clone());
627                }
628                shape_ast::ast::ExportItem::Trait(t) => {
629                    names.push(t.name.clone());
630                }
631                shape_ast::ast::ExportItem::ForeignFunction(f) => {
632                    names.push(f.name.clone());
633                }
634            },
635            _ => {}
636        }
637    }
638
639    names.sort();
640    names.dedup();
641    names
642}
643
644#[cfg(test)]
645mod tests {
646    use super::*;
647
648    fn discover_system_library_alias() -> Option<String> {
649        let candidates = [
650            "libm.so.6",
651            "libc.so.6",
652            "libSystem.B.dylib",
653            "kernel32.dll",
654            "ucrtbase.dll",
655        ];
656        for candidate in candidates {
657            if unsafe { libloading::Library::new(candidate) }.is_ok() {
658                return Some(candidate.to_string());
659            }
660        }
661        None
662    }
663
664    #[test]
665    fn test_path_to_module_path_basic() {
666        let base = Path::new("/project");
667        assert_eq!(
668            path_to_module_path(Path::new("/project/main.shape"), base),
669            "main"
670        );
671        assert_eq!(
672            path_to_module_path(Path::new("/project/utils/helpers.shape"), base),
673            "utils::helpers"
674        );
675    }
676
677    #[test]
678    fn test_path_to_module_path_index() {
679        let base = Path::new("/project");
680        assert_eq!(
681            path_to_module_path(Path::new("/project/utils/index.shape"), base),
682            "utils"
683        );
684        assert_eq!(
685            path_to_module_path(Path::new("/project/index.shape"), base),
686            ""
687        );
688    }
689
690    #[test]
691    fn test_compile_temp_project() {
692        let tmp = tempfile::tempdir().expect("temp dir");
693        let root = tmp.path();
694
695        // Create shape.toml
696        std::fs::write(
697            root.join("shape.toml"),
698            r#"
699[project]
700name = "test-bundle"
701version = "0.1.0"
702"#,
703        )
704        .expect("write shape.toml");
705
706        // Create source files
707        std::fs::write(root.join("main.shape"), "pub fn run() { 42 }").expect("write main");
708        std::fs::create_dir_all(root.join("utils")).expect("create utils dir");
709        std::fs::write(root.join("utils/helpers.shape"), "pub fn helper() { 1 }")
710            .expect("write helpers");
711
712        let project =
713            shape_runtime::project::find_project_root(root).expect("should find project root");
714
715        let bundle = BundleCompiler::compile(&project).expect("compilation should succeed");
716
717        assert_eq!(bundle.metadata.name, "test-bundle");
718        assert_eq!(bundle.metadata.version, "0.1.0");
719        assert!(
720            bundle.modules.len() >= 2,
721            "should have at least 2 modules, got {}",
722            bundle.modules.len()
723        );
724
725        let main_mod = bundle.modules.iter().find(|m| m.module_path == "main");
726        assert!(main_mod.is_some(), "should have main module");
727
728        let helpers_mod = bundle
729            .modules
730            .iter()
731            .find(|m| m.module_path == "utils::helpers");
732        assert!(helpers_mod.is_some(), "should have utils::helpers module");
733    }
734
735    #[test]
736    fn test_compile_with_stdlib_imports() {
737        let tmp = tempfile::tempdir().expect("temp dir");
738        let root = tmp.path();
739
740        std::fs::write(
741            root.join("shape.toml"),
742            r#"
743[project]
744name = "test-stdlib-imports"
745version = "0.1.0"
746"#,
747        )
748        .expect("write shape.toml");
749
750        // Source file that uses stdlib imports — this previously failed because
751        // BundleCompiler didn't resolve imports before compilation.
752        std::fs::write(
753            root.join("main.shape"),
754            r#"
755from std::core::native use { ptr_new_cell }
756
757pub fn make_cell() {
758    let cell = ptr_new_cell()
759    cell
760}
761"#,
762        )
763        .expect("write main.shape");
764
765        let project =
766            shape_runtime::project::find_project_root(root).expect("should find project root");
767
768        let bundle = BundleCompiler::compile(&project)
769            .expect("compilation with stdlib imports should succeed");
770
771        assert_eq!(bundle.metadata.name, "test-stdlib-imports");
772        let main_mod = bundle.modules.iter().find(|m| m.module_path == "main");
773        assert!(main_mod.is_some(), "should have main module");
774    }
775
776    #[test]
777    fn test_compile_embeds_transitive_native_scopes_from_shapec_dependencies() {
778        let Some(alias) = discover_system_library_alias() else {
779            // Host test image does not expose a known system alias.
780            return;
781        };
782
783        let tmp = tempfile::tempdir().expect("temp dir");
784        let leaf_dir = tmp.path().join("leaf");
785        let mid_dir = tmp.path().join("mid");
786        std::fs::create_dir_all(&leaf_dir).expect("create leaf dir");
787        std::fs::create_dir_all(&mid_dir).expect("create mid dir");
788
789        std::fs::write(
790            leaf_dir.join("shape.toml"),
791            format!(
792                r#"
793[project]
794name = "leaf"
795version = "1.2.3"
796
797[native-dependencies]
798duckdb = {{ provider = "system", version = "1.0.0", linux = "{alias}", macos = "{alias}", windows = "{alias}" }}
799"#
800            ),
801        )
802        .expect("write leaf shape.toml");
803        std::fs::write(leaf_dir.join("main.shape"), "pub fn leaf_marker() { 1 }")
804            .expect("write leaf source");
805
806        let leaf_project = shape_runtime::project::find_project_root(&leaf_dir)
807            .expect("leaf project root should resolve");
808        let leaf_bundle = BundleCompiler::compile(&leaf_project).expect("compile leaf bundle");
809        let leaf_bundle_path = tmp.path().join("leaf.shapec");
810        leaf_bundle
811            .write_to_file(&leaf_bundle_path)
812            .expect("write leaf bundle");
813        assert!(
814            leaf_bundle
815                .native_dependency_scopes
816                .iter()
817                .any(|scope| scope.package_key == "leaf@1.2.3"
818                    && scope.dependencies.contains_key("duckdb")),
819            "leaf bundle should embed its native dependency scope"
820        );
821
822        std::fs::write(
823            mid_dir.join("shape.toml"),
824            r#"
825[project]
826name = "mid"
827version = "0.4.0"
828
829[dependencies]
830leaf = { path = "../leaf.shapec" }
831"#,
832        )
833        .expect("write mid shape.toml");
834        std::fs::write(mid_dir.join("main.shape"), "pub fn mid_marker() { 2 }")
835            .expect("write mid source");
836
837        let mid_project =
838            shape_runtime::project::find_project_root(&mid_dir).expect("mid project root");
839        let mid_bundle = BundleCompiler::compile(&mid_project).expect("compile mid bundle");
840
841        assert!(
842            mid_bundle
843                .native_dependency_scopes
844                .iter()
845                .any(|scope| scope.package_key == "leaf@1.2.3"
846                    && scope.dependencies.contains_key("duckdb")),
847            "mid bundle should preserve transitive native scopes from leaf.shapec"
848        );
849    }
850}