1use 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
19pub struct BundleCompiler;
21
22impl BundleCompiler {
23 pub fn compile(project: &ProjectRoot) -> Result<PackageBundle, String> {
25 let root = &project.root_path;
26
27 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 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 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 let mut hasher = Sha256::new();
79 hasher.update(source.as_bytes());
80 let source_hash = format!("{:x}", hasher.finalize());
81
82 all_sources.push_str(&source);
84
85 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 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 let export_names = collect_export_names(&ast);
98
99 let mut stdlib_names = crate::module_resolution::prepend_prelude_items(&mut ast);
101
102 stdlib_names.extend(resolve_and_inline_imports(&mut ast, &mut loader));
104
105 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 let content_addressed = bytecode.content_addressed.clone();
117
118 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 let mut hasher = Sha256::new();
139 hasher.update(all_sources.as_bytes());
140 let source_hash = format!("{:x}", hasher.finalize());
141
142 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 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 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 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 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 let mut manifest =
211 ModuleManifest::new(module_path.clone(), metadata.version.clone());
212
213 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 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 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
275fn 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 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
518fn discover_shape_files(
520 root: &Path,
521 project: &ProjectRoot,
522) -> Result<Vec<(PathBuf, String)>, String> {
523 let mut files = Vec::new();
524
525 collect_shape_files(root, root, &mut files)?;
527
528 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 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
542fn 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 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
572fn 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 parts.last() == Some(&"index") && parts.len() > 1 {
592 parts[..parts.len() - 1].join("::")
593 } else if parts.last() == Some(&"index") {
594 String::new()
596 } else {
597 parts.join("::")
598 }
599}
600
601fn 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 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 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 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 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}