From 90aa5a0a8fe3b80ac14bb52c407ae65409cf8b25 Mon Sep 17 00:00:00 2001 From: Denys Zadorozhnyi Date: Thu, 2 Jul 2026 16:49:27 +0300 Subject: [PATCH 01/12] feat: embed component WIT in the compiled Miden package The WIT generated by `#[component]` was written to `target/generated-wit/` and re-read by dependent crates' macros through `wit = "..."` path metadata in `miden-project.toml`, fragile plumbing every consuming project had to carry. Embed the public WIT in a `rodata,miden_wit` Wasm custom section instead (with a linker uniqueness guard so two components in one binary fail at link time rather than concatenating into garbage WIT), carry it through the compiler pipeline alongside the account component metadata, and attach it to the `.masp` as a custom `wit` section. The `#[account(...)]`, sibling `#[component(pkg::Iface)]`, `#[note]`, and `#[tx_script]` macros now read dependency WIT from the dependency's compiled package, so the `wit` keys are no longer read, prebuilt `.masp` file dependencies are self-contained, and packages without embedded WIT are rejected with a rebuild hint. Components authored manually (a local `wit/` directory with a bare `miden::generate!()`) embed their single WIT file the same way. The WIT section is not covered by the package content digest until miden-mast-package gains a first-class WIT section id upstream. The uniqueness guard adds one exported data byte, which shifts expected package sizes and VM cycle counts slightly. --- CHANGELOG.md | 6 + Cargo.lock | 3 + .../basic-wallet-tx-script/miden-project.toml | 3 - examples/counter-note/miden-project.toml | 3 - examples/p2id-note/miden-project.toml | 3 - examples/p2ide-note/miden-project.toml | 3 - .../increment-note/miden-project.toml | 4 - .../rust/note/template/miden-project.toml | 4 - .../tx-script/template/miden-project.toml | 4 - frontend/wasm/src/component/translator.rs | 12 + frontend/wasm/src/lib.rs | 3 + frontend/wasm/src/module/module_env.rs | 8 +- midenc-compile/Cargo.toml | 1 + midenc-compile/src/pipeline/artifacts.rs | 5 + midenc-compile/src/pipeline/assembly.rs | 12 + midenc-compile/src/pipeline/backend.rs | 11 + midenc-compile/src/pipeline/frontends/hir.rs | 8 + midenc-compile/src/pipeline/frontends/rust.rs | 2 + midenc-compile/src/pipeline/frontends/wasm.rs | 5 + midenc-compile/src/pipeline/seed.rs | 6 + midenc-compile/src/pipeline/testing.rs | 1 + midenc-compile/tests/codegen_legalization.rs | 1 + sdk/base-macros/Cargo.toml | 1 + .../src/component_macro/generate_wit.rs | 86 +--- sdk/base-macros/src/component_macro/mod.rs | 13 +- .../src/component_macro/sibling.rs | 160 +----- sdk/base-macros/src/dependency_package.rs | 480 ++++++++++++++++++ sdk/base-macros/src/fpi.rs | 372 +------------- sdk/base-macros/src/generate.rs | 62 ++- sdk/base-macros/src/lib.rs | 1 + sdk/base-macros/src/manifest_paths.rs | 169 ++---- sdk/base-macros/src/util.rs | 51 +- sdk/base-macros/src/wit_world.rs | 475 +++++------------ sdk/sdk/MIGRATION.md | 39 +- sdk/wasm-metadata/src/lib.rs | 6 + .../component-macros-note/miden-project.toml | 3 - .../miden-project.toml | 3 - .../cross-ctx-note-word/miden-project.toml | 3 - .../cross-ctx-note/miden-project.toml | 3 - .../components/swapp-note/miden-project.toml | 3 - .../src/mockchain/fpi/common.rs | 10 - .../src/mockchain/support/projects.rs | 38 +- tests/integration/Cargo.toml | 1 + tests/integration/src/sdk/canonabi.rs | 40 +- tests/integration/src/sdk/macros.rs | 139 +++-- tests/integration/src/sdk/mod.rs | 6 - tests/support/src/testing/setup.rs | 1 + tools/cargo-miden/src/template.rs | 40 +- 48 files changed, 1016 insertions(+), 1297 deletions(-) create mode 100644 sdk/base-macros/src/dependency_package.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index e980cc50e..a6a4b18d4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - FPI expansions record `option_env!("MIDENC_PACKAGE_CACHE")`, so a consumer crate recompiles — and re-reads its dependency procedure roots — whenever the compiler's fingerprinted package cache path rotates, even if a stale cache directory survives on disk #1302 +- BREAKING: The component WIT generated by `#[component]` is now embedded in the compiled Miden + package (a `wit` section of the `.masp`) instead of being written to `target/generated-wit/`, + and the SDK macros read dependency WIT from the dependency's compiled package. The + `wit = "..."` keys in `miden-project.toml` are no longer read, and dependency packages built by + older toolchains are rejected. See the [migration guide](./sdk/sdk/MIGRATION.md) for the + manifest edits and rebuild steps #1248 ## [0.10.0-rc.1] diff --git a/Cargo.lock b/Cargo.lock index 596f64253..dfe72c2a4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2847,6 +2847,7 @@ name = "miden-base-macros" version = "0.14.0-rc.1" dependencies = [ "heck", + "miden-assembly", "miden-assembly-syntax", "miden-debug-types", "miden-field", @@ -3661,6 +3662,7 @@ dependencies = [ "midenc-dialect-scf", "midenc-frontend-masm", "midenc-frontend-wasm", + "midenc-frontend-wasm-metadata", "midenc-hir", "midenc-hir-transform", "midenc-session", @@ -4011,6 +4013,7 @@ dependencies = [ "midenc-dialect-wasm", "midenc-expect-test", "midenc-frontend-wasm", + "midenc-frontend-wasm-metadata", "midenc-hir", "midenc-hir-eval", "midenc-integration-test-support", diff --git a/examples/basic-wallet-tx-script/miden-project.toml b/examples/basic-wallet-tx-script/miden-project.toml index 851fcf5a8..1baa9df3a 100644 --- a/examples/basic-wallet-tx-script/miden-project.toml +++ b/examples/basic-wallet-tx-script/miden-project.toml @@ -11,6 +11,3 @@ path = "src/lib.rs" miden-core = "*" miden-protocol = "*" basic-wallet = { path = "../basic-wallet" } - -[package.metadata.miden.dependencies] -basic-wallet = { wit = "../basic-wallet/target/generated-wit/" } diff --git a/examples/counter-note/miden-project.toml b/examples/counter-note/miden-project.toml index cd38ce615..a74eaf6de 100644 --- a/examples/counter-note/miden-project.toml +++ b/examples/counter-note/miden-project.toml @@ -11,6 +11,3 @@ path = "src/lib.rs" miden-core = "*" miden-protocol = "*" counter-contract = { path = "../counter-contract" } - -[package.metadata.miden.dependencies] -counter-contract = { wit = "../counter-contract/target/generated-wit/" } diff --git a/examples/p2id-note/miden-project.toml b/examples/p2id-note/miden-project.toml index 184a72f5a..47f9d3980 100644 --- a/examples/p2id-note/miden-project.toml +++ b/examples/p2id-note/miden-project.toml @@ -11,6 +11,3 @@ path = "src/lib.rs" miden-core = "*" miden-protocol = "*" basic-wallet = { path = "../basic-wallet" } - -[package.metadata.miden.dependencies] -basic-wallet = { wit = "../basic-wallet/target/generated-wit/" } diff --git a/examples/p2ide-note/miden-project.toml b/examples/p2ide-note/miden-project.toml index 1415632f9..b229458d2 100644 --- a/examples/p2ide-note/miden-project.toml +++ b/examples/p2ide-note/miden-project.toml @@ -11,6 +11,3 @@ path = "src/lib.rs" miden-core = "*" miden-protocol = "*" basic-wallet = { path = "../basic-wallet" } - -[package.metadata.miden.dependencies] -basic-wallet = { wit = "../basic-wallet/target/generated-wit/" } diff --git a/extra/templates/project/contracts/increment-note/miden-project.toml b/extra/templates/project/contracts/increment-note/miden-project.toml index 6ff70c952..0b811fd4b 100644 --- a/extra/templates/project/contracts/increment-note/miden-project.toml +++ b/extra/templates/project/contracts/increment-note/miden-project.toml @@ -11,7 +11,3 @@ namespace = "miden:increment-note/miden-increment-note@0.1.0" miden-core = "*" miden-protocol = "*" counter-account = { path = "../counter-account" } - -# WIT for the account component this note calls, produced by building counter-account. -[package.metadata.miden.dependencies] -counter-account = { wit = "../counter-account/target/generated-wit/" } diff --git a/extra/templates/rust/note/template/miden-project.toml b/extra/templates/rust/note/template/miden-project.toml index a6a0c8bc8..c9195115f 100644 --- a/extra/templates/rust/note/template/miden-project.toml +++ b/extra/templates/rust/note/template/miden-project.toml @@ -11,7 +11,3 @@ miden-core = "*" miden-protocol = "*" # TODO: Add your account contract dependencies here add-contract = { path = "../add-contract" } - -[package.metadata.miden.dependencies] -# TODO: Add your account contract WIT dependencies here -add-contract = { wit = "../add-contract/target/generated-wit/" } diff --git a/extra/templates/rust/tx-script/template/miden-project.toml b/extra/templates/rust/tx-script/template/miden-project.toml index 0ef9808b2..f7294c6ea 100644 --- a/extra/templates/rust/tx-script/template/miden-project.toml +++ b/extra/templates/rust/tx-script/template/miden-project.toml @@ -11,7 +11,3 @@ miden-core = "*" miden-protocol = "*" # TODO: Add your account contract dependencies here add-contract = { path = "../add-contract" } - -[package.metadata.miden.dependencies] -# TODO: Add your account contract WIT dependencies here -add-contract = { wit = "../add-contract/target/generated-wit/" } diff --git a/frontend/wasm/src/component/translator.rs b/frontend/wasm/src/component/translator.rs index e517d8168..9688c9a5a 100644 --- a/frontend/wasm/src/component/translator.rs +++ b/frontend/wasm/src/component/translator.rs @@ -173,6 +173,17 @@ impl<'a> ComponentTranslator<'a> { &self.lifted_export_names, )?; + let component_wit_bytes_vec: Vec> = self + .nested_modules + .iter() + .flat_map(|t| t.1.component_wit_bytes.map(|slice| slice.to_vec())) + .collect(); + assert!( + component_wit_bytes_vec.len() <= 1, + "unexpected multiple core Wasm module to have a component WIT section", + ); + let component_wit_bytes = component_wit_bytes_vec.first().map(ToOwned::to_owned); + let account_component_metadata_bytes_vec: Vec> = self .nested_modules .into_iter() @@ -188,6 +199,7 @@ impl<'a> ComponentTranslator<'a> { let output = FrontendOutput { component: self.result.component, account_component_metadata_bytes, + component_wit_bytes, }; Ok(output) } diff --git a/frontend/wasm/src/lib.rs b/frontend/wasm/src/lib.rs index f98b17237..1b19c6d91 100644 --- a/frontend/wasm/src/lib.rs +++ b/frontend/wasm/src/lib.rs @@ -41,6 +41,8 @@ pub struct FrontendOutput { pub component: builtin::ComponentRef, /// The serialized AccountComponentMetadata (name, description, storage layout, etc.) pub account_component_metadata_bytes: Option>, + /// The component's public WIT source emitted by the `#[component]` macro. + pub component_wit_bytes: Option>, } /// Translate a valid Wasm core module or Wasm Component Model binary into Miden @@ -57,6 +59,7 @@ pub fn translate( Ok(FrontendOutput { component, account_component_metadata_bytes: None, + component_wit_bytes: None, }) } else { translate_component(wasm, config, context) diff --git a/frontend/wasm/src/module/module_env.rs b/frontend/wasm/src/module/module_env.rs index 1d118f5ad..f29a0e9e9 100644 --- a/frontend/wasm/src/module/module_env.rs +++ b/frontend/wasm/src/module/module_env.rs @@ -4,7 +4,8 @@ use std::path::PathBuf; use cranelift_entity::{PrimaryMap, packed_option::ReservedValue}; use midenc_frontend_wasm_metadata::{ - FrontendMetadata, WASM_FRONTEND_METADATA_CUSTOM_SECTION_NAME, decode_section, + FrontendMetadata, WASM_COMPONENT_WIT_CUSTOM_SECTION_NAME, + WASM_FRONTEND_METADATA_CUSTOM_SECTION_NAME, decode_section, }; use midenc_hir::{FxHashMap, FxHashSet, Ident, interner::Symbol}; use midenc_session::diagnostics::{DiagnosticsHandler, IntoDiagnostic, Report, Severity}; @@ -86,6 +87,8 @@ pub struct ParsedModule<'data> { /// The serialized AccountComponentMetadata (name, description, storage layout, etc.) pub account_component_metadata_bytes: Option<&'data [u8]>, + /// The component's public WIT source emitted by the `#[component]` macro. + pub component_wit_bytes: Option<&'data [u8]>, /// Frontend-only component metadata entries emitted by SDK macros (empty when none present). pub component_frontend_metadata: Vec, } @@ -313,6 +316,9 @@ impl<'a, 'data> ModuleEnvironment<'a, 'data> { Payload::CustomSection(s) if s.name() == "rodata,miden_account" => { self.result.account_component_metadata_bytes = Some(s.data()); } + Payload::CustomSection(s) if s.name() == WASM_COMPONENT_WIT_CUSTOM_SECTION_NAME => { + self.result.component_wit_bytes = Some(s.data()); + } Payload::CustomSection(s) if s.name() == WASM_FRONTEND_METADATA_CUSTOM_SECTION_NAME => { let metadata = decode_section(s.data()).map_err(|err| { diagnostics diff --git a/midenc-compile/Cargo.toml b/midenc-compile/Cargo.toml index a2930968f..95c03398f 100644 --- a/midenc-compile/Cargo.toml +++ b/midenc-compile/Cargo.toml @@ -39,6 +39,7 @@ miden-assembly-syntax.workspace = true miden-mast-package.workspace = true miden-package-registry.workspace = true midenc-frontend-wasm.workspace = true +midenc-frontend-wasm-metadata.workspace = true midenc-frontend-masm.workspace = true midenc-dialect-scf.workspace = true midenc-dialect-hir.workspace = true diff --git a/midenc-compile/src/pipeline/artifacts.rs b/midenc-compile/src/pipeline/artifacts.rs index b022fc195..b87251876 100644 --- a/midenc-compile/src/pipeline/artifacts.rs +++ b/midenc-compile/src/pipeline/artifacts.rs @@ -22,6 +22,7 @@ pub struct MidenComponent { pub world: builtin::WorldRef, pub component: Option, pub account_component_metadata_bytes: Option>, + pub component_wit_bytes: Option>, #[cfg(feature = "std")] pub source_provenance: miden_assembly::ProjectSourceProvenanceInputs, } @@ -32,6 +33,7 @@ impl Clone for MidenComponent { world: self.world, component: self.component, account_component_metadata_bytes: self.account_component_metadata_bytes.clone(), + component_wit_bytes: self.component_wit_bytes.clone(), #[cfg(feature = "std")] source_provenance: miden_assembly::ProjectSourceProvenanceInputs { root: miden_assembly::SourceFileProvenance { @@ -57,6 +59,8 @@ pub struct CodegenOutput { pub component: Arc, /// The serialized AccountComponentMetadata (name, description, storage layout, etc.) pub account_component_metadata_bytes: Option>, + /// The component's public WIT source emitted by the `#[component]` macro. + pub component_wit_bytes: Option>, #[cfg(feature = "std")] pub source_provenance: miden_assembly::ProjectSourceProvenanceInputs, } @@ -88,6 +92,7 @@ impl Clone for CodegenOutput { Self { component: self.component.clone(), account_component_metadata_bytes: self.account_component_metadata_bytes.clone(), + component_wit_bytes: self.component_wit_bytes.clone(), source_provenance: self.source_provenance(), } } diff --git a/midenc-compile/src/pipeline/assembly.rs b/midenc-compile/src/pipeline/assembly.rs index ebb8e0497..2c1eae885 100644 --- a/midenc-compile/src/pipeline/assembly.rs +++ b/midenc-compile/src/pipeline/assembly.rs @@ -60,6 +60,7 @@ pub(crate) fn post_process_package( package: &mut Package, component: &MasmComponent, account_component_metadata_bytes: Option<&[u8]>, + component_wit_bytes: Option<&[u8]>, target: &midenc_session::miden_project::Target, registry: &dyn miden_package_registry::PackageRegistryAndProvider, ) -> Result<(), Report> { @@ -68,6 +69,7 @@ pub(crate) fn post_process_package( use midenc_session::miden_project::TargetType; attach_account_component_metadata(package, account_component_metadata_bytes); + attach_component_wit(package, component_wit_bytes); extend_rodata_advice_map(package, &component.rodata); // Embed the kernel in note/transaction script packages, if not already embedded @@ -101,6 +103,16 @@ fn attach_account_component_metadata( } } +/// Attach the component's public WIT source to the assembled package. +fn attach_component_wit(package: &mut Package, component_wit_bytes: Option<&[u8]>) { + use miden_mast_package::{Section, SectionId}; + if let Some(bytes) = component_wit_bytes { + let id = SectionId::custom(midenc_frontend_wasm_metadata::PACKAGE_WIT_SECTION_ID) + .expect("the WIT section id must be a valid custom section id"); + package.sections.push(Section::new(id, bytes.to_vec())); + } +} + /// Extend the package advice map with the component's rodata segments. fn extend_rodata_advice_map(package: &mut Package, rodata: &[midenc_codegen_masm::Rodata]) { if rodata.is_empty() { diff --git a/midenc-compile/src/pipeline/backend.rs b/midenc-compile/src/pipeline/backend.rs index ece10f883..a7d623745 100644 --- a/midenc-compile/src/pipeline/backend.rs +++ b/midenc-compile/src/pipeline/backend.rs @@ -51,6 +51,8 @@ use crate::{CodegenOutput, CompilerResult, MidenComponent}; /// advice map fails at run time, in the VM, with nothing in the build to point at. /// - [`account_component_metadata_bytes`](LoweredTarget::account_component_metadata_bytes) /// becomes the package's account-component metadata section. +/// - [`component_wit_bytes`](LoweredTarget::component_wit_bytes) becomes the package's +/// component WIT section. /// - [`source_provenance`](LoweredTarget::source_provenance) is what the assembler hashes to /// decide whether a cached build of this target is still current. /// @@ -66,6 +68,8 @@ pub struct LoweredTarget { pub component: Arc, /// The serialized account-component metadata, if this target has any. pub account_component_metadata_bytes: Option>, + /// The component's public WIT source, if this target embeds any. + pub component_wit_bytes: Option>, /// The provenance of the sources this target was built from. pub source_provenance: ProjectSourceProvenanceInputs, } @@ -124,6 +128,7 @@ pub fn masm_from_transformed_hir( let CodegenOutput { component, account_component_metadata_bytes, + component_wit_bytes, source_provenance, } = codegen(hir, context)?; let session = cx.session(); @@ -134,6 +139,7 @@ pub fn masm_from_transformed_hir( sources, component, account_component_metadata_bytes, + component_wit_bytes, source_provenance, }; // After the checkpoint, so that a run stopping at `masm.lowered` does not reach it: @@ -295,6 +301,7 @@ pub fn codegen(hir: MidenComponent, context: Rc) -> CompilerResult) -> CompilerResult lowered, @@ -362,6 +363,7 @@ impl Frontend for HirFrontend { CodegenOutput { component, account_component_metadata_bytes, + component_wit_bytes, source_provenance, }, ); @@ -393,6 +395,7 @@ impl Frontend for HirFrontend { package, &found.component, found.account_component_metadata_bytes.as_deref(), + found.component_wit_bytes.as_deref(), cx.assembly().target, cx.assembly().package_registry, ) @@ -468,6 +471,7 @@ pub fn extract_miden_component_or_bail( world, component: None, account_component_metadata_bytes: None, + component_wit_bytes: None, source_provenance, }) } else if let Ok(component) = op.try_downcast_op::() { @@ -476,6 +480,7 @@ pub fn extract_miden_component_or_bail( world, component: Some(component), account_component_metadata_bytes: None, + component_wit_bytes: None, source_provenance, }) } else if let Ok(module) = op.try_downcast_op::() { @@ -486,6 +491,7 @@ pub fn extract_miden_component_or_bail( world, component: Some(component), account_component_metadata_bytes: None, + component_wit_bytes: None, source_provenance, }) } else if let Ok(world) = parent.try_downcast_op::() { @@ -493,6 +499,7 @@ pub fn extract_miden_component_or_bail( world, component: None, account_component_metadata_bytes: None, + component_wit_bytes: None, source_provenance, }) } else { @@ -507,6 +514,7 @@ pub fn extract_miden_component_or_bail( world, component: None, account_component_metadata_bytes: None, + component_wit_bytes: None, source_provenance, }) } diff --git a/midenc-compile/src/pipeline/frontends/rust.rs b/midenc-compile/src/pipeline/frontends/rust.rs index c2206154e..de98f7e18 100644 --- a/midenc-compile/src/pipeline/frontends/rust.rs +++ b/midenc-compile/src/pipeline/frontends/rust.rs @@ -1183,6 +1183,7 @@ impl Frontend for RustProjectFrontend { package, &found.component, found.account_component_metadata_bytes.as_deref(), + found.component_wit_bytes.as_deref(), cx.assembly().target, cx.assembly().package_registry, ) @@ -2141,6 +2142,7 @@ mod tests { modules: Vec::new(), }), account_component_metadata_bytes: None, + component_wit_bytes: None, source_provenance: ProjectSourceProvenanceInputs { root: SourceFileProvenance { path: std::path::PathBuf::from("seeded.wat").into_boxed_path(), diff --git a/midenc-compile/src/pipeline/frontends/wasm.rs b/midenc-compile/src/pipeline/frontends/wasm.rs index 305836851..f6d37bb64 100644 --- a/midenc-compile/src/pipeline/frontends/wasm.rs +++ b/midenc-compile/src/pipeline/frontends/wasm.rs @@ -470,6 +470,7 @@ impl WasmFrontend { let FrontendOutput { component, account_component_metadata_bytes, + component_wit_bytes, } = midenc_frontend_wasm::translate(&source.wasm, &config, context.clone())?; log::debug!( "parsed hir component from wasm bytes with first module name: {}", @@ -482,6 +483,7 @@ impl WasmFrontend { world, component: Some(component), account_component_metadata_bytes, + component_wit_bytes, source_provenance, }) } @@ -527,6 +529,7 @@ impl WasmFrontend { sources, component, account_component_metadata_bytes, + component_wit_bytes, source_provenance, } = match backend::hir_to_masm(cx, hir)? { Flow::Continue(lowered) => lowered, @@ -537,6 +540,7 @@ impl WasmFrontend { CodegenOutput { component, account_component_metadata_bytes, + component_wit_bytes, source_provenance, }, ); @@ -591,6 +595,7 @@ impl Frontend for WasmFrontend { package, &found.component, found.account_component_metadata_bytes.as_deref(), + found.component_wit_bytes.as_deref(), cx.assembly().target, cx.assembly().package_registry, ) diff --git a/midenc-compile/src/pipeline/seed.rs b/midenc-compile/src/pipeline/seed.rs index d82fa3a0d..5dd6a070a 100644 --- a/midenc-compile/src/pipeline/seed.rs +++ b/midenc-compile/src/pipeline/seed.rs @@ -394,6 +394,7 @@ impl Frontend for SeedFrontend { sources, component, account_component_metadata_bytes, + component_wit_bytes, source_provenance, } = match self.resume(cx, hir)? { Flow::Continue(lowered) => lowered, @@ -404,6 +405,7 @@ impl Frontend for SeedFrontend { CodegenOutput { component, account_component_metadata_bytes, + component_wit_bytes, source_provenance, }, ); @@ -438,6 +440,7 @@ impl Frontend for SeedFrontend { package, &found.component, found.account_component_metadata_bytes.as_deref(), + found.component_wit_bytes.as_deref(), cx.assembly().target, cx.assembly().package_registry, ) @@ -556,6 +559,7 @@ mod tests { sources, component, account_component_metadata_bytes, + component_wit_bytes, source_provenance, } = match backend::hir_to_masm(cx, hir)? { Flow::Continue(lowered) => lowered, @@ -566,6 +570,7 @@ mod tests { CodegenOutput { component, account_component_metadata_bytes, + component_wit_bytes, source_provenance, }, ); @@ -605,6 +610,7 @@ mod tests { package, &found.component, found.account_component_metadata_bytes.as_deref(), + found.component_wit_bytes.as_deref(), cx.assembly().target, cx.assembly().package_registry, ) diff --git a/midenc-compile/src/pipeline/testing.rs b/midenc-compile/src/pipeline/testing.rs index 5c32513f4..74ee77d4e 100644 --- a/midenc-compile/src/pipeline/testing.rs +++ b/midenc-compile/src/pipeline/testing.rs @@ -213,6 +213,7 @@ pub(crate) fn component_in_namespace( world, component: Some(component), account_component_metadata_bytes: metadata, + component_wit_bytes: None, source_provenance: ProjectSourceProvenanceInputs { root: SourceFileProvenance { path: FsPath::new(file!()).to_path_buf().into_boxed_path(), diff --git a/midenc-compile/tests/codegen_legalization.rs b/midenc-compile/tests/codegen_legalization.rs index 853368747..ac1b7466d 100644 --- a/midenc-compile/tests/codegen_legalization.rs +++ b/midenc-compile/tests/codegen_legalization.rs @@ -79,6 +79,7 @@ fn build_test_component( world, component: Some(component), account_component_metadata_bytes: None, + component_wit_bytes: None, source_provenance: ProjectSourceProvenanceInputs { root: SourceFileProvenance { path: Path::new(file!()).to_path_buf().into_boxed_path(), diff --git a/sdk/base-macros/Cargo.toml b/sdk/base-macros/Cargo.toml index fb6b864b1..88ae061b8 100644 --- a/sdk/base-macros/Cargo.toml +++ b/sdk/base-macros/Cargo.toml @@ -39,6 +39,7 @@ wit-component = { workspace = true, optional = true } [dev-dependencies] # NOTE: Use local paths for dev-only dependency to avoid relying on crates.io during packaging +miden-assembly = { workspace = true, features = ["std"] } miden-protocol = { workspace = true, features = ["std"] } miden-field.workspace = true miden-field-repr.workspace = true diff --git a/sdk/base-macros/src/component_macro/generate_wit.rs b/sdk/base-macros/src/component_macro/generate_wit.rs index ac5b4a76c..cf77f34ba 100644 --- a/sdk/base-macros/src/component_macro/generate_wit.rs +++ b/sdk/base-macros/src/component_macro/generate_wit.rs @@ -1,8 +1,4 @@ -use std::{ - collections::{BTreeSet, HashSet}, - fs, - io::ErrorKind, -}; +use std::collections::{BTreeSet, HashSet}; use proc_macro::Span; use semver::Version; @@ -11,7 +7,6 @@ use syn::spanned::Spanned; use crate::{ component_macro::{CORE_TYPES_PACKAGE, ComponentMethod, MethodReturn, to_kebab_case}, types::{ExportedTypeDef, ExportedTypeKind, ensure_custom_type_defined}, - util::generated_wit_folder, wit_builder::WitBuilder, wit_world::write_world_block, }; @@ -36,69 +31,6 @@ pub(super) struct ComponentWitSpec<'a> { pub(super) exported_types: &'a [ExportedTypeDef], } -/// Writes the generated component WIT to the crate's `wit` directory so that dependent targets can -/// reference it via manifest metadata. -pub fn write_component_wit_file( - call_site_span: Span, - wit_source: &str, - package_name: &str, -) -> Result<(), syn::Error> { - let sanitized_package_name = sanitize_package_name(package_name); - let autogenerated_wit_folder = generated_wit_folder()?; - let wit_path = autogenerated_wit_folder.join(format!("{sanitized_package_name}.wit")); - - for entry in fs::read_dir(&autogenerated_wit_folder).map_err(|err| { - syn::Error::new( - call_site_span.into(), - format!( - "failed to read generated WIT directory '{}': {err}", - autogenerated_wit_folder.display() - ), - ) - })? { - let entry = entry.map_err(|err| { - syn::Error::new( - call_site_span.into(), - format!( - "failed to inspect generated WIT directory '{}': {err}", - autogenerated_wit_folder.display() - ), - ) - })?; - let path = entry.path(); - if path != wit_path && path.extension().and_then(|ext| ext.to_str()) == Some("wit") { - fs::remove_file(&path).map_err(|err| { - syn::Error::new( - call_site_span.into(), - format!("failed to remove stale WIT file '{}': {err}", path.display()), - ) - })?; - } - } - - let needs_write = match fs::read_to_string(&wit_path) { - Ok(existing) => existing != wit_source, - Err(err) if err.kind() == ErrorKind::NotFound => true, - Err(err) => { - return Err(syn::Error::new( - call_site_span.into(), - format!("failed to read existing WIT file '{}': {err}", wit_path.display()), - )); - } - }; - - if needs_write { - fs::write(&wit_path, wit_source).map_err(|err| { - syn::Error::new( - call_site_span.into(), - format!("failed to write WIT file '{}': {err}", wit_path.display()), - ) - })?; - } - - Ok(()) -} - /// Renders the WIT source describing the component interface exported by the `impl` block. pub(super) fn build_component_wit(spec: ComponentWitSpec<'_>) -> Result { let exported_type_names: HashSet = @@ -230,19 +162,3 @@ fn component_method_signature( Ok(signature) } - -fn sanitize_package_name(package_name: &str) -> String { - let mut sanitized = package_name - .chars() - .map(|ch| match ch { - 'a'..='z' | 'A'..='Z' | '0'..='9' | '-' | '_' => ch, - _ => '-', - }) - .collect::(); - - if sanitized.is_empty() { - sanitized.push_str("component"); - } - - sanitized -} diff --git a/sdk/base-macros/src/component_macro/mod.rs b/sdk/base-macros/src/component_macro/mod.rs index aa9432ad3..74290da7a 100644 --- a/sdk/base-macros/src/component_macro/mod.rs +++ b/sdk/base-macros/src/component_macro/mod.rs @@ -20,14 +20,14 @@ use crate::{ account_component_metadata::AccountComponentMetadataBuilder, boilerplate::runtime_boilerplate, component_macro::{ - generate_wit::{ComponentWitSpec, build_component_wit, write_component_wit_file}, + generate_wit::{ComponentWitSpec, build_component_wit}, storage::process_storage_fields, }, dependency_ref::{DependencyRef, DependencyRefArgs}, types::{ ExportedTypeDef, ExportedTypeKind, TypeRef, map_type_to_type_ref, registered_export_types, }, - util::generate_frontend_link_section, + util::{generate_frontend_link_section, generate_wit_link_section}, }; mod generate_wit; @@ -689,8 +689,8 @@ fn expand_component_trait_impl( exported_types: &exported_types, })?; // Dependency imports are only needed while generating this crate's bindings. The public WIT - // file stays export-only so downstream crates can depend on this account without also - // materializing all of its transitive FPI dependencies next to the generated WIT. + // stays export-only so downstream crates can depend on this account without also + // materializing all of its transitive FPI dependencies. let public_wit_source = build_component_wit(ComponentWitSpec { component_package: &package_name, component_version: metadata.package.version().inner(), @@ -701,7 +701,9 @@ fn expand_component_trait_impl( methods: &methods, exported_types: &exported_types, })?; - write_component_wit_file(call_site_span, &public_wit_source, &package_name)?; + // The public WIT is embedded into a Wasm custom section, carried by the compiler into the + // Miden package (`.masp`), where dependent crates' macros read it back during expansion. + let wit_link_section = generate_wit_link_section(&public_wit_source); let inline_literal = Literal::string(&inline_wit_source); let interface_path = @@ -745,6 +747,7 @@ fn expand_component_trait_impl( // Use the fully-qualified component type here so the export macro works even when // the impl block was declared through a module-qualified path (e.g. `impl Foo for super::Bar`). self::bindings::export!(#component_type); + #wit_link_section }) } diff --git a/sdk/base-macros/src/component_macro/sibling.rs b/sdk/base-macros/src/component_macro/sibling.rs index 368875590..5ab6a22e9 100644 --- a/sdk/base-macros/src/component_macro/sibling.rs +++ b/sdk/base-macros/src/component_macro/sibling.rs @@ -59,8 +59,7 @@ pub(super) fn expand_sibling_traits( &inline_wit, SIBLING_BINDINGS_WORLD, &with_entries, - ) - .map_err(|err| augment_missing_sibling_wit(err, &dependencies))?; + )?; let file: syn::File = syn::parse2(bindings)?; let modules = fpi::collect_import_modules(&file.items, &fpi::is_plain_import_function)?; @@ -97,61 +96,6 @@ pub(super) fn expand_sibling_traits( }) } -/// Rewrites a missing-package failure from sibling binding generation into actionable guidance. -/// -/// A sibling reference is selected by reading the dependency's generated WIT (which -/// `wit_world::collect_miden_dependencies` finds under `target/generated-wit`), but the inline -/// `generate!` resolves imports against `manifest_paths::resolve_wit_paths`, which only puts a -/// dependency's WIT on the search path when `[package.metadata.miden.dependencies]..wit` is -/// declared (or a `wit/` directory sits at the dependency root). Without that manifest entry the -/// reference selects successfully and then fails here with a bare wit-parser "package not found". -/// This maps that case to a diagnostic naming the dependencies and the manifest entry to add. -fn augment_missing_sibling_wit(err: syn::Error, dependencies: &[SelectedDependency]) -> syn::Error { - let message = err.to_string(); - if !message.contains("not found") { - return err; - } - - // Only the "package '' not found" portion names the missing package; wit-parser appends a - // `known packages:` list of the packages it *did* resolve. Matching against the whole message - // would blame a resolved sibling that happens to appear in that list, so restrict the search to - // the text before it. Within that, match up to the version boundary (`@`) so a package id - // that is a prefix of another (`miden:counter` vs `miden:counter-contract`) is not over-matched. - let not_found = message.split("known packages").next().unwrap_or(message.as_str()); - let missing = dependencies - .iter() - .filter(|dependency| { - let package = dependency.import().split('/').next().unwrap_or(dependency.import()); - not_found.contains(&format!("{package}@")) - }) - .collect::>(); - if missing.is_empty() { - return err; - } - - let hints = missing - .iter() - .map(|dependency| { - format!( - " [package.metadata.miden.dependencies]\n \"{}\" = {{ wit = \"{}\" }}", - dependency.name, - dependency.root.join("target/generated-wit").display(), - ) - }) - .collect::>() - .join("\n"); - - Error::new( - Span2::call_site(), - format!( - "could not resolve the WIT for sibling component dependencies; their generated WIT is \ - not on the macro's WIT search path. Declare each sibling dependency's generated WIT \ - in `miden-project.toml` so `#[component(...)]` can resolve \ - it:\n{hints}\n\nunderlying error: {message}" - ), - ) -} - /// Rejects a sibling reference whose generated trait would shadow the component trait itself. /// /// The generated `pub trait ` is emitted next to the user's component trait, so a @@ -332,8 +276,9 @@ mod tests { fn test_dependency() -> SelectedDependency { SelectedDependency { - name: "pausable".to_string(), - root: std::path::PathBuf::from("/tmp/pausable"), + package_path: std::path::PathBuf::from( + "/tmp/pausable/target/miden/debug/pausable.masp", + ), interface: crate::wit_world::DependencyInterface { name: "pausable".to_string(), import: "miden:pausable/pausable@0.1.0".to_string(), @@ -454,101 +399,4 @@ mod tests { let ident = sibling_bindings_module_ident(&format_ident!("MyComponent")); assert_eq!(ident.to_string(), "__miden_sibling_bindings_my_component"); } - - #[test] - fn augments_missing_wit_package_error_with_manifest_guidance() { - let dependency = SelectedDependency { - name: "counter-contract".to_string(), - root: std::path::PathBuf::from("/tmp/counter"), - interface: crate::wit_world::DependencyInterface { - name: "counter-contract".to_string(), - import: "miden:counter-contract/counter-contract@0.1.0".to_string(), - types: Vec::new(), - }, - }; - let raw = Error::new( - Span2::call_site(), - "package 'miden:counter-contract@0.1.0' not found. known packages: miden:base@1.0.0", - ); - - let message = - augment_missing_sibling_wit(raw, std::slice::from_ref(&dependency)).to_string(); - - assert!(message.contains("[package.metadata.miden.dependencies]"), "message: {message}"); - assert!(message.contains("\"counter-contract\""), "message: {message}"); - assert!(message.contains("target/generated-wit"), "message: {message}"); - // The original wit-parser detail is preserved, not masked. - assert!(message.contains("underlying error"), "message: {message}"); - } - - #[test] - fn leaves_unrelated_generation_errors_untouched() { - let raw = Error::new(Span2::call_site(), "some unrelated macro error"); - let augmented = augment_missing_sibling_wit(raw, std::slice::from_ref(&test_dependency())); - assert_eq!(augmented.to_string(), "some unrelated macro error"); - } - - #[test] - fn does_not_over_match_a_prefix_package_id() { - // A `miden:counter` dependency must not be flagged when the error names the distinct - // `miden:counter-contract` package, even though the former id is a prefix of the latter. - let dependency = SelectedDependency { - name: "counter".to_string(), - root: std::path::PathBuf::from("/tmp/counter"), - interface: crate::wit_world::DependencyInterface { - name: "counter".to_string(), - import: "miden:counter/counter@0.1.0".to_string(), - types: Vec::new(), - }, - }; - let raw = Error::new( - Span2::call_site(), - "package 'miden:counter-contract@0.1.0' not found. known packages: miden:base@1.0.0", - ); - - // No dependency matches the error's package id, so the original error passes through. - let augmented = - augment_missing_sibling_wit(raw, std::slice::from_ref(&dependency)).to_string(); - assert!(augmented.starts_with("package 'miden:counter-contract@0.1.0' not found")); - assert!(!augmented.contains("[package.metadata.miden.dependencies]")); - } - - #[test] - fn does_not_blame_a_sibling_listed_under_known_packages() { - // `first` resolved (it appears in the error's `known packages` list); only `second` is - // missing. The hint must name only `second`, not the healthy `first`. - let first = SelectedDependency { - name: "first-counter".to_string(), - root: std::path::PathBuf::from("/tmp/first"), - interface: crate::wit_world::DependencyInterface { - name: "first-counter".to_string(), - import: "miden:first-counter/first-counter@0.1.0".to_string(), - types: Vec::new(), - }, - }; - let second = SelectedDependency { - name: "second-counter".to_string(), - root: std::path::PathBuf::from("/tmp/second"), - interface: crate::wit_world::DependencyInterface { - name: "second-counter".to_string(), - import: "miden:second-counter/second-counter@0.1.0".to_string(), - types: Vec::new(), - }, - }; - let raw = Error::new( - Span2::call_site(), - "package 'miden:second-counter@0.1.0' not found. known packages: miden:base@1.0.0, \ - miden:first-counter@0.1.0", - ); - - let deps = [first, second]; - let message = augment_missing_sibling_wit(raw, &deps).to_string(); - // The hint wraps the dependency name in quotes; the resolved sibling appears only in the - // verbatim underlying error (unquoted), so a quoted match isolates the hint. - assert!(message.contains("\"second-counter\""), "message: {message}"); - assert!( - !message.contains("\"first-counter\""), - "must not blame the resolved sibling: {message}" - ); - } } diff --git a/sdk/base-macros/src/dependency_package.rs b/sdk/base-macros/src/dependency_package.rs new file mode 100644 index 000000000..6aa61d2fd --- /dev/null +++ b/sdk/base-macros/src/dependency_package.rs @@ -0,0 +1,480 @@ +//! Locating and reading compiled Miden dependency packages (`.masp`) at macro-expansion time. +//! +//! A Miden path dependency is consumed through its compiled package: the `.masp` carries both the +//! dependency's embedded component WIT (read here) and its procedure roots (read by [`crate::fpi`]). + +use std::{ + env, fs, + path::{Path, PathBuf}, +}; + +use miden_mast_package::{Package, SectionId}; +use miden_protocol::utils::serde::Deserializable; +use midenc_frontend_wasm_metadata::PACKAGE_WIT_SECTION_ID; +use proc_macro2::Span; +use syn::Error; + +/// WIT source extracted from a compiled Miden dependency package. +pub(crate) struct DependencyWitSource { + /// Manifest key used for this dependency. + pub(crate) name: String, + /// Canonical project root or precompiled package path. + pub(crate) root: PathBuf, + /// Path of the compiled `.masp` package the WIT was read from. + pub(crate) package_path: PathBuf, + /// The component WIT source embedded in the package. + pub(crate) wit: String, +} + +/// Reads the embedded WIT of every Miden path dependency's compiled package. +pub(crate) fn collect_dependency_wit_sources( + manifest_dir: &Path, + package: &miden_project::Package, +) -> Result, Error> { + let error_span = Span::call_site(); + let mut sources = Vec::new(); + + for dependency in package.dependencies() { + match dependency.scheme() { + miden_project::DependencyVersionScheme::Path { path, .. } => { + let absolute_path = manifest_dir.join(path.path()); + let dependency_root = fs::canonicalize(&absolute_path).map_err(|err| { + Error::new( + error_span, + format!( + "failed to canonicalize dependency '{}' path '{}': {err}", + dependency.name(), + absolute_path.display() + ), + ) + })?; + let package_path = + resolve_dependency_package_path(dependency.name().as_ref(), &dependency_root)?; + let wit = read_package_wit(&package_path)?; + sources.push(DependencyWitSource { + name: dependency.name().to_string(), + root: dependency_root, + package_path, + wit, + }); + } + // TODO(pauls): We should also handle git dependencies at some point + _ => continue, + } + } + + Ok(sources) +} + +/// Returns the package section id carrying the embedded component WIT. +pub(crate) fn wit_section_id() -> SectionId { + SectionId::custom(PACKAGE_WIT_SECTION_ID) + .expect("the WIT section id must be a valid custom section id") +} + +/// Reads the component WIT embedded in a compiled Miden package. +pub(crate) fn read_package_wit(package_path: &Path) -> Result { + let error_span = Span::call_site(); + let package_bytes = fs::read(package_path).map_err(|err| { + Error::new( + error_span, + format!("failed to read dependency package '{}': {err}", package_path.display()), + ) + })?; + let package = Package::read_from_bytes(&package_bytes).map_err(|err| { + Error::new( + error_span, + format!("failed to deserialize dependency package '{}': {err}", package_path.display()), + ) + })?; + + let wit_section_id = wit_section_id(); + let Some(section) = package.sections.iter().find(|section| section.id == wit_section_id) else { + return Err(Error::new( + error_span, + format!( + "dependency package '{}' does not embed component WIT (missing package section \ + '{PACKAGE_WIT_SECTION_ID}'); it was likely built with an older Miden toolchain. \ + Rebuild the dependency with the current `cargo miden build`.", + package_path.display() + ), + )); + }; + + String::from_utf8(section.data.to_vec()).map_err(|err| { + Error::new( + error_span, + format!( + "dependency package '{}' contains an invalid component WIT section (not UTF-8): \ + {err}", + package_path.display() + ), + ) + }) +} + +/// Finds the `.masp` package artifact for the dependency named `name` rooted at `root`. +pub(crate) fn resolve_dependency_package_path(name: &str, root: &Path) -> Result { + if root.is_file() { + return Ok(root.to_path_buf()); + } + + let preferred_profile = env::var("PROFILE").unwrap_or_else(|_| "debug".to_string()); + let mut profiles = vec![preferred_profile.clone()]; + if preferred_profile != "release" { + profiles.push("release".to_string()); + } + if preferred_profile != "debug" { + profiles.push("debug".to_string()); + } + + let package_stems = dependency_package_stems(name, root); + + // When midenc communicates its package cache, compiled dependency packages live there and + // nowhere else; searching the conventional output directories would find stale artifacts. + if let Some(filesystem_cache_dir) = env::var_os("MIDENC_PACKAGE_CACHE") { + let filesystem_cache_dir = PathBuf::from(filesystem_cache_dir); + if let Some(package) = find_dependency_package_in_dir(&filesystem_cache_dir, &package_stems)? + { + return Ok(package.clone()); + } + + return Err(Error::new( + Span::call_site(), + missing_cached_dependency_package_message( + name, + root, + &package_stems, + &filesystem_cache_dir, + ), + )); + } + + let output_dirs = dependency_output_dirs(root, &profiles); + for dir in &output_dirs { + if let Some(package) = find_dependency_package_in_dir(dir, &package_stems)? { + return Ok(package.clone()); + } + } + + Err(Error::new( + Span::call_site(), + missing_dependency_package_message(name, root, &package_stems, &output_dirs, &profiles), + )) +} + +/// Formats the diagnostic for a dependency package missing from the build-owned package cache. +fn missing_cached_dependency_package_message( + name: &str, + root: &Path, + package_stems: &[String], + filesystem_cache_dir: &Path, +) -> String { + let expected_files = package_stems + .iter() + .map(|stem| format!("'{stem}.masp'")) + .collect::>() + .join(", "); + + format!( + "could not find a built `.masp` package for Miden dependency '{name}' (root '{}'). The \ + SDK macros need the dependency package during Rust macro expansion to read its embedded \ + WIT and procedure roots. Expected one of these package names: {expected_files}. Searched \ + MIDENC_PACKAGE_CACHE directory '{}'. This cache is populated by the enclosing \ + midenc-driven build; compile this crate as part of that build so its dependency packages \ + are available during macro expansion.", + root.display(), + filesystem_cache_dir.display(), + ) +} + +/// Formats the diagnostic emitted when a dependency's compiled package cannot be located. +fn missing_dependency_package_message( + name: &str, + root: &Path, + package_stems: &[String], + output_dirs: &[PathBuf], + profiles: &[String], +) -> String { + let searched = output_dirs + .iter() + .map(|dir| format!("'{}'", dir.display())) + .collect::>() + .join(", "); + let expected_files = package_stems + .iter() + .flat_map(|stem| profiles.iter().map(move |profile| format!("{stem}.masp in {profile}"))) + .collect::>() + .join(", "); + let build_hint = dependency_build_hint(root); + + format!( + "could not find a built `.masp` package for Miden dependency '{name}' (root '{}'). The \ + SDK macros need the dependency package during Rust macro expansion to read its embedded \ + WIT and procedure roots. Expected one of: {expected_files}. Searched: {searched}. \ + {build_hint}", + root.display(), + ) +} + +/// Returns a command hint for building a dependency package before expanding dependent macros. +fn dependency_build_hint(root: &Path) -> String { + let manifest_path = root.join("Cargo.toml"); + if manifest_path.is_file() { + format!( + "Build the dependency first with `cargo miden build --manifest-path {} --release`, or \ + persist the compiled package to '{}/target/miden/' before compiling this \ + crate.", + manifest_path.display(), + root.display(), + ) + } else { + format!( + "Build the dependency first with `cargo miden build`, or persist the compiled package \ + to '{}/target/miden/' before compiling this crate.", + root.display(), + ) + } +} + +/// Returns candidate output directories where a dependency `.masp` may have been written. +fn dependency_output_dirs(root: &Path, profiles: &[String]) -> Vec { + let mut dirs = Vec::new(); + + // The dependency root is the most precise location for path dependencies. Prefer it over + // ambient target directories so restored or previously built artifacts cannot shadow the + // package that belongs to the dependency being wrapped. + push_profile_dirs(&mut dirs, root.join("target"), profiles); + push_manifest_ancestor_target_profile_dirs(&mut dirs, root, profiles); + push_ancestor_target_profile_dirs(&mut dirs, root, profiles); + + if let Ok(target_dir) = env::var("CARGO_TARGET_DIR") { + push_profile_dirs(&mut dirs, PathBuf::from(target_dir), profiles); + } + + if let Ok(out_dir) = env::var("OUT_DIR") { + for ancestor in Path::new(&out_dir).ancestors() { + push_profile_dirs(&mut dirs, ancestor.to_path_buf(), profiles); + } + } + + if let Ok(current_dir) = env::current_dir() { + push_profile_dirs(&mut dirs, current_dir.join("target"), profiles); + push_manifest_ancestor_target_profile_dirs(&mut dirs, ¤t_dir, profiles); + push_ancestor_target_profile_dirs(&mut dirs, ¤t_dir, profiles); + } + + dirs +} + +/// Adds `target/miden/` directories while preserving insertion order. +fn push_profile_dirs(dirs: &mut Vec, target_root: PathBuf, profiles: &[String]) { + for profile in profiles { + let dir = target_root.join("miden").join(profile); + if !dirs.iter().any(|existing| existing == &dir) { + dirs.push(dir); + } + } +} + +/// Adds `target/miden/` directories found in ancestors of `path`. +fn push_ancestor_target_profile_dirs(dirs: &mut Vec, path: &Path, profiles: &[String]) { + for ancestor in path.ancestors() { + if ancestor.file_name().is_some_and(|name| name == "target") { + push_profile_dirs(dirs, ancestor.to_path_buf(), profiles); + } + } +} + +/// Adds `target/miden/` directories for Cargo manifest ancestors. +fn push_manifest_ancestor_target_profile_dirs( + dirs: &mut Vec, + path: &Path, + profiles: &[String], +) { + for ancestor in path.ancestors() { + if ancestor.join("Cargo.toml").is_file() || ancestor.join("Cargo.lock").is_file() { + push_profile_dirs(dirs, ancestor.join("target"), profiles); + } + } +} + +/// Finds a dependency package in `dir`, preferring filenames that match the package name. +fn find_dependency_package_in_dir( + dir: &Path, + package_stems: &[String], +) -> Result, Error> { + if !dir.is_dir() { + return Ok(None); + } + + let mut packages = fs::read_dir(dir) + .map_err(|err| { + Error::new( + Span::call_site(), + format!("failed to read dependency output directory '{}': {err}", dir.display()), + ) + })? + .collect::, _>>() + .map_err(|err| { + Error::new( + Span::call_site(), + format!("failed to iterate dependency output directory '{}': {err}", dir.display()), + ) + })? + .into_iter() + .map(|entry| entry.path()) + .filter(|path| { + path.extension() + .is_some_and(|extension| extension.eq_ignore_ascii_case(Package::EXTENSION)) + }) + .collect::>(); + packages.sort(); + + for stem in package_stems { + if let Some(package) = packages.iter().find(|path| { + path.file_stem() + .and_then(|value| value.to_str()) + .is_some_and(|file_stem| file_stem == stem) + }) { + return Ok(Some(package.clone())); + } + } + + Ok((packages.len() == 1).then(|| packages[0].clone())) +} + +/// Returns likely `.masp` filename stems for a dependency. +fn dependency_package_stems(name: &str, root: &Path) -> Vec { + let mut stems = Vec::new(); + + if let Some(package_name) = dependency_manifest_package_name(root) { + push_dependency_stem(&mut stems, &package_name); + } + + if let Some(name) = name.split([':', '/']).next_back() { + push_dependency_stem(&mut stems, name); + } + + if let Some(name) = root.file_name().and_then(|name| name.to_str()) { + push_dependency_stem(&mut stems, name); + } + + stems +} + +/// Reads the Cargo package name for dependency directories. +fn dependency_manifest_package_name(root: &Path) -> Option { + let manifest_path = root.join("Cargo.toml"); + let manifest = fs::read_to_string(manifest_path).ok()?; + let manifest = manifest.parse::().ok()?; + manifest + .get("package") + .and_then(toml::Value::as_table) + .and_then(|package| package.get("name")) + .and_then(toml::Value::as_str) + .map(ToOwned::to_owned) +} + +/// Adds Miden package stem candidates if they have not already been added. +fn push_dependency_stem(stems: &mut Vec, name: &str) { + if !name.is_empty() && !stems.iter().any(|existing| existing == name) { + stems.push(name.to_owned()); + } + + let normalized = name.replace('-', "_"); + if !normalized.is_empty() && !stems.iter().any(|existing| existing == &normalized) { + stems.push(normalized); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn missing_cached_dependency_package_message_describes_the_cache_contract() { + let stems = vec!["counter".to_string(), "counter_component".to_string()]; + let cache_dir = Path::new("/target/miden/packages/0123456789abcdef"); + + let message = missing_cached_dependency_package_message( + "counter", + Path::new("/projects/counter"), + &stems, + cache_dir, + ); + + assert!(message.contains("'counter.masp'")); + assert!(message.contains("'counter_component.masp'")); + assert!(message.contains(&cache_dir.display().to_string())); + assert!(message.contains("populated by the enclosing midenc-driven build")); + assert!(!message.contains(" in release")); + assert!(!message.contains("target/miden/")); + } + + #[test] + fn dependency_stem_preserves_package_filename_before_legacy_alias() { + let mut stems = Vec::new(); + + push_dependency_stem(&mut stems, "no-arg-account"); + + assert_eq!(stems, ["no-arg-account", "no_arg_account"]); + } + + #[test] + fn dependency_output_dirs_include_manifest_ancestor_targets() { + let temp_root = env::temp_dir() + .join(format!("midenc-fpi-dependency-output-dirs-{}", std::process::id())); + let workspace_root = temp_root.join("workspace"); + let dependency_root = workspace_root.join("tests/fixtures/dependency"); + std::fs::create_dir_all(&dependency_root).unwrap(); + std::fs::write(workspace_root.join("Cargo.lock"), "").unwrap(); + std::fs::write(dependency_root.join("Cargo.toml"), "").unwrap(); + + let mut dirs = Vec::new(); + push_manifest_ancestor_target_profile_dirs( + &mut dirs, + &dependency_root, + &[String::from("release")], + ); + + assert_eq!(dirs[0], dependency_root.join("target/miden/release")); + assert!( + dirs.contains(&workspace_root.join("target/miden/release")), + "expected workspace target in {dirs:?}" + ); + + std::fs::remove_dir_all(temp_root).unwrap(); + } + + #[test] + fn missing_dependency_package_message_explains_macro_time_requirement() { + let temp_root = + env::temp_dir().join(format!("midenc-fpi-missing-package-{}", std::process::id())); + std::fs::create_dir_all(&temp_root).unwrap(); + std::fs::write(temp_root.join("Cargo.toml"), "[package]\nname = \"counter\"\n").unwrap(); + + let profiles = vec!["release".to_string(), "debug".to_string()]; + let stems = vec!["counter".to_string(), "counter_component".to_string()]; + let output_dirs = + vec![temp_root.join("target/miden/release"), temp_root.join("target/miden/debug")]; + + let message = missing_dependency_package_message( + "counter", + &temp_root, + &stems, + &output_dirs, + &profiles, + ); + + assert!(message.contains("could not find a built `.masp` package")); + assert!(message.contains("during Rust macro expansion")); + assert!(message.contains("embedded WIT and procedure roots")); + assert!(message.contains("counter.masp in release")); + assert!(message.contains("counter_component.masp in debug")); + assert!(message.contains("cargo miden build --manifest-path")); + assert!(message.contains(&temp_root.display().to_string())); + + std::fs::remove_dir_all(temp_root).unwrap(); + } +} diff --git a/sdk/base-macros/src/fpi.rs b/sdk/base-macros/src/fpi.rs index 64a325a47..55e190dd2 100644 --- a/sdk/base-macros/src/fpi.rs +++ b/sdk/base-macros/src/fpi.rs @@ -2,10 +2,9 @@ use std::{ collections::{HashMap, HashSet}, - env, fmt::Write as _, fs, - path::{Path, PathBuf}, + path::PathBuf, }; use heck::{ToKebabCase, ToSnakeCase}; @@ -25,8 +24,6 @@ use wit_bindgen_core::wit_parser::{ TypeOwner, WorldId, WorldItem, WorldKey, }; -#[cfg(test)] -use crate::wit_world::DependencyInterface; use crate::{ dependency_ref::DependencyRef, generate::{ @@ -1503,7 +1500,7 @@ fn load_dependency( ) -> syn::Result { let import = dependency.import().to_owned(); let module_path = import_module_path(&import); - let package_path = resolve_dependency_package_path(&dependency)?; + let package_path = dependency.package_path.clone(); let package_bytes = fs::read(&package_path).map_err(|err| { Error::new( Span::call_site(), @@ -1577,278 +1574,6 @@ pub(crate) fn import_module_path(import: &str) -> String { .join("::") } -/// Finds the `.masp` package artifact corresponding to a manifest dependency entry. -fn resolve_dependency_package_path(dependency: &SelectedDependency) -> syn::Result { - if dependency.root.is_file() { - return Ok(dependency.root.clone()); - } - - let package_stems = dependency_package_stems(dependency); - if let Some(filesystem_cache_dir) = std::env::var_os("MIDENC_PACKAGE_CACHE") { - let filesystem_cache_dir = PathBuf::from(filesystem_cache_dir); - if let Some(package) = - find_dependency_package_in_dir(&filesystem_cache_dir, &package_stems)? - { - return Ok(package.clone()); - } - - Err(Error::new( - Span::call_site(), - missing_cached_dependency_package_message( - dependency, - &package_stems, - &filesystem_cache_dir, - ), - )) - } else { - let preferred_profile = env::var("PROFILE").unwrap_or_else(|_| "debug".to_string()); - let mut profiles = vec![preferred_profile.clone()]; - if preferred_profile != "release" { - profiles.push("release".to_string()); - } - if preferred_profile != "debug" { - profiles.push("debug".to_string()); - } - let output_dirs = dependency_output_dirs(dependency, &profiles); - for dir in &output_dirs { - if let Some(package) = find_dependency_package_in_dir(dir, &package_stems)? { - return Ok(package.clone()); - } - } - Err(Error::new( - Span::call_site(), - missing_dependency_package_message(dependency, &package_stems, &output_dirs, &profiles), - )) - } -} - -/// Formats the diagnostic for a missing dependency in the build-owned package cache. -fn missing_cached_dependency_package_message( - dependency: &SelectedDependency, - package_stems: &[String], - filesystem_cache_dir: &Path, -) -> String { - let expected_files = package_stems - .iter() - .map(|stem| format!("'{stem}.masp'")) - .collect::>() - .join(", "); - - format!( - "miden::generate! could not find a built `.masp` package for FPI dependency '{}' (import \ - '{}', root '{}'). FPI wrappers need the dependency package during Rust macro expansion \ - to read procedure roots. Expected one of these package names: {expected_files}. Searched \ - MIDENC_PACKAGE_CACHE directory '{}'. This cache is populated by the enclosing \ - midenc-driven build; compile this crate as part of that build so its dependency packages \ - are available during macro expansion.", - dependency.name, - dependency.import(), - dependency.root.display(), - filesystem_cache_dir.display(), - ) -} - -/// Formats the diagnostic emitted when FPI wrapper generation cannot load a dependency package. -fn missing_dependency_package_message( - dependency: &SelectedDependency, - package_stems: &[String], - output_dirs: &[PathBuf], - profiles: &[String], -) -> String { - let searched = output_dirs - .iter() - .map(|dir| format!("'{}'", dir.display())) - .collect::>() - .join(", "); - let expected_files = package_stems - .iter() - .flat_map(|stem| profiles.iter().map(move |profile| format!("{stem}.masp in {profile}"))) - .collect::>() - .join(", "); - let build_hint = dependency_build_hint(dependency); - - format!( - "miden::generate! could not find a built `.masp` package for FPI dependency '{}' (import \ - '{}', root '{}'). FPI wrappers need the dependency package during Rust macro expansion \ - to read procedure roots. Expected one of: {expected_files}. Searched: {searched}. \ - {build_hint}", - dependency.name, - dependency.import(), - dependency.root.display(), - ) -} - -/// Returns a command hint for building a dependency package before generating FPI wrappers. -fn dependency_build_hint(dependency: &SelectedDependency) -> String { - let manifest_path = dependency.root.join("Cargo.toml"); - if manifest_path.is_file() { - format!( - "Build the dependency first with `cargo miden build --manifest-path {} --release`, or \ - persist the compiled package to '{}/target/miden/' before compiling this \ - crate.", - manifest_path.display(), - dependency.root.display(), - ) - } else { - format!( - "Build the dependency first with `cargo miden build`, or persist the compiled package \ - to '{}/target/miden/' before compiling this crate.", - dependency.root.display(), - ) - } -} - -/// Returns candidate output directories where a dependency `.masp` may have been written. -fn dependency_output_dirs(dependency: &SelectedDependency, profiles: &[String]) -> Vec { - let mut dirs = Vec::new(); - - // The dependency root is the most precise location for path dependencies. Prefer it over - // ambient target directories so restored or previously built artifacts cannot shadow the - // package that belongs to the dependency being wrapped. - push_profile_dirs(&mut dirs, dependency.root.join("target"), profiles); - push_manifest_ancestor_target_profile_dirs(&mut dirs, &dependency.root, profiles); - push_ancestor_target_profile_dirs(&mut dirs, &dependency.root, profiles); - - if let Ok(target_dir) = env::var("CARGO_TARGET_DIR") { - push_profile_dirs(&mut dirs, PathBuf::from(target_dir), profiles); - } - - if let Ok(out_dir) = env::var("OUT_DIR") { - for ancestor in Path::new(&out_dir).ancestors() { - push_profile_dirs(&mut dirs, ancestor.to_path_buf(), profiles); - } - } - - if let Ok(current_dir) = env::current_dir() { - push_profile_dirs(&mut dirs, current_dir.join("target"), profiles); - push_manifest_ancestor_target_profile_dirs(&mut dirs, ¤t_dir, profiles); - push_ancestor_target_profile_dirs(&mut dirs, ¤t_dir, profiles); - } - - dirs -} - -/// Adds `target/miden/` directories while preserving insertion order. -fn push_profile_dirs(dirs: &mut Vec, target_root: PathBuf, profiles: &[String]) { - for profile in profiles { - let dir = target_root.join("miden").join(profile); - if !dirs.iter().any(|existing| existing == &dir) { - dirs.push(dir); - } - } -} - -/// Adds `target/miden/` directories found in ancestors of `path`. -fn push_ancestor_target_profile_dirs(dirs: &mut Vec, path: &Path, profiles: &[String]) { - for ancestor in path.ancestors() { - if ancestor.file_name().is_some_and(|name| name == "target") { - push_profile_dirs(dirs, ancestor.to_path_buf(), profiles); - } - } -} - -/// Adds `target/miden/` directories for Cargo manifest ancestors. -fn push_manifest_ancestor_target_profile_dirs( - dirs: &mut Vec, - path: &Path, - profiles: &[String], -) { - for ancestor in path.ancestors() { - if ancestor.join("Cargo.toml").is_file() || ancestor.join("Cargo.lock").is_file() { - push_profile_dirs(dirs, ancestor.join("target"), profiles); - } - } -} - -/// Finds a dependency package in `dir`, preferring filenames that match the package name. -fn find_dependency_package_in_dir( - dir: &Path, - package_stems: &[String], -) -> syn::Result> { - if !dir.is_dir() { - return Ok(None); - } - - let mut packages = fs::read_dir(dir) - .map_err(|err| { - Error::new( - Span::call_site(), - format!("failed to read dependency output directory '{}': {err}", dir.display()), - ) - })? - .collect::, _>>() - .map_err(|err| { - Error::new( - Span::call_site(), - format!("failed to iterate dependency output directory '{}': {err}", dir.display()), - ) - })? - .into_iter() - .map(|entry| entry.path()) - .filter(|path| { - path.extension() - .is_some_and(|extension| extension.eq_ignore_ascii_case(Package::EXTENSION)) - }) - .collect::>(); - packages.sort(); - - for stem in package_stems { - if let Some(package) = packages.iter().find(|path| { - path.file_stem() - .and_then(|value| value.to_str()) - .is_some_and(|file_stem| file_stem == stem) - }) { - return Ok(Some(package.clone())); - } - } - - Ok(None) -} - -/// Returns likely `.masp` filename stems for a dependency. -fn dependency_package_stems(dependency: &SelectedDependency) -> Vec { - let mut stems = Vec::new(); - - if let Some(package_name) = dependency_manifest_package_name(&dependency.root) { - push_dependency_stem(&mut stems, &package_name); - } - - if let Some(name) = dependency.name.split([':', '/']).next_back() { - push_dependency_stem(&mut stems, name); - } - - if let Some(name) = dependency.root.file_name().and_then(|name| name.to_str()) { - push_dependency_stem(&mut stems, name); - } - - stems -} - -/// Reads the Cargo package name for dependency directories. -fn dependency_manifest_package_name(root: &Path) -> Option { - let manifest_path = root.join("Cargo.toml"); - let manifest = fs::read_to_string(manifest_path).ok()?; - let manifest = manifest.parse::().ok()?; - manifest - .get("package") - .and_then(toml::Value::as_table) - .and_then(|package| package.get("name")) - .and_then(toml::Value::as_str) - .map(ToOwned::to_owned) -} - -/// Adds Miden package stem candidates if they have not already been added. -fn push_dependency_stem(stems: &mut Vec, name: &str) { - if !name.is_empty() && !stems.iter().any(|existing| existing == name) { - stems.push(name.to_owned()); - } - - let normalized = name.replace('-', "_"); - if !normalized.is_empty() && !stems.iter().any(|existing| existing == &normalized) { - stems.push(normalized); - } -} - /// Extracts the WIT interface/function key encoded in a package procedure export path. fn procedure_root_key_from_export_path(path: &MasmPath) -> Option { let interface = single_non_root_path_component(path.parent()?)?; @@ -2035,99 +1760,6 @@ interface api { ); } - #[test] - fn dependency_stem_preserves_package_filename_before_legacy_alias() { - let mut stems = Vec::new(); - - push_dependency_stem(&mut stems, "no-arg-account"); - - assert_eq!(stems, ["no-arg-account", "no_arg_account"]); - } - - #[test] - fn dependency_output_dirs_include_manifest_ancestor_targets() { - let temp_root = env::temp_dir() - .join(format!("midenc-fpi-dependency-output-dirs-{}", std::process::id())); - let workspace_root = temp_root.join("workspace"); - let dependency_root = workspace_root.join("tests/fixtures/dependency"); - std::fs::create_dir_all(&dependency_root).unwrap(); - std::fs::write(workspace_root.join("Cargo.lock"), "").unwrap(); - std::fs::write(dependency_root.join("Cargo.toml"), "").unwrap(); - - let mut dirs = Vec::new(); - push_manifest_ancestor_target_profile_dirs( - &mut dirs, - &dependency_root, - &[String::from("release")], - ); - - assert_eq!(dirs[0], dependency_root.join("target/miden/release")); - assert!( - dirs.contains(&workspace_root.join("target/miden/release")), - "expected workspace target in {dirs:?}" - ); - - std::fs::remove_dir_all(temp_root).unwrap(); - } - - #[test] - fn missing_dependency_package_message_explains_macro_time_requirement() { - let temp_root = - env::temp_dir().join(format!("midenc-fpi-missing-package-{}", std::process::id())); - std::fs::create_dir_all(&temp_root).unwrap(); - std::fs::write(temp_root.join("Cargo.toml"), "[package]\nname = \"counter\"\n").unwrap(); - - let dependency = SelectedDependency { - name: "counter".to_string(), - root: temp_root.clone(), - interface: DependencyInterface { - name: "counter".to_string(), - import: "miden:counter/counter@0.0.1".to_string(), - types: Vec::new(), - }, - }; - let profiles = vec!["release".to_string(), "debug".to_string()]; - let stems = vec!["counter".to_string(), "counter_component".to_string()]; - let output_dirs = - vec![temp_root.join("target/miden/release"), temp_root.join("target/miden/debug")]; - - let message = - missing_dependency_package_message(&dependency, &stems, &output_dirs, &profiles); - - assert!(message.contains("miden::generate! could not find a built `.masp` package")); - assert!(message.contains("FPI wrappers need the dependency package during Rust macro")); - assert!(message.contains("counter.masp in release")); - assert!(message.contains("counter_component.masp in debug")); - assert!(message.contains("cargo miden build --manifest-path")); - assert!(message.contains(&temp_root.display().to_string())); - - std::fs::remove_dir_all(temp_root).unwrap(); - } - - #[test] - fn missing_cached_dependency_package_message_describes_the_cache_contract() { - let dependency = SelectedDependency { - name: "counter".to_string(), - root: PathBuf::from("/projects/counter"), - interface: DependencyInterface { - name: "counter".to_string(), - import: "miden:counter/counter@0.0.1".to_string(), - types: Vec::new(), - }, - }; - let stems = vec!["counter".to_string(), "counter_component".to_string()]; - let cache_dir = Path::new("/target/miden/packages/0123456789abcdef"); - - let message = missing_cached_dependency_package_message(&dependency, &stems, cache_dir); - - assert!(message.contains("'counter.masp'")); - assert!(message.contains("'counter_component.masp'")); - assert!(message.contains(&cache_dir.display().to_string())); - assert!(message.contains("populated by the enclosing midenc-driven build")); - assert!(!message.contains(" in release")); - assert!(!message.contains("target/miden/")); - } - #[test] fn procedure_root_key_rejects_nested_non_wit_export_path() { let path = MasmPath::validate( diff --git a/sdk/base-macros/src/generate.rs b/sdk/base-macros/src/generate.rs index 762828c8b..cf867bc3c 100644 --- a/sdk/base-macros/src/generate.rs +++ b/sdk/base-macros/src/generate.rs @@ -18,7 +18,7 @@ use wit_bindgen_core::{ }; use wit_bindgen_rust::{Opts, WithOption}; -use crate::{fpi, manifest_paths}; +use crate::{dependency_package::DependencyWitSource, fpi, manifest_paths}; /// Fully-qualified WIT interface path for Miden SDK core types. pub(crate) const CORE_TYPES_INTERFACE: &str = "miden:base/core-types@1.0.0"; @@ -139,6 +139,14 @@ pub(crate) fn expand(input: proc_macro::TokenStream) -> proc_macro::TokenStream .into(); } + // A bare `generate!()` over a local `wit/` directory is the manual component-authoring + // flow, so embed the crate's WIT the same way the `#[component]` macro does — the + // compiled package must carry it for dependent crates' macros to read. + let wit_link_section = match local_wit_link_section(&args, &config) { + Ok(tokens) => tokens, + Err(err) => return err.to_compile_error().into(), + }; + match generate_bindings(&args, &config, world_value.as_deref()) { Ok(raw_bindings) => quote! { // Wrap the bindings in the `bindings` module since `generate!` makes a top level @@ -149,6 +157,7 @@ pub(crate) fn expand(input: proc_macro::TokenStream) -> proc_macro::TokenStream pub mod bindings { #raw_bindings } + #wit_link_section } .into(), Err(err) => err.to_compile_error().into(), @@ -158,6 +167,31 @@ pub(crate) fn expand(input: proc_macro::TokenStream) -> proc_macro::TokenStream } } +/// Embeds the crate's local WIT into the component WIT custom section for bare `generate!()`. +/// +/// Inline invocations come from other SDK macros (which embed the WIT themselves when the crate +/// is a component), and multi-file `wit/` directories cannot be embedded verbatim, so both yield +/// no section. +fn local_wit_link_section( + args: &GenerateArgs, + config: &manifest_paths::ResolvedWit, +) -> Result { + if args.inline.is_some() { + return Ok(TokenStream2::new()); + } + let Some(local_wit_path) = &config.embeddable_local_wit else { + return Ok(TokenStream2::new()); + }; + + let wit_source = fs::read_to_string(local_wit_path).map_err(|err| { + Error::new( + Span::call_site(), + format!("failed to read WIT file '{}': {err}", local_wit_path.display()), + ) + })?; + Ok(crate::util::generate_wit_link_section(&wit_source)) +} + /// Generates WIT bindings using `wit-bindgen` directly instead of the `generate!` macro. /// /// The `world` parameter specifies which world to generate bindings for. This should already @@ -170,6 +204,7 @@ fn generate_bindings( ) -> Result { generate_bindings_from_sources( &config.paths, + &config.dependency_sources, args.inline.as_ref().map(|src| src.value()).as_deref(), world, &args.with_entries, @@ -188,6 +223,7 @@ pub(crate) fn generate_inline_fpi_bindings( ) -> Result { generate_bindings_from_sources( &config.paths, + &config.dependency_sources, Some(inline_source), Some(world), with_entries, @@ -208,6 +244,7 @@ pub(crate) fn generate_inline_import_bindings( ) -> Result { generate_bindings_from_sources( &config.paths, + &config.dependency_sources, Some(inline_source), Some(world), with_entries, @@ -219,13 +256,14 @@ pub(crate) fn generate_inline_import_bindings( /// Generates WIT bindings from resolved source paths and optional inline source. fn generate_bindings_from_sources( paths: &[String], + dependency_sources: &[DependencyWitSource], inline_source: Option<&str>, world: Option<&str>, with_entries: &[(String, WithOption)], fpi_imports: &[fpi::FpiImportSpec], scope_component_type_sections: bool, ) -> Result { - let mut wit_sources = load_wit_sources(paths, inline_source)?; + let mut wit_sources = load_wit_sources(paths, dependency_sources, inline_source)?; let world_id = wit_sources .resolve @@ -475,9 +513,10 @@ struct LoadedWitSources { files_read: Vec, } -/// Loads WIT sources from file paths and optionally an inline source. +/// Loads WIT sources from file paths, dependency packages, and optionally an inline source. fn load_wit_sources( paths: &[String], + dependency_sources: &[DependencyWitSource], inline_source: Option<&str>, ) -> Result { let manifest_dir = env::var("CARGO_MANIFEST_DIR").map_err(|err| { @@ -509,6 +548,23 @@ fn load_wit_sources( files.extend(sources.paths().map(|p| p.to_owned())); } + // Load WIT definitions embedded in the compiled packages of Miden path dependencies. The + // `.masp` paths are recorded like read files so rustc recompiles when a dependency package + // changes. + for source in dependency_sources { + let pkg = resolve.push_str(format!("{}.wit", source.name), &source.wit).map_err(|err| { + Error::new( + Span::call_site(), + format!( + "failed to load WIT embedded in dependency package '{}': {err}", + source.package_path.display() + ), + ) + })?; + packages.push(pkg); + files.push(source.package_path.clone()); + } + if let Some(src) = inline_source { // When inline source is provided, it becomes the primary package for world selection. // We clear previously collected package IDs because the inline source defines the world diff --git a/sdk/base-macros/src/lib.rs b/sdk/base-macros/src/lib.rs index 25cc8272a..285c7842e 100644 --- a/sdk/base-macros/src/lib.rs +++ b/sdk/base-macros/src/lib.rs @@ -67,6 +67,7 @@ extern crate proc_macro; mod account_component_metadata; mod boilerplate; mod component_macro; +mod dependency_package; mod dependency_ref; mod export_type; mod foreign_account; diff --git a/sdk/base-macros/src/manifest_paths.rs b/sdk/base-macros/src/manifest_paths.rs index b307cdcf1..397072448 100644 --- a/sdk/base-macros/src/manifest_paths.rs +++ b/sdk/base-macros/src/manifest_paths.rs @@ -10,6 +10,7 @@ use proc_macro2::Span; use syn::Error; use crate::{ + dependency_package::{DependencyWitSource, collect_dependency_wit_sources}, util::{bundled_wit_folder, strip_line_comment}, wit_world::ProjectPackageMetadata, }; @@ -23,7 +24,12 @@ pub(crate) const SDK_WIT_SOURCE: &str = include_str!("../wit/miden.wit"); /// WIT metadata extracted from the consuming crate. pub(crate) struct ResolvedWit { pub paths: Vec, + /// WIT sources read from the compiled packages of Miden path dependencies. + pub dependency_sources: Vec, pub world: Option, + /// The world-defining local WIT file, present when it is the crate's only WIT file and can + /// therefore be embedded verbatim as the component's public WIT. + pub embeddable_local_wit: Option, } #[derive(Default)] @@ -51,117 +57,15 @@ pub(crate) fn resolve_wit_paths(options: ResolveOptions) -> Result { - let raw_path = Path::new(path.path()).join("wit"); - let absolute = if raw_path.is_absolute() { - raw_path.to_path_buf() - } else { - Path::new(&manifest.manifest_dir).join(raw_path) - }; - let canonical = - fs::canonicalize(&absolute).unwrap_or_else(|_| absolute.clone()); - let Ok(metadata) = fs::metadata(&canonical) else { - continue; - }; - if !metadata.is_dir() { - continue; - } - let Some(path_str) = canonical.to_str() else { - continue; - }; - if !resolved.iter().any(|existing| existing == path_str) { - resolved.push(path_str.to_owned()); - } - } - // TODO(pauls): We should also handle git dependencies at some point - _ => continue, - } - } - - for (dependency, config) in dependencies { - let Some(table) = config.as_table() else { - return Err(Error::new( - Span::call_site(), - format!( - "invalid miden-project.toml configuration: expected \ - metadata.dependencies.{dependency} to be a table" - ), - )); - }; - let Some(wit) = table.get("wit") else { - continue; - }; - let Some(wit_path) = wit.as_str() else { - return Err(Error::new( - Span::call_site(), - format!( - "invalid miden-project.toml configuration: expected \ - metadata.dependencies.{dependency}.wit to be a string" - ), - )); - }; - let raw_path = Path::new(wit_path); - let absolute = if raw_path.is_absolute() { - raw_path.to_path_buf() - } else { - Path::new(&manifest.manifest_dir).join(raw_path) - }; - let canonical = fs::canonicalize(&absolute).unwrap_or_else(|_| absolute.clone()); - let metadata = fs::metadata(&canonical).map_err(|err| { - Error::new( - Span::call_site(), - format!( - "failed to read metadata for dependency '{dependency}' path '{}': {err}", - canonical.display() - ), - ) - })?; - - let search_path = if metadata.is_dir() { - canonical - } else if let Some(parent) = canonical.parent() { - parent.to_path_buf() - } else { - return Err(Error::new( - Span::call_site(), - format!( - "dependency '{dependency}' path '{}' does not have a parent directory", - canonical.display() - ), - )); - }; - - let path_str = search_path.to_str().ok_or_else(|| { - Error::new( - Span::call_site(), - format!("dependency '{dependency}' path contains invalid UTF-8"), - ) - })?; - - if !resolved.iter().any(|existing| existing == path_str) { - resolved.push(path_str.to_owned()); - } - } - } + // Dependency WIT is read from each path dependency's compiled `.masp` package rather than + // from files on disk; the sources are pushed into the wit-bindgen resolver alongside the + // file-based paths collected here. + let dependency_sources = + collect_dependency_wit_sources(&manifest.manifest_dir, &manifest.package)?; let local_wit_root = Path::new(&manifest.manifest_dir).join("wit"); let mut world = None; + let mut embeddable_local_wit = None; if local_wit_root.exists() && !options.allow_missing_local_wit { let local_root = fs::canonicalize(&local_wit_root).unwrap_or(local_wit_root); @@ -174,12 +78,17 @@ pub(crate) fn resolve_wit_paths(options: ResolveOptions) -> Result Result { Ok(fs::canonicalize(&autogenerated_wit_folder).unwrap_or(autogenerated_wit_folder)) } +/// A world detected in the crate's local `wit` directory. +struct LocalWorld { + /// `package/world` id used for wit-bindgen world selection. + world: String, + /// The world-defining WIT file, present when it is the directory's only WIT file and can + /// therefore be embedded verbatim as the component's public WIT. + embeddable_file: Option, +} + /// Scans the component's `wit` directory to find the default world. -fn detect_world_name(wit_root: &Path) -> Result, Error> { +fn detect_world(wit_root: &Path) -> Result, Error> { let mut entries = fs::read_dir(wit_root) .map_err(|err| { Error::new(Span::call_site(), format!("failed to read '{}': {err}", wit_root.display())) @@ -231,20 +149,23 @@ fn detect_world_name(wit_root: &Path) -> Result, Error> { })?; entries.sort_by_key(|entry| entry.file_name()); - for entry in entries { - let path = entry.path(); - if path.file_name().is_some_and(|name| name == "deps") { - continue; - } - if path.is_dir() { - continue; - } - if path.extension().and_then(|ext| ext.to_str()) != Some("wit") { - continue; - } - - if let Some((package, world)) = parse_package_and_world(&path)? { - return Ok(Some(format!("{package}/{world}"))); + let wit_files = entries + .into_iter() + .map(|entry| entry.path()) + .filter(|path| { + !path.file_name().is_some_and(|name| name == "deps") + && !path.is_dir() + && path.extension().and_then(|ext| ext.to_str()) == Some("wit") + }) + .collect::>(); + + for path in &wit_files { + if let Some((package, world)) = parse_package_and_world(path)? { + let embeddable_file = (wit_files.len() == 1).then(|| path.clone()); + return Ok(Some(LocalWorld { + world: format!("{package}/{world}"), + embeddable_file, + })); } } diff --git a/sdk/base-macros/src/util.rs b/sdk/base-macros/src/util.rs index d7e4fbf1e..11a82b720 100644 --- a/sdk/base-macros/src/util.rs +++ b/sdk/base-macros/src/util.rs @@ -1,7 +1,8 @@ use std::{env, fs, path::PathBuf}; use midenc_frontend_wasm_metadata::{ - FrontendMetadata, WASM_FRONTEND_METADATA_CUSTOM_SECTION_NAME, encode_section, + FrontendMetadata, WASM_COMPONENT_WIT_CUSTOM_SECTION_NAME, + WASM_FRONTEND_METADATA_CUSTOM_SECTION_NAME, encode_section, }; use proc_macro2::{Literal, Span, TokenStream as TokenStream2}; use quote::{format_ident, quote}; @@ -10,13 +11,14 @@ use syn::Error; /// Folder within a project that holds bundled WIT files const BUNDLED_WIT_DEPS_DIR: &str = "bundled-miden-wit"; -/// The prefix for the folder within a project that holds autogenerated WIT files -const GENERATED_WIT_DIR: &str = "generated-wit"; /// Rust item name used for the emitted frontend metadata bytes blob. const FRONTEND_METADATA_BYTES_STATIC_IDENT: &str = "__miden_frontend_metadata_bytes"; /// Linker symbol used to reject multiple frontend-marked procedures in one project. pub(crate) const FRONTEND_METADATA_UNIQUENESS_GUARD_SYMBOL: &str = "__MIDEN_FRONTEND_METADATA_UNIQUENESS_GUARD"; +/// Linker symbol used to reject multiple `#[component]` implementations in one project. +pub(crate) const COMPONENT_WIT_UNIQUENESS_GUARD_SYMBOL: &str = + "__MIDEN_COMPONENT_WIT_UNIQUENESS_GUARD"; fn target_folder() -> PathBuf { let mut manifest_dir = env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR is not set"); @@ -39,21 +41,6 @@ pub fn bundled_wit_folder() -> Result { Ok(wit_deps_dir) } -pub fn generated_wit_folder() -> Result { - let out_dir = target_folder(); - let wit_deps_dir = out_dir.join(GENERATED_WIT_DIR); - fs::create_dir_all(&wit_deps_dir).map_err(|err| { - Error::new( - Span::call_site(), - format!( - "failed to create WIT dependencies directory '{}': {err}", - wit_deps_dir.display() - ), - ) - })?; - Ok(wit_deps_dir) -} - /// Emits frontend-only metadata into the shared component frontend custom section. /// /// A component may need several entries (an optional `#[auth_script]` entry plus one entry per @@ -85,6 +72,34 @@ pub(crate) fn generate_frontend_link_section(entries: &[FrontendMetadata]) -> To } } +/// Embeds the component's public WIT source into the dedicated Wasm custom section. +pub(crate) fn generate_wit_link_section(wit_source: &str) -> TokenStream2 { + let wit_bytes = wit_source.as_bytes(); + let wit_len = wit_bytes.len(); + let encoded_bytes = Literal::byte_string(wit_bytes); + + quote! { + const _: () = { + // A linked binary may contain exactly one component implementation. Reusing a fixed + // symbol name lets the linker reject duplicates across modules or crates, which would + // otherwise concatenate into one unparseable WIT custom section. + #[doc(hidden)] + #[used] + #[unsafe(export_name = #COMPONENT_WIT_UNIQUENESS_GUARD_SYMBOL)] + static __miden_component_wit_uniqueness_guard: u8 = 0; + }; + + #[unsafe( + // Keep the Mach-O-friendly `segment,section` naming scheme used by the other metadata + // sections so the linker preserves these bytes in test and release builds. + link_section = #WASM_COMPONENT_WIT_CUSTOM_SECTION_NAME + )] + #[doc(hidden)] + #[allow(clippy::octal_escapes)] + pub static __MIDEN_COMPONENT_WIT: [u8; #wit_len] = *#encoded_bytes; + } +} + /// Strips line comments starting with `//` from the provided source line. /// /// Returns the portion of the line before the comment, or the entire line if no comment exists. diff --git a/sdk/base-macros/src/wit_world.rs b/sdk/base-macros/src/wit_world.rs index 9d69181c2..9dfaaea17 100644 --- a/sdk/base-macros/src/wit_world.rs +++ b/sdk/base-macros/src/wit_world.rs @@ -17,7 +17,10 @@ use wit_bindgen_core::wit_parser::{ InterfaceId, PackageId, Resolve, Type as WitType, TypeDefKind, TypeOwner, WorldItem, }; -use crate::wit_builder::WitBuilder; +use crate::{ + dependency_package::{DependencyWitSource, collect_dependency_wit_sources}, + wit_builder::WitBuilder, +}; /// Parsed package metadata from the consuming crate's manifest. pub struct ManifestPackage { @@ -260,8 +263,8 @@ impl ManifestPackage { pub(crate) struct MidenDependency { /// Manifest key used for this dependency. pub(crate) name: String, - /// Canonical project root or precompiled package path. - pub(crate) root: PathBuf, + /// Path of the compiled `.masp` package the dependency metadata was read from. + pub(crate) package_path: PathBuf, /// Exported WIT interfaces loaded from the dependency metadata. pub(crate) interfaces: Vec, } @@ -273,8 +276,7 @@ impl MidenDependency { .iter() .find(|interface| interface.name == interface_name) .map(|interface| SelectedDependency { - name: self.name.clone(), - root: self.root.clone(), + package_path: self.package_path.clone(), interface: interface.clone(), }) } @@ -291,10 +293,8 @@ impl MidenDependency { /// `pkg::Interface` macro argument resolves to one `SelectedDependency`. #[derive(Debug)] pub(crate) struct SelectedDependency { - /// Manifest key used for this dependency. - pub(crate) name: String, - /// Canonical project root or precompiled package path. - pub(crate) root: PathBuf, + /// Path of the compiled `.masp` package the dependency metadata was read from. + pub(crate) package_path: PathBuf, /// The selected exported WIT interface. pub(crate) interface: DependencyInterface, } @@ -358,6 +358,9 @@ pub(crate) fn write_world_block( } /// Collects dependency metadata needed for SDK-generated dependency imports. +/// +/// The dependency's exported interfaces are read from the component WIT embedded in its compiled +/// `.masp` package, which cargo-miden materializes before the dependent crate's macros expand. fn collect_miden_dependencies( manifest_dir: &Path, package: &miden_project::Package, @@ -365,204 +368,51 @@ fn collect_miden_dependencies( ) -> Result, syn::Error> { let mut dependencies = Vec::new(); - for dependency in package.dependencies() { - match dependency.scheme() { - miden_project::DependencyVersionScheme::Path { path, .. } => { - let absolute_path = manifest_dir.join(path.path()); - let dependency_root = fs::canonicalize(&absolute_path).map_err(|err| { - syn::Error::new( - error_span, - format!( - "failed to canonicalize dependency '{}' path '{}': {err}", - dependency.name(), - absolute_path.display() - ), - ) - })?; - let wit_root = - dependency_wit_root(manifest_dir, package, dependency, &dependency_root)?; - - let dependency_wit = parse_dependency_wit(&wit_root).map_err(|msg| { - syn::Error::new( - error_span, - dependency_wit_error_message(dependency, &dependency_root, &wit_root, &msg), - ) - })?; - - dependencies.push(MidenDependency { - name: dependency.name().to_string(), - root: dependency_root, - interfaces: dependency_wit.interfaces, - }); - } - _ => continue, - } - } - - dependencies.sort_by(|a, b| a.name.cmp(&b.name)); - - Ok(dependencies) -} - -/// Returns the WIT root for a dependency, honoring explicit Miden project metadata. -fn dependency_wit_root( - manifest_dir: &Path, - package: &miden_project::Package, - dependency: &miden_project::Dependency, - dependency_root: &Path, -) -> Result { - let error_span = Span::call_site(); - if let Some(wit_path) = package - .metadata() - .get("miden") - .and_then(|meta| meta.get("dependencies")) - .and_then(|value| value.as_table()) - .and_then(|dependencies| dependencies.get(dependency.name().as_ref())) - .and_then(|config| config.as_table()) - .and_then(|config| config.get("wit")) - { - let wit_path = wit_path.as_str().ok_or_else(|| { - syn::Error::new( - error_span, - format!( - "invalid miden-project.toml configuration: expected \ - package.metadata.miden.dependencies.{}.wit to be a string", - dependency.name() - ), - ) + for source in collect_dependency_wit_sources(manifest_dir, package)? { + let dependency_wit = parse_dependency_wit_source(&source.wit).map_err(|msg| { + syn::Error::new(error_span, dependency_wit_error_message(&source, &msg)) })?; - return canonicalize_dependency_wit_path(manifest_dir, dependency, wit_path, error_span); - } - - if dependency_root.is_file() { - return Err(syn::Error::new( - error_span, - format!( - "dependency '{}' points to file '{}', which can be used as a `.masp` package \ - artifact but cannot supply dependency WIT metadata; add a matching \ - package.metadata.miden.dependencies entry with a `wit` path to the dependency's \ - generated WIT", - dependency.name(), - dependency_root.display() - ), - )); - } - - Ok(dependency_root.to_path_buf()) -} - -/// Resolves an explicit dependency WIT path from manifest metadata. -fn canonicalize_dependency_wit_path( - manifest_dir: &Path, - dependency: &miden_project::Dependency, - path: &str, - error_span: Span, -) -> Result { - let raw_path = Path::new(path); - let absolute_path = if raw_path.is_absolute() { - raw_path.to_path_buf() - } else { - manifest_dir.join(raw_path) - }; - fs::canonicalize(&absolute_path).map_err(|err| { - syn::Error::new( - error_span, - format!( - "failed to resolve dependency WIT metadata for dependency '{}' from \ - package.metadata.miden.dependencies.{}.wit = '{}': '{}': {err}. The SDK macro \ - needs the dependency's generated WIT file or directory during Rust macro \ - expansion; generate the dependency WIT or update the `wit` path.", - dependency.name(), - dependency.name(), - path, - absolute_path.display() - ), - ) - }) -} -/// Parses the first exported WIT interface exposed by a dependency root or WIT file. -fn parse_dependency_wit(root: &Path) -> Result { - if root.is_file() { - return parse_dependency_wit_path(root)?.ok_or_else(|| { - format!("WIT file '{}' does not contain a world export", root.display()) + dependencies.push(MidenDependency { + name: source.name, + package_path: source.package_path, + interfaces: dependency_wit.interfaces, }); } - let wit_dirs = dependency_wit_candidate_paths(root); - let mut parser_errors = Vec::new(); - for path in &wit_dirs { - if !path.exists() { - continue; - } - match parse_dependency_wit_path(path) { - Ok(Some(info)) => return Ok(info), - Ok(None) => {} - Err(err) => { - parser_errors.push(format!("'{}': {err}", path.display())); - } - } - } - - if parser_errors.is_empty() { - Err("no WIT world definition found".to_string()) - } else { - Err(format!( - "no WIT world definition found; parser errors: {}", - parser_errors.join("; ") - )) - } -} + dependencies.sort_by(|a, b| a.name.cmp(&b.name)); -/// Returns the WIT paths searched for generated dependency metadata. -fn dependency_wit_candidate_paths(root: &Path) -> Vec { - if root.is_file() { - vec![root.to_path_buf()] - } else { - vec![root.to_path_buf(), root.join("wit"), root.join("target/generated-wit")] - } + Ok(dependencies) } /// Formats the dependency WIT diagnostic emitted by SDK macros. -fn dependency_wit_error_message( - dependency: &miden_project::Dependency, - dependency_root: &Path, - wit_root: &Path, - details: &str, -) -> String { - let candidates = dependency_wit_candidate_paths(wit_root) - .into_iter() - .map(|path| format!("'{}'", path.display())) - .collect::>() - .join(", "); - +fn dependency_wit_error_message(source: &DependencyWitSource, details: &str) -> String { format!( - "failed to load dependency WIT metadata for dependency '{}' (dependency root '{}', WIT \ - root '{}'): {details}. The SDK macro needs the dependency's generated WIT during Rust \ - macro expansion to construct dependency imports. Searched for WIT world definitions in: \ - {candidates}. Generate the dependency WIT by compiling the dependency component, or set \ - package.metadata.miden.dependencies.{}.wit to the generated WIT file or directory.", - dependency.name(), - dependency_root.display(), - wit_root.display(), - dependency.name() + "failed to load dependency WIT metadata for dependency '{}' (root '{}') from its compiled \ + package '{}': {details}. The SDK macros read the dependency's component WIT embedded in \ + the `.masp` package during Rust macro expansion to construct dependency imports; rebuild \ + the dependency with the current `cargo miden build`.", + source.name, + source.root.display(), + source.package_path.display(), ) } /// WIT metadata extracted from a dependency package. +#[derive(Debug)] struct DependencyWit { interfaces: Vec, } -/// Parses one WIT path and returns dependency metadata when it exports at least one interface. -fn parse_dependency_wit_path(path: &Path) -> Result, String> { +/// Parses dependency WIT source and returns metadata for its exported interfaces. +fn parse_dependency_wit_source(wit_source: &str) -> Result { let mut resolve = Resolve::default(); resolve .push_str("miden.wit", crate::manifest_paths::SDK_WIT_SOURCE) .map_err(|err| format!("failed to load bundled Miden WIT: {err}"))?; - let (package_id, _) = resolve - .push_path(path) - .map_err(|err| format!("failed to parse WIT path '{}': {err}", path.display()))?; + let package_id = resolve + .push_str("package.wit", wit_source) + .map_err(|err| format!("failed to parse embedded dependency WIT: {err}"))?; // Skip exported interfaces that cannot be turned into a referenceable import id (anonymous // inline interfaces, or interfaces in an unversioned package) rather than failing the whole @@ -574,10 +424,10 @@ fn parse_dependency_wit_path(path: &Path) -> Result, Strin .filter_map(|interface_id| dependency_interface_metadata(&resolve, interface_id).ok()) .collect::>(); if interfaces.is_empty() { - return Ok(None); + return Err("no exported WIT interface found in the embedded dependency WIT".to_string()); } - Ok(Some(DependencyWit { interfaces })) + Ok(DependencyWit { interfaces }) } /// Returns the interfaces exported by the worlds of the parsed package, in declaration order. @@ -660,7 +510,7 @@ fn is_dependency_interface_type( mod tests { use std::{ fs, - path::PathBuf, + path::{Path, PathBuf}, sync::Arc, time::{SystemTime, UNIX_EPOCH}, }; @@ -668,11 +518,10 @@ mod tests { use miden_assembly_syntax::{ast, debuginfo::Span as MidenSpan}; use miden_project::Uri; use proc_macro2::Span; - use toml::{Value, value::Table}; - use super::{ProjectPackageMetadata, collect_miden_dependencies, parse_dependency_wit}; + use super::{ProjectPackageMetadata, collect_miden_dependencies, parse_dependency_wit_source}; - // This WIT is generated for the basic wallet example at examples/basic-wallet/target/generated-wit/miden-basic-wallet.wit + // This WIT matches what the `#[component]` macro embeds into the basic wallet example package. const BASIC_WALLET_GENERATED_WIT: &str = r#"// This file is auto-generated by the `#[component]` macro. // Do not edit this file manually. @@ -708,13 +557,43 @@ world basic-wallet-world { format!("{pid}-{nanos}-{count}") } + /// Writes a minimal `.masp` package fixture, optionally embedding `wit` in the WIT section. + fn write_masp_fixture(package_path: &Path, wit: Option<&str>) { + use miden_assembly::{Assembler, DefaultSourceManager, ModuleParser, ast::ModuleKind}; + + let source_manager = Arc::new(DefaultSourceManager::default()); + let module = ModuleParser::new(Some(ModuleKind::Library)) + .parse_str( + Some(miden_assembly::Path::new("dep")), + "pub proc callee(a: felt) -> felt\n add.1\nend", + source_manager.clone(), + ) + .expect("fixture module must parse"); + let mut package = Assembler::new(source_manager) + .assemble_library("basic-wallet", module, None::>) + .expect("fixture library must assemble"); + package.version = "0.1.0".parse().expect("fixture version must parse"); + if let Some(wit) = wit { + package.sections.push(miden_mast_package::Section::new( + crate::dependency_package::wit_section_id(), + wit.as_bytes().to_vec(), + )); + } + + use miden_protocol::utils::serde::Serializable; + fs::create_dir_all(package_path.parent().expect("package path must have a parent")) + .expect("package directory must be created"); + fs::write(package_path, package.to_bytes()).expect("package fixture must be written"); + } + + /// Creates a dependency project root with a compiled package under `target/miden/debug`. fn basic_wallet_fixture_root() -> PathBuf { let unique = unique_fixture_suffix(); let root = std::env::temp_dir().join(format!("miden-base-macros-wit-world-{unique}")); - let generated_wit_dir = root.join("target/generated-wit"); - fs::create_dir_all(&generated_wit_dir).expect("generated-wit directory must be created"); - fs::write(generated_wit_dir.join("miden-basic-wallet.wit"), BASIC_WALLET_GENERATED_WIT) - .expect("basic wallet fixture must be written"); + write_masp_fixture( + &root.join("target/miden/debug/basic_wallet.masp"), + Some(BASIC_WALLET_GENERATED_WIT), + ); root } @@ -725,10 +604,7 @@ world basic-wallet-world { root } - fn package_with_dependency( - package_path: PathBuf, - wit_path: Option, - ) -> Box { + fn package_with_dependency(package_path: PathBuf) -> Box { let target = miden_project::Target::new( miden_project::TargetType::Library, "default", @@ -743,38 +619,7 @@ world basic-wallet-world { }, miden_project::Linkage::Dynamic, ); - let package = - miden_project::Package::new("consumer", target).with_dependencies([dependency]); - - if let Some(wit_path) = wit_path { - package.with_metadata(miden_metadata_dependencies( - "basic-wallet", - wit_path.to_string_lossy().as_ref(), - )) - } else { - package - } - } - - fn miden_metadata_dependencies( - dependency_name: &str, - wit_path: &str, - ) -> miden_project::MetadataSet { - let mut dependency_config = Table::new(); - dependency_config.insert("wit".to_string(), Value::String(wit_path.to_string())); - - let mut dependencies = Table::new(); - dependencies.insert(dependency_name.to_string(), Value::Table(dependency_config)); - - let mut miden_metadata = miden_project::Metadata::default(); - miden_metadata.insert( - MidenSpan::unknown(Arc::::from("dependencies")), - MidenSpan::unknown(Value::Table(dependencies)), - ); - - let mut metadata = miden_project::MetadataSet::default(); - metadata.insert(MidenSpan::unknown(Arc::::from("miden")), miden_metadata); - metadata + miden_project::Package::new("consumer", target).with_dependencies([dependency]) } #[test] @@ -828,9 +673,6 @@ path = "src/lib.rs" #[test] fn parses_exported_interface_type_names_with_wit_parser() { - let fixture_root = empty_fixture_root(); - let wit_dir = fixture_root.join("target/generated-wit"); - fs::create_dir_all(&wit_dir).expect("generated-wit directory must be created"); let wit = r#" package miden:typed-account@0.0.1; @@ -855,9 +697,8 @@ world typed-account-world { export typed-account; } "#; - fs::write(wit_dir.join("typed-account.wit"), wit).expect("typed WIT fixture"); - let dependency_wit = parse_dependency_wit(&fixture_root).unwrap(); + let dependency_wit = parse_dependency_wit_source(wit).unwrap(); assert_eq!(dependency_wit.interfaces.len(), 1); assert_eq!(dependency_wit.interfaces[0].name, "typed-account"); @@ -866,15 +707,10 @@ world typed-account-world { dependency_wit.interfaces[0].types, vec!["mixed-scalar-record", "options", "amount"] ); - - fs::remove_dir_all(fixture_root).expect("temporary fixture directory must be removed"); } #[test] fn parses_all_exported_interfaces_and_selects_by_name() { - let fixture_root = empty_fixture_root(); - let wit_dir = fixture_root.join("target/generated-wit"); - fs::create_dir_all(&wit_dir).expect("generated-wit directory must be created"); let wit = r#" package miden:multi-account@0.0.1; @@ -891,12 +727,11 @@ world multi-account-world { export second-api; } "#; - fs::write(wit_dir.join("multi-account.wit"), wit).expect("multi-interface WIT fixture"); - let dependency_wit = parse_dependency_wit(&fixture_root).unwrap(); + let dependency_wit = parse_dependency_wit_source(wit).unwrap(); let dependency = super::MidenDependency { name: "multi-account".to_string(), - root: fixture_root.clone(), + package_path: PathBuf::from("/tmp/multi-account/target/miden/debug/multi_account.masp"), interfaces: dependency_wit.interfaces, }; @@ -904,18 +739,13 @@ world multi-account-world { let selected = dependency.select("second-api").expect("interface must be selectable"); assert_eq!(selected.import(), "miden:multi-account/second-api@0.0.1"); - assert_eq!(selected.name, "multi-account"); + assert_eq!(selected.package_path, dependency.package_path); assert!(dependency.select("missing-api").is_none()); - - fs::remove_dir_all(fixture_root).expect("temporary fixture directory must be removed"); } #[test] fn skips_anonymous_exported_interfaces() { - let fixture_root = empty_fixture_root(); - let wit_dir = fixture_root.join("target/generated-wit"); - fs::create_dir_all(&wit_dir).expect("generated-wit directory must be created"); // The world exports a named interface plus an inline (anonymous) one; the anonymous export // must be skipped rather than failing the whole dependency parse. let wit = r#" @@ -932,161 +762,118 @@ world mixed-export-world { } } "#; - fs::write(wit_dir.join("mixed-export.wit"), wit).expect("mixed-export WIT fixture"); - let dependency_wit = parse_dependency_wit(&fixture_root).unwrap(); + let dependency_wit = parse_dependency_wit_source(wit).unwrap(); assert_eq!(dependency_wit.interfaces.len(), 1); assert_eq!(dependency_wit.interfaces[0].name, "named-api"); - - fs::remove_dir_all(fixture_root).expect("temporary fixture directory must be removed"); } #[test] - fn parses_generated_component_world_from_dependency_root() { - let fixture_root = basic_wallet_fixture_root(); - let dependency_wit = parse_dependency_wit(&fixture_root).unwrap(); - - assert_eq!(dependency_wit.interfaces.len(), 1); - assert_eq!(dependency_wit.interfaces[0].import, "miden:basic-wallet/basic-wallet@0.1.0"); - assert!(dependency_wit.interfaces[0].types.is_empty()); - - fs::remove_dir_all(fixture_root).expect("temporary fixture directory must be removed"); - } + fn dependency_wit_without_exported_interfaces_reports_error() { + // An embedded WIT whose world exports nothing referenceable must produce a parse error + // rather than an empty dependency. + let wit = r#" +package miden:empty-export@0.0.1; - #[test] - fn parses_direct_generated_wit_directory() { - let fixture_root = basic_wallet_fixture_root(); - let dependency_wit = - parse_dependency_wit(&fixture_root.join("target/generated-wit")).unwrap(); +world empty-export-world { +} +"#; - assert_eq!(dependency_wit.interfaces.len(), 1); - assert_eq!(dependency_wit.interfaces[0].import, "miden:basic-wallet/basic-wallet@0.1.0"); - assert!(dependency_wit.interfaces[0].types.is_empty()); + let err = parse_dependency_wit_source(wit).unwrap_err(); - fs::remove_dir_all(fixture_root).expect("temporary fixture directory must be removed"); + assert!(err.contains("no exported WIT interface found"), "unexpected error: {err}"); } #[test] - fn miden_file_dependency_uses_project_wit_metadata() { + fn collects_dependency_interfaces_from_compiled_package() { let fixture_root = basic_wallet_fixture_root(); - let package_path = fixture_root.join("target/release/basic_wallet.masp"); - fs::create_dir_all(package_path.parent().expect("package path must have a parent")) - .expect("package directory must be created"); - fs::write(&package_path, b"package bytes").expect("package fixture must be written"); + let dependency_root = fixture_root.clone(); - let package = package_with_dependency( - package_path.clone(), - Some(fixture_root.join("target/generated-wit")), - ); + let package = package_with_dependency(dependency_root.clone()); let dependencies = collect_miden_dependencies(&fixture_root, &package, proc_macro2::Span::call_site()) .unwrap(); - let package_path = - fs::canonicalize(package_path).expect("package path fixture must canonicalize"); assert_eq!(dependencies.len(), 1); - assert_eq!(dependencies[0].root, package_path); assert_eq!(dependencies[0].interface_names(), vec!["basic-wallet"]); assert_eq!(dependencies[0].interfaces[0].import, "miden:basic-wallet/basic-wallet@0.1.0"); + assert!(dependencies[0].interfaces[0].types.is_empty()); + assert!( + dependencies[0].package_path.ends_with("target/miden/debug/basic_wallet.masp"), + "unexpected package path: {}", + dependencies[0].package_path.display() + ); fs::remove_dir_all(fixture_root).expect("temporary fixture directory must be removed"); } #[test] - fn missing_dependency_wit_reports_actionable_sdk_macro_error() { + fn file_dependency_reads_wit_from_masp_package() { + // A dependency that points directly at a `.masp` file is self-contained: the embedded WIT + // is read from that package with no additional manifest metadata. let fixture_root = empty_fixture_root(); - let dependency_root = fixture_root.join("basic-wallet"); - fs::create_dir_all(&dependency_root).expect("dependency fixture directory must be created"); - let package = package_with_dependency(dependency_root, None); + let package_path = fixture_root.join("prebuilt/basic_wallet.masp"); + write_masp_fixture(&package_path, Some(BASIC_WALLET_GENERATED_WIT)); - let error = + let package = package_with_dependency(package_path.clone()); + + let dependencies = collect_miden_dependencies(&fixture_root, &package, proc_macro2::Span::call_site()) - .expect_err("dependency without generated WIT must fail dependency metadata load"); - let message = error.to_string(); + .unwrap(); + let package_path = + fs::canonicalize(package_path).expect("package path fixture must canonicalize"); - assert!( - message - .contains("failed to load dependency WIT metadata for dependency 'basic-wallet'"), - "unexpected error: {message}" - ); - assert!( - message.contains("The SDK macro needs the dependency's generated WIT"), - "unexpected error: {message}" - ); - assert!(message.contains("target/generated-wit"), "unexpected error: {message}"); - assert!( - message.contains("package.metadata.miden.dependencies.basic-wallet.wit"), - "unexpected error: {message}" - ); - assert!(message.contains("Generate the dependency WIT"), "unexpected error: {message}"); + assert_eq!(dependencies.len(), 1); + assert_eq!(dependencies[0].package_path, package_path); + assert_eq!(dependencies[0].interface_names(), vec!["basic-wallet"]); + assert_eq!(dependencies[0].interfaces[0].import, "miden:basic-wallet/basic-wallet@0.1.0"); fs::remove_dir_all(fixture_root).expect("temporary fixture directory must be removed"); } #[test] - fn missing_explicit_dependency_wit_reports_actionable_sdk_macro_error() { + fn missing_dependency_package_reports_actionable_error() { let fixture_root = empty_fixture_root(); let dependency_root = fixture_root.join("basic-wallet"); fs::create_dir_all(&dependency_root).expect("dependency fixture directory must be created"); - let package = package_with_dependency( - dependency_root, - Some(PathBuf::from("target/generated-wit/missing.wit")), - ); + let package = package_with_dependency(dependency_root); let error = collect_miden_dependencies(&fixture_root, &package, proc_macro2::Span::call_site()) - .expect_err( - "missing explicit dependency WIT path must fail dependency metadata load", - ); + .expect_err("dependency without a compiled package must fail metadata load"); let message = error.to_string(); assert!( - message.contains( - "failed to resolve dependency WIT metadata for dependency 'basic-wallet'" - ), - "unexpected error: {message}" - ); - assert!( - message.contains("package.metadata.miden.dependencies.basic-wallet.wit"), + message.contains("could not find a built `.masp` package"), "unexpected error: {message}" ); assert!( - message.contains("target/generated-wit/missing.wit"), + message.contains("Miden dependency 'basic-wallet'"), "unexpected error: {message}" ); - assert!( - message - .contains("The SDK macro needs the dependency's generated WIT file or directory"), - "unexpected error: {message}" - ); - assert!(message.contains("update the `wit` path"), "unexpected error: {message}"); + assert!(message.contains("during Rust macro expansion"), "unexpected error: {message}"); + assert!(message.contains("cargo miden build"), "unexpected error: {message}"); fs::remove_dir_all(fixture_root).expect("temporary fixture directory must be removed"); } #[test] - fn miden_file_dependency_without_project_wit_reports_typed_fpi_error() { - let fixture_root = basic_wallet_fixture_root(); - let package_path = fixture_root.join("target/release/basic_wallet.masp"); - fs::create_dir_all(package_path.parent().expect("package path must have a parent")) - .expect("package directory must be created"); - fs::write(&package_path, b"package bytes").expect("package fixture must be written"); - - let package = package_with_dependency(package_path, None); + fn package_without_wit_section_reports_rebuild_error() { + let fixture_root = empty_fixture_root(); + let dependency_root = fixture_root.join("basic-wallet"); + write_masp_fixture(&dependency_root.join("target/miden/debug/basic_wallet.masp"), None); + let package = package_with_dependency(dependency_root); let error = collect_miden_dependencies(&fixture_root, &package, proc_macro2::Span::call_site()) - .expect_err("artifact-only dependency must not provide dependency WIT metadata"); + .expect_err("package without an embedded WIT section must fail metadata load"); let message = error.to_string(); - assert!(message.contains("points to file"), "unexpected error: {message}"); - assert!( - message.contains("package.metadata.miden.dependencies"), - "unexpected error: {message}" - ); - assert!(message.contains("dependency WIT metadata"), "unexpected error: {message}"); + assert!(message.contains("does not embed component WIT"), "unexpected error: {message}"); + assert!(message.contains("older Miden toolchain"), "unexpected error: {message}"); + assert!(message.contains("cargo miden build"), "unexpected error: {message}"); fs::remove_dir_all(fixture_root).expect("temporary fixture directory must be removed"); } diff --git a/sdk/sdk/MIGRATION.md b/sdk/sdk/MIGRATION.md index db40de48d..36c673595 100644 --- a/sdk/sdk/MIGRATION.md +++ b/sdk/sdk/MIGRATION.md @@ -8,10 +8,12 @@ The most recent migration is at the top. When cutting a new release, add its mig directly below this paragraph, above the previous one (newest first, like the [CHANGELOG](./CHANGELOG.md)). - + ## Unreleased + + ### Kernel scalars are typed instead of `Felt` (counts, block heights, nonces, attachments) Binding surfaces whose values are counts now return `u32`: `tx::get_num_input_notes`, @@ -242,6 +244,41 @@ let asset = miden::native_account::get_initial_asset(asset_key); context (runtime-enforced). Tx/note scripts must create notes through an account component wrapper method (see the `basic-wallet` example's `create_note`). +### Component WIT is embedded in the compiled package + +The component WIT generated by `#[component]` is now embedded in the compiled Miden package (a +`wit` section of the `.masp`) instead of being written to `target/generated-wit/`. The +`#[account(...)]`, sibling `#[component(pkg::Interface)]`, `#[note]`, and `#[tx_script]` macros +read dependency WIT from the dependency's compiled `.masp`, so WIT path metadata is no longer read +and every Miden path dependency must be a built package (cargo-miden builds path dependencies +automatically; a dependency that points directly at a prebuilt `.masp` file is self-contained). + +Remove the `wit = "..."` entries from `[package.metadata.miden.dependencies]` in +`miden-project.toml`. + +Before: + +```toml +[dependencies] +basic-wallet = { path = "../basic-wallet" } + +[package.metadata.miden.dependencies] +basic-wallet = { wit = "../basic-wallet/target/generated-wit/" } +``` + +After: + +```toml +[dependencies] +basic-wallet = { path = "../basic-wallet" } +``` + +A `.masp` built by an older SDK has no embedded WIT and is rejected during macro expansion with a +"does not embed component WIT" error; rebuild each dependency with the current toolchain +(`cargo miden build`). Components written without the `#[component]` macro — a hand-written `wit/` +directory and a bare `miden::generate!()` — embed their WIT automatically when the `wit/` +directory contains a single `.wit` file, so no changes are needed there. + ## 0.13.0 -> 0.13.1 ### `*_note::get_metadata` returns a single-word `NoteMetadata` diff --git a/sdk/wasm-metadata/src/lib.rs b/sdk/wasm-metadata/src/lib.rs index d18fce703..28f0c9374 100644 --- a/sdk/wasm-metadata/src/lib.rs +++ b/sdk/wasm-metadata/src/lib.rs @@ -15,6 +15,12 @@ use serde::{Deserialize, Serialize}; pub const WASM_FRONTEND_METADATA_CUSTOM_SECTION_NAME: &str = "rodata,miden_account_component_frontend"; +/// Name of the Wasm custom section used to store the component's public WIT source. +pub const WASM_COMPONENT_WIT_CUSTOM_SECTION_NAME: &str = "rodata,miden_wit"; + +/// Name of the Miden package (`.masp`) section that carries the component's public WIT source. +pub const PACKAGE_WIT_SECTION_ID: &str = "wit"; + /// Frontend-only metadata emitted by the SDK macros into a dedicated Wasm custom section. #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] #[serde(tag = "kind", rename_all = "snake_case")] diff --git a/tests/fixtures/components/component-macros-note/miden-project.toml b/tests/fixtures/components/component-macros-note/miden-project.toml index 7d10612a9..31110cb6e 100644 --- a/tests/fixtures/components/component-macros-note/miden-project.toml +++ b/tests/fixtures/components/component-macros-note/miden-project.toml @@ -9,6 +9,3 @@ path = "src/lib.rs" [dependencies] component_macros = { path = "../component-macros-account" } - -[package.metadata.miden.dependencies] -component_macros = { wit = "../component-macros-account/target/generated-wit" } diff --git a/tests/fixtures/components/cross-ctx-note-word-arg/miden-project.toml b/tests/fixtures/components/cross-ctx-note-word-arg/miden-project.toml index be8a4ab39..702b0c3c9 100644 --- a/tests/fixtures/components/cross-ctx-note-word-arg/miden-project.toml +++ b/tests/fixtures/components/cross-ctx-note-word-arg/miden-project.toml @@ -9,6 +9,3 @@ path = "src/lib.rs" [dependencies] cross-ctx-account-word-arg = { path = "../cross-ctx-account-word-arg" } - -[package.metadata.miden.dependencies] -cross-ctx-account-word-arg = { wit = "../cross-ctx-account-word-arg/wit/cross-ctx-account-word.wit" } diff --git a/tests/fixtures/components/cross-ctx-note-word/miden-project.toml b/tests/fixtures/components/cross-ctx-note-word/miden-project.toml index 99824f7b5..7f33ff0e6 100644 --- a/tests/fixtures/components/cross-ctx-note-word/miden-project.toml +++ b/tests/fixtures/components/cross-ctx-note-word/miden-project.toml @@ -9,6 +9,3 @@ path = "src/lib.rs" [dependencies] cross-ctx-account-word = { path = "../cross-ctx-account-word" } - -[package.metadata.miden.dependencies] -cross-ctx-account-word = { wit = "../cross-ctx-account-word/wit/cross-ctx-account-word.wit" } diff --git a/tests/fixtures/components/cross-ctx-note/miden-project.toml b/tests/fixtures/components/cross-ctx-note/miden-project.toml index 834abadd0..8f35c23c7 100644 --- a/tests/fixtures/components/cross-ctx-note/miden-project.toml +++ b/tests/fixtures/components/cross-ctx-note/miden-project.toml @@ -9,6 +9,3 @@ path = "src/lib.rs" [dependencies] cross-ctx-account = { path = "../cross-ctx-account" } - -[package.metadata.miden.dependencies] -cross-ctx-account = { wit = "../cross-ctx-account/wit/cross-ctx-account.wit" } diff --git a/tests/fixtures/components/swapp-note/miden-project.toml b/tests/fixtures/components/swapp-note/miden-project.toml index 776520703..1e1585672 100644 --- a/tests/fixtures/components/swapp-note/miden-project.toml +++ b/tests/fixtures/components/swapp-note/miden-project.toml @@ -9,6 +9,3 @@ path = "src/lib.rs" [dependencies] basic-wallet = { path = "../../../../examples/basic-wallet" } - -[package.metadata.miden.dependencies] -basic-wallet = { wit = "../../../../examples/basic-wallet/target/generated-wit/" } diff --git a/tests/integration-network/src/mockchain/fpi/common.rs b/tests/integration-network/src/mockchain/fpi/common.rs index c11fddc5e..6c7b713fc 100644 --- a/tests/integration-network/src/mockchain/fpi/common.rs +++ b/tests/integration-network/src/mockchain/fpi/common.rs @@ -404,7 +404,6 @@ fn dependent_account_miden_project_toml( ) -> String { let namespace = account_component_namespace(account_package, "caller-account"); let dependency_name = miden_dependency_name(dependency_package); - let dependency_wit_path = dependency_root.join("target/generated-wit"); format!( r#" [package] @@ -423,12 +422,8 @@ miden-protocol = "*" [package.metadata.miden] supported-types = ["RegularAccountUpdatableCode"] - -[package.metadata.miden.dependencies] -"{dependency_name}" = {{ wit = "{dependency_wit_path}" }} "#, dependency_root = dependency_root.display(), - dependency_wit_path = dependency_wit_path.display(), ) } @@ -440,18 +435,13 @@ fn dependent_account_cargo_toml( dependency_root: &Path, ) -> String { let mut manifest = account_cargo_toml_for(account_name, account_package); - let dependency_wit_path = dependency_root.join("target/generated-wit"); manifest.push_str(&format!( r#" [package.metadata.miden.dependencies] "{dependency_package}" = {{ path = "{dependency_root}" }} - -[package.metadata.component.target.dependencies] -"{dependency_package}" = {{ path = "{dependency_wit_path}" }} "#, dependency_package = dependency_package, dependency_root = dependency_root.display(), - dependency_wit_path = dependency_wit_path.display(), )); manifest } diff --git a/tests/integration-network/src/mockchain/support/projects.rs b/tests/integration-network/src/mockchain/support/projects.rs index bc8dc212f..a68b331fe 100644 --- a/tests/integration-network/src/mockchain/support/projects.rs +++ b/tests/integration-network/src/mockchain/support/projects.rs @@ -169,7 +169,10 @@ debug = false manifest } -/// Appends path dependencies and WIT mappings to a generated Miden project manifest. +/// Appends path dependencies to a generated Miden project manifest. +/// +/// Dependency WIT is read from each dependency's compiled `.masp` package, so no WIT path +/// metadata is emitted. pub(crate) fn append_miden_project_dependencies( manifest: &mut String, dependencies: &[(&str, &Path)], @@ -183,23 +186,6 @@ pub(crate) fn append_miden_project_dependencies( dependency_root = dependency_root.display(), )); } - - manifest.push_str( - r#" -[package.metadata.miden.dependencies] -"#, - ); - - for (dependency_package, dependency_root) in dependencies { - let dependency_name = miden_dependency_name(dependency_package); - let dependency_wit_path = dependency_root.join("target/generated-wit"); - manifest.push_str(&format!( - r#" -"{dependency_name}" = {{ wit = "{dependency_wit_path}" }} -"#, - dependency_wit_path = dependency_wit_path.display(), - )); - } } /// Appends package metadata for dependencies to a generated Cargo manifest. @@ -221,22 +207,6 @@ pub(crate) fn append_cargo_dependency_metadata( dependency_root = dependency_root.display(), )); } - - manifest.push_str( - r#" -[package.metadata.component.target.dependencies] -"#, - ); - for (dependency_package, dependency_root) in dependencies { - let dependency_wit_path = dependency_root.join("target/generated-wit"); - manifest.push_str(&format!( - r#" -"{dependency_package}" = {{ path = "{dependency_wit_path}" }} -"#, - dependency_package = dependency_package, - dependency_wit_path = dependency_wit_path.display(), - )); - } } /// Returns the package-local dependency name accepted by `miden-project.toml`. diff --git a/tests/integration/Cargo.toml b/tests/integration/Cargo.toml index d2a68c665..6a227d181 100644 --- a/tests/integration/Cargo.toml +++ b/tests/integration/Cargo.toml @@ -33,6 +33,7 @@ midenc-dialect-hir.workspace = true midenc-dialect-scf.workspace = true midenc-dialect-wasm.workspace = true midenc-frontend-wasm.workspace = true +midenc-frontend-wasm-metadata.workspace = true midenc-hir = { workspace = true, features = ["logging"] } midenc-hir-eval.workspace = true midenc-session.workspace = true diff --git a/tests/integration/src/sdk/canonabi.rs b/tests/integration/src/sdk/canonabi.rs index c9367e1c5..62d698c4b 100644 --- a/tests/integration/src/sdk/canonabi.rs +++ b/tests/integration/src/sdk/canonabi.rs @@ -1,6 +1,6 @@ //! Integration tests for component-model CanonABI values. -use std::{fs, path::Path}; +use std::path::Path; use midenc_frontend_wasm::WasmTranslationConfig; use midenc_integration_test_support::{ @@ -119,7 +119,6 @@ fn build_note_project( note_body: &str, ) -> Project { let sdk_path = sdk_crate_path(); - let generated_wit = account_root.join("target/generated-wit"); let cargo_toml = format!( r#"cargo-features = ["trim-paths"] @@ -143,9 +142,6 @@ project-kind = "note-script" [package.metadata.miden.dependencies] "miden:{account_slug}" = {{ path = "{account_root}" }} -[package.metadata.component.target.dependencies] -"miden:{account_slug}" = {{ path = "{generated_wit}" }} - [profile.release] trim-paths = ["diagnostics", "object"] @@ -157,7 +153,6 @@ trim-paths = ["diagnostics", "object"] account_slug = names.account_slug, sdk_path = sdk_path.display(), account_root = account_root.display(), - generated_wit = generated_wit.display(), ); let miden_project_toml = format!( r#"[package] @@ -173,15 +168,11 @@ path = "src/lib.rs" miden-core = "*" miden-protocol = "*" {account_crate} = {{ path = "{account_root}" }} - -[package.metadata.miden.dependencies] -{account_crate} = {{ wit = "{generated_wit}" }} "#, note_crate = names.note_crate, note_slug = names.note_slug, account_crate = names.account_crate, account_root = account_root.display(), - generated_wit = generated_wit.display(), ); let source = format!( r#"#![no_std] @@ -222,21 +213,18 @@ fn build_generated_test(root: impl AsRef) -> CompilerTest { builder.build() } -/// Reads the single generated WIT file emitted by the account project. -fn read_generated_wit(project: &Project) -> String { - let generated_wit_dir = project.root().join("target/generated-wit"); - let mut wit_paths = fs::read_dir(&generated_wit_dir) - .unwrap_or_else(|err| { - panic!("failed to read generated WIT dir {}: {err}", generated_wit_dir.display()) - }) - .map(|entry| entry.expect("failed to inspect generated WIT entry").path()) - .filter(|path| path.extension().and_then(|ext| ext.to_str()) == Some("wit")) - .collect::>(); - wit_paths.sort(); - assert_eq!(wit_paths.len(), 1, "expected one generated WIT file, got {wit_paths:?}"); - fs::read_to_string(&wit_paths[0]).unwrap_or_else(|err| { - panic!("failed to read generated WIT {}: {err}", wit_paths[0].display()) - }) +/// Extracts the component WIT embedded in a compiled account package. +fn package_wit(package: &miden_mast_package::Package) -> String { + let wit_section_id = miden_mast_package::SectionId::custom( + midenc_frontend_wasm_metadata::PACKAGE_WIT_SECTION_ID, + ) + .expect("the WIT section id must be a valid custom section id"); + let section = package + .sections + .iter() + .find(|section| section.id == wit_section_id) + .expect("compiled account package must embed its component WIT"); + String::from_utf8(section.data.to_vec()).expect("embedded WIT must be UTF-8") } /// Runs a generated account/note pair by executing the compiled note script directly. @@ -252,7 +240,7 @@ fn run_canonabi_case( let mut account_test = build_generated_test(&account_root); let account_package = account_test.compile_package(); assert!(account_package.is_library()); - let generated_wit = read_generated_wit(&account_project); + let generated_wit = package_wit(&account_package); assert_generated_wit(&generated_wit); let note_project = build_note_project(&names, &account_root, note_body); diff --git a/tests/integration/src/sdk/macros.rs b/tests/integration/src/sdk/macros.rs index 6264f9483..dc27aeceb 100644 --- a/tests/integration/src/sdk/macros.rs +++ b/tests/integration/src/sdk/macros.rs @@ -592,7 +592,7 @@ impl TestComponent for TestComponentStorage { /// Hand-written stand-in for the generated WIT of a sibling component dependency. /// -/// Mirrors the shape the `#[component]` macro writes to `target/generated-wit` so the sibling +/// Mirrors the shape the `#[component]` macro embeds into the compiled package so the sibling /// tests don't have to compile a real dependency project with cargo-miden. const TEST_SIBLING_GENERATED_WIT: &str = r#"package miden:test-sibling@0.0.1; @@ -636,37 +636,39 @@ world test-sibling-world { /// Builds an account component project with one sibling component dependency named `test-sibling`. /// -/// The sibling exists only as its generated WIT (under `dep/target/generated-wit`), which is all -/// the macros need: sibling calls resolve at link time and read no `.masp` during expansion. +/// The sibling exists only as a synthesized `.masp` package (under `dep/target/miden/debug`) +/// embedding its component WIT, which is all the macros need: sibling calls resolve at link time +/// and read no procedure roots during expansion. fn account_component_project_with_sibling_dep( name: &str, lib_rs: &str, ) -> crate::cargo_proj::Project { - account_component_project_with_sibling_dep_inner(name, lib_rs, TEST_SIBLING_GENERATED_WIT, true) + account_component_project_with_sibling_dep_inner(name, lib_rs, Some(TEST_SIBLING_GENERATED_WIT)) } /// Builds an account component project with one sibling component dependency named `test-sibling`. /// -/// `sibling_wit` is the dependency's generated WIT written under `dep/target/generated-wit`. -/// `declare_sibling_wit` controls whether that WIT is declared under -/// `[package.metadata.miden.dependencies]` in `miden-project.toml`. Omitting it reproduces the -/// case where the reference selects (the WIT is read from `target/generated-wit`) but the inline -/// `generate!` cannot resolve the import, so the macro emits the missing-WIT diagnostic. +/// `sibling_wit` is embedded into the WIT section of the dependency's synthesized `.masp` +/// package. Passing `None` omits the section, reproducing a dependency package built by a +/// toolchain that predates embedded WIT. fn account_component_project_with_sibling_dep_inner( name: &str, lib_rs: &str, - sibling_wit: &str, - declare_sibling_wit: bool, + sibling_wit: Option<&str>, +) -> crate::cargo_proj::Project { + let cargo_proj = account_component_project_with_sibling_dep_root(name, lib_rs); + write_sibling_package(&cargo_proj, sibling_wit); + cargo_proj +} + +/// Builds the sibling-dependency project skeleton without a compiled dependency package. +fn account_component_project_with_sibling_dep_root( + name: &str, + lib_rs: &str, ) -> crate::cargo_proj::Project { let sdk_path = sdk_crate_path(); let namespace = base::account_component_namespace(name, "test-component"); let component_package = format!("miden:{}", name.replace('_', "-")); - let sibling_wit_entry = if declare_sibling_wit { - "\n[package.metadata.miden.dependencies]\ntest-sibling = { wit = \ - \"dep/target/generated-wit\" }\n" - } else { - "" - }; let miden_project_toml = format!( r#" [package] @@ -682,7 +684,7 @@ path = "src/lib.rs" miden-core = "*" miden-protocol = "*" test-sibling = {{ path = "dep" }} -{sibling_wit_entry}"# +"# ); let cargo_toml = format!( r#" @@ -701,9 +703,6 @@ miden = {{ path = "{sdk_path}" }} [package.metadata.component] package = "{component_package}" -[package.metadata.component.target.dependencies] -"miden:test-sibling" = {{ path = "dep/target/generated-wit/test-sibling.wit" }} - [package.metadata.miden] project-kind = "account" supported-types = ["RegularAccountUpdatableCode"] @@ -714,11 +713,45 @@ supported-types = ["RegularAccountUpdatableCode"] project(name) .file("miden-project.toml", &miden_project_toml) .file("Cargo.toml", &cargo_toml) - .file("dep/target/generated-wit/test-sibling.wit", sibling_wit) + // The dependency root must exist on disk for the macros to canonicalize it. + .file("dep/.gitkeep", "") .file("src/lib.rs", lib_rs) .build() } +/// Synthesizes the sibling dependency `.masp` package under `dep/target/miden/debug`. +fn write_sibling_package(cargo_proj: &crate::cargo_proj::Project, wit: Option<&str>) { + use miden_assembly::{Assembler, DefaultSourceManager, ModuleParser, ast::ModuleKind}; + use miden_core::serde::Serializable; + + let source_manager = std::sync::Arc::new(DefaultSourceManager::default()); + let module = ModuleParser::new(Some(ModuleKind::Library)) + .parse_str( + Some(miden_assembly::Path::new("dep")), + "pub proc callee(a: felt) -> felt\n add.1\nend", + source_manager.clone(), + ) + .expect("sibling fixture module must parse"); + let mut package = Assembler::new(source_manager) + .assemble_library("test-sibling", module, None::>) + .expect("sibling fixture library must assemble"); + package.version = "0.0.1".parse().expect("sibling fixture version must parse"); + if let Some(wit) = wit { + let wit_section_id = miden_mast_package::SectionId::custom( + midenc_frontend_wasm_metadata::PACKAGE_WIT_SECTION_ID, + ) + .expect("the WIT section id must be a valid custom section id"); + package + .sections + .push(miden_mast_package::Section::new(wit_section_id, wit.as_bytes().to_vec())); + } + + let package_dir = cargo_proj.root().join("dep/target/miden/debug"); + std::fs::create_dir_all(&package_dir).expect("sibling package directory must be created"); + std::fs::write(package_dir.join("test_sibling.masp"), package.to_bytes()) + .expect("sibling package fixture must be written"); +} + #[test] fn component_trait_with_sibling_dependency_compiles() { // The sibling reference generates `trait TestSibling` with default methods calling the @@ -904,10 +937,9 @@ impl TestComponent for TestComponentStorage { } #[test] -fn component_sibling_reports_missing_wit_dependency_manifest_entry() { - // The reference is valid and the dependency WIT exists under `target/generated-wit`, but the - // `[package.metadata.miden.dependencies]` entry that puts it on the macro's WIT search path is - // omitted. Expansion should surface the actionable diagnostic, not a bare wit-parser error. +fn component_sibling_reports_missing_dependency_package() { + // The reference is valid but the dependency has no compiled `.masp` package. Expansion should + // surface the actionable build-the-dependency diagnostic, not a bare wit-parser error. let lib_rs = r#"#![no_std] #![feature(alloc_error_handler)] @@ -929,27 +961,63 @@ impl TestComponent for TestComponentStorage { } "#; - let cargo_proj = account_component_project_with_sibling_dep_inner( - "component_sibling_missing_wit_entry", + let cargo_proj = account_component_project_with_sibling_dep_root( + "component_sibling_missing_dep_package", lib_rs, - TEST_SIBLING_GENERATED_WIT, - false, ); let output = cargo_check_miden_target(&cargo_proj); assert!( !output.status.success(), - "expected the missing sibling WIT entry to fail the build" + "expected the missing dependency package to fail the build" ); let stderr = String::from_utf8_lossy(&output.stderr); assert!( - stderr.contains("could not resolve the WIT for sibling component dependencies"), + stderr.contains("could not find a built `.masp` package"), "unexpected stderr: {stderr}" ); + assert!(stderr.contains("cargo miden build"), "unexpected stderr: {stderr}"); +} + +#[test] +fn component_sibling_reports_dependency_package_without_embedded_wit() { + // The dependency package exists but predates embedded WIT (no `wit` section). Expansion + // should tell the user to rebuild the dependency with the current toolchain. + let lib_rs = r#"#![no_std] +#![feature(alloc_error_handler)] + +use miden::{component, component_storage, felt, native_account::NativeAccount, Felt}; + +#[component_storage] +struct TestComponentStorage; + +#[component(test_sibling::TestSibling)] +trait TestComponent: NativeAccount + TestSibling { + fn value(&mut self) -> Felt; +} + +#[component] +impl TestComponent for TestComponentStorage { + fn value(&mut self) -> Felt { + self.get_value() + } +} +"#; + + let cargo_proj = account_component_project_with_sibling_dep_inner( + "component_sibling_package_without_wit", + lib_rs, + None, + ); + let output = cargo_check_miden_target(&cargo_proj); assert!( - stderr.contains("[package.metadata.miden.dependencies]"), - "unexpected stderr: {stderr}" + !output.status.success(), + "expected a dependency package without embedded WIT to fail the build" ); + let stderr = String::from_utf8_lossy(&output.stderr); + + assert!(stderr.contains("does not embed component WIT"), "unexpected stderr: {stderr}"); + assert!(stderr.contains("cargo miden build"), "unexpected stderr: {stderr}"); } #[test] @@ -985,8 +1053,7 @@ impl TestComponent for TestComponentStorage { let cargo_proj = account_component_project_with_sibling_dep_inner( "component_sibling_owned_record", lib_rs, - TEST_SIBLING_OWNED_TYPE_WIT, - true, + Some(TEST_SIBLING_OWNED_TYPE_WIT), ); let output = cargo_check_miden_target(&cargo_proj); let stderr = String::from_utf8_lossy(&output.stderr); diff --git a/tests/integration/src/sdk/mod.rs b/tests/integration/src/sdk/mod.rs index 1c877e9b2..96ac69590 100644 --- a/tests/integration/src/sdk/mod.rs +++ b/tests/integration/src/sdk/mod.rs @@ -179,9 +179,6 @@ package = "miden:swapp-note" [package.metadata.miden.dependencies] "miden:basic-wallet" = {{ path = "../basic-wallet" }} - -[package.metadata.component.target.dependencies] -"miden:basic-wallet" = {{ path = "../basic-wallet/target/generated-wit/" }} "#, sdk_path.display(), ); @@ -197,9 +194,6 @@ path = "src/lib.rs" [dependencies] basic-wallet = { path = "../basic-wallet" } - -[package.metadata.miden.dependencies] -basic-wallet = { wit = "../basic-wallet/target/generated-wit/" } "#; let original_swapp_note_source = include_str!(concat!( env!("CARGO_MANIFEST_DIR"), diff --git a/tests/support/src/testing/setup.rs b/tests/support/src/testing/setup.rs index 4a4e73f85..aa7aee4d4 100644 --- a/tests/support/src/testing/setup.rs +++ b/tests/support/src/testing/setup.rs @@ -79,6 +79,7 @@ pub fn build_empty_component_for_test(context: Rc) -> MidenComponent { world, component: Some(component), account_component_metadata_bytes: None, + component_wit_bytes: None, source_provenance: miden_assembly::ProjectSourceProvenanceInputs { root: miden_assembly::SourceFileProvenance { path: Path::new("mod.rs").to_path_buf().into_boxed_path(), diff --git a/tools/cargo-miden/src/template.rs b/tools/cargo-miden/src/template.rs index 7910da1a5..370f64a6f 100644 --- a/tools/cargo-miden/src/template.rs +++ b/tools/cargo-miden/src/template.rs @@ -177,13 +177,6 @@ version = \"{}\" .and_then(|metadata| metadata.get("miden")) .and_then(|miden| miden.get("dependencies")) .and_then(|dependencies| dependencies.as_table_like()); - let component_target_dependencies = cargo_manifest - .get("package") - .and_then(|package| package.get("metadata")) - .and_then(|metadata| metadata.get("component")) - .and_then(|component| component.get("target")) - .and_then(|target| target.get("dependencies")) - .and_then(|dependencies| dependencies.as_table_like()); manifest.push_str("[dependencies]\n"); manifest.push_str("miden-core = \"*\"\n"); @@ -208,43 +201,12 @@ version = \"{}\" .and_then(|package| package.get("metadata")) .and_then(|metadata| metadata.get("miden")) .and_then(|miden| miden.get("supported-types")); - let mut wit_dependencies = Vec::new(); - if let Some(dependencies) = metadata_dependencies { - for (name, dependency) in dependencies.iter() { - if let Some(wit) = dependency.get("wit").and_then(|wit| wit.as_str()) { - wit_dependencies.push((miden_dependency_name(name).to_string(), wit.to_string())); - } - } - } - if let Some(dependencies) = component_target_dependencies { - for (name, dependency) in dependencies.iter() { - let wit = dependency - .get("wit") - .or_else(|| dependency.get("path")) - .and_then(|wit| wit.as_str()); - if let Some(wit) = wit { - wit_dependencies.push((miden_dependency_name(name).to_string(), wit.to_string())); - } - } - } - if supported_types.is_some() || !wit_dependencies.is_empty() { - manifest.push('\n'); - } if let Some(supported_types) = supported_types { + manifest.push('\n'); manifest.push_str("[package.metadata.miden]\n"); manifest.push_str(&format!("supported-types = {supported_types}\n")); } - if !wit_dependencies.is_empty() { - manifest.push_str("\n[package.metadata.miden.dependencies]\n"); - for (name, wit) in wit_dependencies { - manifest.push_str(&format!( - "{} = {{ wit = \"{}\" }}\n", - toml_key(&name), - toml_escape(&wit) - )); - } - } manifest } From cfde6f147055bc208e94abc697570dc69f3976ff Mon Sep 17 00:00:00 2001 From: Denys Zadorozhnyi Date: Thu, 2 Jul 2026 17:16:10 +0300 Subject: [PATCH 02/12] refactor: carry package section payloads in one pipeline type MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The account component metadata and the component WIT were threaded through the compiler as two parallel `Option>` fields, so every additional out-of-band payload would have to be added to each stage struct, every construction site, and the growing `assemble_with_registry` signature. Introduce `PackageSections` in `midenc-frontend-wasm-metadata` (next to the section-name constants it complements) and carry it as a single field through `FrontendOutput`, `MidenComponent`, and `CodegenOutput`; assembly takes `&PackageSections` and attaches all payloads in one `attach_package_sections`. Adding a future payload is now one field plus its producer and consumer — stage signatures and construction sites stay fixed. `ParsedModule` keeps its borrowed per-section slices; the carrier starts where the data becomes owned. --- frontend/wasm/src/component/translator.rs | 8 +++-- frontend/wasm/src/lib.rs | 10 +++--- midenc-compile/src/pipeline/artifacts.rs | 19 +++++----- midenc-compile/src/pipeline/assembly.rs | 7 ++-- midenc-compile/src/pipeline/backend.rs | 36 +++++++------------ midenc-compile/src/pipeline/frontends/hir.rs | 24 +++++-------- midenc-compile/src/pipeline/frontends/rust.rs | 6 ++-- midenc-compile/src/pipeline/frontends/wasm.rs | 15 +++----- midenc-compile/src/pipeline/seed.rs | 18 ++++------ midenc-compile/src/pipeline/testing.rs | 6 ++-- midenc-compile/tests/codegen_legalization.rs | 3 +- sdk/wasm-metadata/src/lib.rs | 13 +++++++ tests/support/src/testing/setup.rs | 3 +- 13 files changed, 73 insertions(+), 95 deletions(-) diff --git a/frontend/wasm/src/component/translator.rs b/frontend/wasm/src/component/translator.rs index 9688c9a5a..c91acc5fd 100644 --- a/frontend/wasm/src/component/translator.rs +++ b/frontend/wasm/src/component/translator.rs @@ -1,7 +1,7 @@ use std::rc::Rc; use cranelift_entity::PrimaryMap; -use midenc_frontend_wasm_metadata::{FrontendMetadata, ProtocolExportKind}; +use midenc_frontend_wasm_metadata::{FrontendMetadata, PackageSections, ProtocolExportKind}; use midenc_hir::{ self as hir2, BuilderExt, Context, FxHashMap, FxHashSet, Ident, SymbolNameComponent, SymbolPath, @@ -198,8 +198,10 @@ impl<'a> ComponentTranslator<'a> { let output = FrontendOutput { component: self.result.component, - account_component_metadata_bytes, - component_wit_bytes, + sections: PackageSections { + account_component_metadata: account_component_metadata_bytes, + component_wit: component_wit_bytes, + }, }; Ok(output) } diff --git a/frontend/wasm/src/lib.rs b/frontend/wasm/src/lib.rs index 1b19c6d91..65a3a5853 100644 --- a/frontend/wasm/src/lib.rs +++ b/frontend/wasm/src/lib.rs @@ -27,6 +27,7 @@ use alloc::rc::Rc; use component::build_ir::translate_component; use error::WasmResult; +use midenc_frontend_wasm_metadata::PackageSections; use midenc_hir::{Context, dialects::builtin}; use module::build_ir::translate_module_as_component; use wasmparser::WasmFeatures; @@ -39,10 +40,8 @@ pub use self::{config::*, emit::WatEmit, error::WasmError}; pub struct FrontendOutput { /// The IR component translated from the Wasm pub component: builtin::ComponentRef, - /// The serialized AccountComponentMetadata (name, description, storage layout, etc.) - pub account_component_metadata_bytes: Option>, - /// The component's public WIT source emitted by the `#[component]` macro. - pub component_wit_bytes: Option>, + /// Out-of-band payloads destined for the compiled package's sections. + pub sections: PackageSections, } /// Translate a valid Wasm core module or Wasm Component Model binary into Miden @@ -58,8 +57,7 @@ pub fn translate( let component = translate_module_as_component(wasm, config, context)?; Ok(FrontendOutput { component, - account_component_metadata_bytes: None, - component_wit_bytes: None, + sections: PackageSections::default(), }) } else { translate_component(wasm, config, context) diff --git a/midenc-compile/src/pipeline/artifacts.rs b/midenc-compile/src/pipeline/artifacts.rs index b87251876..6119443b2 100644 --- a/midenc-compile/src/pipeline/artifacts.rs +++ b/midenc-compile/src/pipeline/artifacts.rs @@ -11,18 +11,19 @@ //! frontend invents. The distinction is why [`CompiledArtifact`] carries the longer name; it //! is the *finished* artifact of one whole compilation, not the artifact of a checkpoint. -use alloc::{sync::Arc, vec::Vec}; +use alloc::sync::Arc; use miden_mast_package::Package; use midenc_codegen_masm::MasmComponent; +use midenc_frontend_wasm_metadata::PackageSections; use midenc_hir::dialects::builtin; /// A parsed Miden component, together with everything assembly will need from the parse. pub struct MidenComponent { pub world: builtin::WorldRef, pub component: Option, - pub account_component_metadata_bytes: Option>, - pub component_wit_bytes: Option>, + /// Out-of-band payloads destined for the compiled package's sections. + pub sections: PackageSections, #[cfg(feature = "std")] pub source_provenance: miden_assembly::ProjectSourceProvenanceInputs, } @@ -32,8 +33,7 @@ impl Clone for MidenComponent { Self { world: self.world, component: self.component, - account_component_metadata_bytes: self.account_component_metadata_bytes.clone(), - component_wit_bytes: self.component_wit_bytes.clone(), + sections: self.sections.clone(), #[cfg(feature = "std")] source_provenance: miden_assembly::ProjectSourceProvenanceInputs { root: miden_assembly::SourceFileProvenance { @@ -57,10 +57,8 @@ impl Clone for MidenComponent { /// The Miden Assembly a component was lowered to, ready to be assembled. pub struct CodegenOutput { pub component: Arc, - /// The serialized AccountComponentMetadata (name, description, storage layout, etc.) - pub account_component_metadata_bytes: Option>, - /// The component's public WIT source emitted by the `#[component]` macro. - pub component_wit_bytes: Option>, + /// Out-of-band payloads destined for the compiled package's sections. + pub sections: PackageSections, #[cfg(feature = "std")] pub source_provenance: miden_assembly::ProjectSourceProvenanceInputs, } @@ -91,8 +89,7 @@ impl Clone for CodegenOutput { fn clone(&self) -> Self { Self { component: self.component.clone(), - account_component_metadata_bytes: self.account_component_metadata_bytes.clone(), - component_wit_bytes: self.component_wit_bytes.clone(), + sections: self.sections.clone(), source_provenance: self.source_provenance(), } } diff --git a/midenc-compile/src/pipeline/assembly.rs b/midenc-compile/src/pipeline/assembly.rs index 2c1eae885..11f23b0bd 100644 --- a/midenc-compile/src/pipeline/assembly.rs +++ b/midenc-compile/src/pipeline/assembly.rs @@ -59,8 +59,7 @@ pub(crate) fn prepare_assembler( pub(crate) fn post_process_package( package: &mut Package, component: &MasmComponent, - account_component_metadata_bytes: Option<&[u8]>, - component_wit_bytes: Option<&[u8]>, + sections: &midenc_frontend_wasm_metadata::PackageSections, target: &midenc_session::miden_project::Target, registry: &dyn miden_package_registry::PackageRegistryAndProvider, ) -> Result<(), Report> { @@ -68,8 +67,8 @@ pub(crate) fn post_process_package( use miden_mast_package::{Section, SectionId}; use midenc_session::miden_project::TargetType; - attach_account_component_metadata(package, account_component_metadata_bytes); - attach_component_wit(package, component_wit_bytes); + attach_account_component_metadata(package, sections.account_component_metadata.as_deref()); + attach_component_wit(package, sections.component_wit.as_deref()); extend_rodata_advice_map(package, &component.rodata); // Embed the kernel in note/transaction script packages, if not already embedded diff --git a/midenc-compile/src/pipeline/backend.rs b/midenc-compile/src/pipeline/backend.rs index a7d623745..1db71fdc8 100644 --- a/midenc-compile/src/pipeline/backend.rs +++ b/midenc-compile/src/pipeline/backend.rs @@ -14,12 +14,13 @@ //! goal (see [`StopFlag`](super::StopFlag)) rather than checked at a phase boundary. A phase //! called directly simply runs. -use alloc::{boxed::Box, rc::Rc, sync::Arc, vec::Vec}; +use alloc::{boxed::Box, rc::Rc, sync::Arc}; use miden_assembly::{ProjectSourceInputs, ProjectSourceProvenanceInputs}; use midenc_codegen_masm::{LegalizeForMasm, MasmComponent, ToMasmComponent}; use midenc_dialect_hir::transforms::{Local2Reg, TransformSpills}; use midenc_dialect_scf::transforms::LiftControlFlowToSCF; +use midenc_frontend_wasm_metadata::PackageSections; use midenc_hir::{ Context, Op, OperationRef, dialects::builtin, @@ -49,10 +50,8 @@ use crate::{CodegenOutput, CompilerResult, MidenComponent}; /// section but a silent miscompile: the lowered code pushes each segment's commitment and /// asks the advice provider for the data behind it, so a package assembled without the /// advice map fails at run time, in the VM, with nothing in the build to point at. -/// - [`account_component_metadata_bytes`](LoweredTarget::account_component_metadata_bytes) -/// becomes the package's account-component metadata section. -/// - [`component_wit_bytes`](LoweredTarget::component_wit_bytes) becomes the package's -/// component WIT section. +/// - [`sections`](LoweredTarget::sections) carries the out-of-band payloads — the serialized +/// account-component metadata and the component's public WIT — that become package sections. /// - [`source_provenance`](LoweredTarget::source_provenance) is what the assembler hashes to /// decide whether a cached build of this target is still current. /// @@ -66,10 +65,8 @@ pub struct LoweredTarget { pub sources: ProjectSourceInputs, /// The lowered component, whose rodata becomes the package's advice map. pub component: Arc, - /// The serialized account-component metadata, if this target has any. - pub account_component_metadata_bytes: Option>, - /// The component's public WIT source, if this target embeds any. - pub component_wit_bytes: Option>, + /// Out-of-band payloads destined for the compiled package's sections. + pub sections: PackageSections, /// The provenance of the sources this target was built from. pub source_provenance: ProjectSourceProvenanceInputs, } @@ -127,8 +124,7 @@ pub fn masm_from_transformed_hir( let context = hir.world.borrow().as_operation().context_rc(); let CodegenOutput { component, - account_component_metadata_bytes, - component_wit_bytes, + sections, source_provenance, } = codegen(hir, context)?; let session = cx.session(); @@ -138,8 +134,7 @@ pub fn masm_from_transformed_hir( let lowered = LoweredTarget { sources, component, - account_component_metadata_bytes, - component_wit_bytes, + sections, source_provenance, }; // After the checkpoint, so that a run stopping at `masm.lowered` does not reach it: @@ -300,8 +295,7 @@ pub fn codegen(hir: MidenComponent, context: Rc) -> CompilerResult) -> CompilerResult lowered, @@ -362,8 +361,7 @@ impl Frontend for HirFrontend { cx.target_key(), CodegenOutput { component, - account_component_metadata_bytes, - component_wit_bytes, + sections, source_provenance, }, ); @@ -394,8 +392,7 @@ impl Frontend for HirFrontend { crate::pipeline::assembly::post_process_package( package, &found.component, - found.account_component_metadata_bytes.as_deref(), - found.component_wit_bytes.as_deref(), + &found.sections, cx.assembly().target, cx.assembly().package_registry, ) @@ -470,8 +467,7 @@ pub fn extract_miden_component_or_bail( Ok(MidenComponent { world, component: None, - account_component_metadata_bytes: None, - component_wit_bytes: None, + sections: Default::default(), source_provenance, }) } else if let Ok(component) = op.try_downcast_op::() { @@ -479,8 +475,7 @@ pub fn extract_miden_component_or_bail( Ok(MidenComponent { world, component: Some(component), - account_component_metadata_bytes: None, - component_wit_bytes: None, + sections: Default::default(), source_provenance, }) } else if let Ok(module) = op.try_downcast_op::() { @@ -490,16 +485,14 @@ pub fn extract_miden_component_or_bail( Ok(MidenComponent { world, component: Some(component), - account_component_metadata_bytes: None, - component_wit_bytes: None, + sections: Default::default(), source_provenance, }) } else if let Ok(world) = parent.try_downcast_op::() { Ok(MidenComponent { world, component: None, - account_component_metadata_bytes: None, - component_wit_bytes: None, + sections: Default::default(), source_provenance, }) } else { @@ -513,8 +506,7 @@ pub fn extract_miden_component_or_bail( Ok(MidenComponent { world, component: None, - account_component_metadata_bytes: None, - component_wit_bytes: None, + sections: Default::default(), source_provenance, }) } diff --git a/midenc-compile/src/pipeline/frontends/rust.rs b/midenc-compile/src/pipeline/frontends/rust.rs index de98f7e18..1e7c2f9b1 100644 --- a/midenc-compile/src/pipeline/frontends/rust.rs +++ b/midenc-compile/src/pipeline/frontends/rust.rs @@ -1182,8 +1182,7 @@ impl Frontend for RustProjectFrontend { crate::pipeline::assembly::post_process_package( package, &found.component, - found.account_component_metadata_bytes.as_deref(), - found.component_wit_bytes.as_deref(), + &found.sections, cx.assembly().target, cx.assembly().package_registry, ) @@ -2141,8 +2140,7 @@ mod tests { stack_pointer: None, modules: Vec::new(), }), - account_component_metadata_bytes: None, - component_wit_bytes: None, + sections: Default::default(), source_provenance: ProjectSourceProvenanceInputs { root: SourceFileProvenance { path: std::path::PathBuf::from("seeded.wat").into_boxed_path(), diff --git a/midenc-compile/src/pipeline/frontends/wasm.rs b/midenc-compile/src/pipeline/frontends/wasm.rs index f6d37bb64..af3d2bd7a 100644 --- a/midenc-compile/src/pipeline/frontends/wasm.rs +++ b/midenc-compile/src/pipeline/frontends/wasm.rs @@ -469,8 +469,7 @@ impl WasmFrontend { let FrontendOutput { component, - account_component_metadata_bytes, - component_wit_bytes, + sections, } = midenc_frontend_wasm::translate(&source.wasm, &config, context.clone())?; log::debug!( "parsed hir component from wasm bytes with first module name: {}", @@ -482,8 +481,7 @@ impl WasmFrontend { Ok(MidenComponent { world, component: Some(component), - account_component_metadata_bytes, - component_wit_bytes, + sections, source_provenance, }) } @@ -528,8 +526,7 @@ impl WasmFrontend { let LoweredTarget { sources, component, - account_component_metadata_bytes, - component_wit_bytes, + sections, source_provenance, } = match backend::hir_to_masm(cx, hir)? { Flow::Continue(lowered) => lowered, @@ -539,8 +536,7 @@ impl WasmFrontend { cx.target_key(), CodegenOutput { component, - account_component_metadata_bytes, - component_wit_bytes, + sections, source_provenance, }, ); @@ -594,8 +590,7 @@ impl Frontend for WasmFrontend { crate::pipeline::assembly::post_process_package( package, &found.component, - found.account_component_metadata_bytes.as_deref(), - found.component_wit_bytes.as_deref(), + &found.sections, cx.assembly().target, cx.assembly().package_registry, ) diff --git a/midenc-compile/src/pipeline/seed.rs b/midenc-compile/src/pipeline/seed.rs index 5dd6a070a..afed7bedb 100644 --- a/midenc-compile/src/pipeline/seed.rs +++ b/midenc-compile/src/pipeline/seed.rs @@ -393,8 +393,7 @@ impl Frontend for SeedFrontend { let LoweredTarget { sources, component, - account_component_metadata_bytes, - component_wit_bytes, + sections, source_provenance, } = match self.resume(cx, hir)? { Flow::Continue(lowered) => lowered, @@ -404,8 +403,7 @@ impl Frontend for SeedFrontend { cx.target_key(), CodegenOutput { component, - account_component_metadata_bytes, - component_wit_bytes, + sections, source_provenance, }, ); @@ -439,8 +437,7 @@ impl Frontend for SeedFrontend { crate::pipeline::assembly::post_process_package( package, &found.component, - found.account_component_metadata_bytes.as_deref(), - found.component_wit_bytes.as_deref(), + &found.sections, cx.assembly().target, cx.assembly().package_registry, ) @@ -558,8 +555,7 @@ mod tests { let LoweredTarget { sources, component, - account_component_metadata_bytes, - component_wit_bytes, + sections, source_provenance, } = match backend::hir_to_masm(cx, hir)? { Flow::Continue(lowered) => lowered, @@ -569,8 +565,7 @@ mod tests { cx.target_key(), CodegenOutput { component, - account_component_metadata_bytes, - component_wit_bytes, + sections, source_provenance, }, ); @@ -609,8 +604,7 @@ mod tests { crate::pipeline::assembly::post_process_package( package, &found.component, - found.account_component_metadata_bytes.as_deref(), - found.component_wit_bytes.as_deref(), + &found.sections, cx.assembly().target, cx.assembly().package_registry, ) diff --git a/midenc-compile/src/pipeline/testing.rs b/midenc-compile/src/pipeline/testing.rs index 74ee77d4e..bad2f5c6d 100644 --- a/midenc-compile/src/pipeline/testing.rs +++ b/midenc-compile/src/pipeline/testing.rs @@ -212,8 +212,10 @@ pub(crate) fn component_in_namespace( crate::MidenComponent { world, component: Some(component), - account_component_metadata_bytes: metadata, - component_wit_bytes: None, + sections: midenc_frontend_wasm_metadata::PackageSections { + account_component_metadata: metadata, + component_wit: None, + }, source_provenance: ProjectSourceProvenanceInputs { root: SourceFileProvenance { path: FsPath::new(file!()).to_path_buf().into_boxed_path(), diff --git a/midenc-compile/tests/codegen_legalization.rs b/midenc-compile/tests/codegen_legalization.rs index ac1b7466d..9fadb6f78 100644 --- a/midenc-compile/tests/codegen_legalization.rs +++ b/midenc-compile/tests/codegen_legalization.rs @@ -78,8 +78,7 @@ fn build_test_component( MidenComponent { world, component: Some(component), - account_component_metadata_bytes: None, - component_wit_bytes: None, + sections: Default::default(), source_provenance: ProjectSourceProvenanceInputs { root: SourceFileProvenance { path: Path::new(file!()).to_path_buf().into_boxed_path(), diff --git a/sdk/wasm-metadata/src/lib.rs b/sdk/wasm-metadata/src/lib.rs index 28f0c9374..e77660850 100644 --- a/sdk/wasm-metadata/src/lib.rs +++ b/sdk/wasm-metadata/src/lib.rs @@ -21,6 +21,19 @@ pub const WASM_COMPONENT_WIT_CUSTOM_SECTION_NAME: &str = "rodata,miden_wit"; /// Name of the Miden package (`.masp`) section that carries the component's public WIT source. pub const PACKAGE_WIT_SECTION_ID: &str = "wit"; +/// Out-of-band payloads extracted from the input binary and attached to the compiled Miden +/// package as sections. +/// +/// Carried through the compiler pipeline as one unit so that adding a payload does not require +/// threading a new field through every stage. +#[derive(Clone, Debug, Default)] +pub struct PackageSections { + /// The serialized AccountComponentMetadata (name, description, storage layout, etc.). + pub account_component_metadata: Option>, + /// The component's public WIT source emitted by the `#[component]` macro. + pub component_wit: Option>, +} + /// Frontend-only metadata emitted by the SDK macros into a dedicated Wasm custom section. #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] #[serde(tag = "kind", rename_all = "snake_case")] diff --git a/tests/support/src/testing/setup.rs b/tests/support/src/testing/setup.rs index aa7aee4d4..b76ce05a9 100644 --- a/tests/support/src/testing/setup.rs +++ b/tests/support/src/testing/setup.rs @@ -78,8 +78,7 @@ pub fn build_empty_component_for_test(context: Rc) -> MidenComponent { MidenComponent { world, component: Some(component), - account_component_metadata_bytes: None, - component_wit_bytes: None, + sections: Default::default(), source_provenance: miden_assembly::ProjectSourceProvenanceInputs { root: miden_assembly::SourceFileProvenance { path: Path::new("mod.rs").to_path_buf().into_boxed_path(), From 61a89bc1a400c846ff980f8366dd4ad5524f7e1f Mon Sep 17 00:00:00 2001 From: Denys Zadorozhnyi Date: Thu, 2 Jul 2026 18:19:56 +0300 Subject: [PATCH 03/12] fix: make WIT embedding runtime-neutral and order dependency WIT before local WIT MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review of the WIT-in-package branch surfaced three defects. The uniqueness-guard export emitted next to the WIT section was the feature's only executable-data footprint: one `#[used]` data byte shifted rodata and cost ~10 VM cycles on every transaction, and metadata must not perturb data segments, cycle counts, or commitments. The wit-bindgen resolver loaded the crate's local `wit/` directory before the WIT embedded in dependency packages, so a manually authored component whose local WIT imports a Miden dependency failed with a bare "package not found". And core Wasm module inputs parsed the metadata custom sections but dropped them instead of attaching them to the package. Drop the guard and detect the failure it guarded against in the frontend instead: linking two `#[component]` implementations concatenates their identically named custom sections, which now surfaces as a diagnostic counting top-level `package ...;` declarations in the section (the duplicate-sections assert in the component translator becomes a diagnostic as well). Package-size and cycle expectations revert to their pre-branch values, confirming WIT embedding no longer touches runtime state. Load WIT sources in dependency order — SDK prelude, then dependency packages, then the local `wit/` directory — and thread `PackageSections` out of `translate_module_as_component` so core-module inputs carry their sections too. Also reword stale diagnostics and docs that referenced the removed WIT path metadata (including the unreachable empty-paths error in `generate!`), name the single-`.wit`-file rule for manually authored components in the missing-WIT error and MIGRATION.md, and add regression tests for the concatenation detector and for local WIT importing a dependency package. --- frontend/wasm/src/component/translator.rs | 15 +++-- frontend/wasm/src/lib.rs | 6 +- frontend/wasm/src/module/build_ir.rs | 20 ++++-- frontend/wasm/src/module/module_env.rs | 47 ++++++++++++++ frontend/wasm/src/module/module_env/tests.rs | 52 ++++++++++++++++ .../src/component_macro/sibling.rs | 4 +- sdk/base-macros/src/dependency_package.rs | 5 +- sdk/base-macros/src/generate.rs | 62 ++++++++++--------- sdk/base-macros/src/manifest_paths.rs | 22 +++---- sdk/base-macros/src/util.rs | 18 ++---- sdk/base-macros/src/wit_world.rs | 3 +- sdk/sdk/MIGRATION.md | 5 +- tests/integration/src/sdk/macros.rs | 49 +++++++++++++++ 13 files changed, 232 insertions(+), 76 deletions(-) diff --git a/frontend/wasm/src/component/translator.rs b/frontend/wasm/src/component/translator.rs index c91acc5fd..8e169ba1e 100644 --- a/frontend/wasm/src/component/translator.rs +++ b/frontend/wasm/src/component/translator.rs @@ -1,7 +1,9 @@ use std::rc::Rc; use cranelift_entity::PrimaryMap; -use midenc_frontend_wasm_metadata::{FrontendMetadata, PackageSections, ProtocolExportKind}; +use midenc_frontend_wasm_metadata::{ + FrontendMetadata, PackageSections, ProtocolExportKind, WASM_COMPONENT_WIT_CUSTOM_SECTION_NAME, +}; use midenc_hir::{ self as hir2, BuilderExt, Context, FxHashMap, FxHashSet, Ident, SymbolNameComponent, SymbolPath, @@ -178,10 +180,13 @@ impl<'a> ComponentTranslator<'a> { .iter() .flat_map(|t| t.1.component_wit_bytes.map(|slice| slice.to_vec())) .collect(); - assert!( - component_wit_bytes_vec.len() <= 1, - "unexpected multiple core Wasm module to have a component WIT section", - ); + if component_wit_bytes_vec.len() > 1 { + return Err(Report::msg(format!( + "found {} '{WASM_COMPONENT_WIT_CUSTOM_SECTION_NAME}' custom sections across the \ + component's core modules; a component may embed at most one component WIT section", + component_wit_bytes_vec.len(), + ))); + } let component_wit_bytes = component_wit_bytes_vec.first().map(ToOwned::to_owned); let account_component_metadata_bytes_vec: Vec> = self diff --git a/frontend/wasm/src/lib.rs b/frontend/wasm/src/lib.rs index 65a3a5853..985085704 100644 --- a/frontend/wasm/src/lib.rs +++ b/frontend/wasm/src/lib.rs @@ -54,11 +54,7 @@ pub fn translate( if wasm[4..8] == [0x01, 0x00, 0x00, 0x00] { // Wasm core module // see https://github.com/WebAssembly/component-model/blob/main/design/mvp/Binary.md#component-definitions - let component = translate_module_as_component(wasm, config, context)?; - Ok(FrontendOutput { - component, - sections: PackageSections::default(), - }) + translate_module_as_component(wasm, config, context) } else { translate_component(wasm, config, context) } diff --git a/frontend/wasm/src/module/build_ir.rs b/frontend/wasm/src/module/build_ir.rs index b2ed0bb1c..df166047d 100644 --- a/frontend/wasm/src/module/build_ir.rs +++ b/frontend/wasm/src/module/build_ir.rs @@ -1,13 +1,12 @@ use core::{mem, str::FromStr}; use std::rc::Rc; +use midenc_frontend_wasm_metadata::PackageSections; use midenc_hir::{ Builder, BuilderExt, Context, FunctionIdent, FxHashMap, Ident, Op, OpBuilder, SymbolPath, Visibility, constants::ConstantData, - dialects::builtin::{ - self, BuiltinOpBuilder, ComponentBuilder, ModuleBuilder, World, WorldBuilder, - }, + dialects::builtin::{BuiltinOpBuilder, ComponentBuilder, ModuleBuilder, World, WorldBuilder}, version::Version, }; use midenc_session::diagnostics::{DiagnosticsHandler, IntoDiagnostic, Severity, SourceSpan}; @@ -18,7 +17,7 @@ use super::{ module_translation_state::ModuleTranslationState, types::ModuleTypesBuilder, }; use crate::{ - WasmTranslationConfig, + FrontendOutput, WasmTranslationConfig, error::WasmResult, intrinsics::Intrinsic, module::{ @@ -40,7 +39,7 @@ pub fn translate_module_as_component( wasm: &[u8], config: &WasmTranslationConfig, context: Rc, -) -> WasmResult { +) -> WasmResult { let mut validator = Validator::new_with_features(crate::supported_features()); let parser = wasmparser::Parser::new(0); let mut module_types_builder = Default::default(); @@ -54,6 +53,12 @@ pub fn translate_module_as_component( if let Some(name_override) = config.override_name.as_ref() { parsed_module.module.set_name_override(name_override.clone()); } + let sections = PackageSections { + account_component_metadata: parsed_module + .account_component_metadata_bytes + .map(|bytes| bytes.to_vec()), + component_wit: parsed_module.component_wit_bytes.map(|bytes| bytes.to_vec()), + }; let module_types = module_types_builder; // If a world wasn't provided to us, create one @@ -82,7 +87,10 @@ pub fn translate_module_as_component( )?; build_ir_module(&mut parsed_module, &module_types, &mut module_state, config, context)?; - Ok(component_ref) + Ok(FrontendOutput { + component: component_ref, + sections, + }) } pub fn build_ir_module( diff --git a/frontend/wasm/src/module/module_env.rs b/frontend/wasm/src/module/module_env.rs index f29a0e9e9..55b7c7a44 100644 --- a/frontend/wasm/src/module/module_env.rs +++ b/frontend/wasm/src/module/module_env.rs @@ -93,6 +93,52 @@ pub struct ParsedModule<'data> { pub component_frontend_metadata: Vec, } +/// Validates that a component WIT custom section holds exactly one top-level WIT package. +/// +/// Linking two `#[component]` implementations into one binary concatenates their identically +/// named custom sections into a single section whose merged text is not valid WIT; name the +/// actual cause here instead of surfacing an opaque parse error in dependent crates. +fn validate_component_wit_section( + bytes: &[u8], + diagnostics: &DiagnosticsHandler, +) -> WasmResult<()> { + let Ok(wit) = core::str::from_utf8(bytes) else { + return Err(diagnostics + .diagnostic(Severity::Error) + .with_message(format!( + "wasm error: the '{WASM_COMPONENT_WIT_CUSTOM_SECTION_NAME}' custom section does \ + not contain valid UTF-8 WIT source" + )) + .into_report()); + }; + + let package_declarations = count_top_level_wit_packages(wit); + if package_declarations > 1 { + return Err(diagnostics + .diagnostic(Severity::Error) + .with_message(format!( + "wasm error: found {package_declarations} top-level WIT package declarations in \ + the '{WASM_COMPONENT_WIT_CUSTOM_SECTION_NAME}' custom section; a linked binary \ + may contain at most one `#[component]` implementation" + )) + .into_report()); + } + + Ok(()) +} + +/// Counts top-level `package ;` declarations in WIT source. +/// +/// Nested package declarations (`package { ... }`) are excluded; a well-formed embedded WIT +/// source contains exactly one top-level declaration, so a higher count indicates concatenated +/// sections from multiple component implementations. +fn count_top_level_wit_packages(wit: &str) -> usize { + wit.lines() + .map(|line| line.split_once("//").map(|(before, _)| before).unwrap_or(line).trim()) + .filter(|line| line.starts_with("package ") && line.contains(';') && !line.contains('{')) + .count() +} + /// Collects the frontend metadata entries emitted by all core modules of one component. /// /// A component's metadata is single-kind by construction: the SDK macros emit either one @@ -317,6 +363,7 @@ impl<'a, 'data> ModuleEnvironment<'a, 'data> { self.result.account_component_metadata_bytes = Some(s.data()); } Payload::CustomSection(s) if s.name() == WASM_COMPONENT_WIT_CUSTOM_SECTION_NAME => { + validate_component_wit_section(s.data(), diagnostics)?; self.result.component_wit_bytes = Some(s.data()); } Payload::CustomSection(s) if s.name() == WASM_FRONTEND_METADATA_CUSTOM_SECTION_NAME => { diff --git a/frontend/wasm/src/module/module_env/tests.rs b/frontend/wasm/src/module/module_env/tests.rs index b600355f3..8d6edf906 100644 --- a/frontend/wasm/src/module/module_env/tests.rs +++ b/frontend/wasm/src/module/module_env/tests.rs @@ -1,5 +1,57 @@ use super::*; +/// Ensures a single embedded component WIT source is recognized as one package. +#[test] +fn component_wit_counts_a_single_package_declaration() { + let wit = r#"// This file is auto-generated by the `#[component]` macro. +package miden:basic-wallet@0.1.0; + +use miden:base/core-types@1.0.0; + +interface basic-wallet { + receive-asset: func(); +} + +world basic-wallet-world { + export basic-wallet; +} +"#; + + assert_eq!(count_top_level_wit_packages(wit), 1); +} + +/// Ensures concatenated WIT sections (two linked `#[component]` implementations) are detected. +#[test] +fn component_wit_detects_concatenated_package_declarations() { + let wit = r#"package miden:first@0.1.0; + +world first-world { +} +package miden:second@0.1.0; + +world second-world { +} +"#; + + assert_eq!(count_top_level_wit_packages(wit), 2); +} + +/// Ensures nested package declarations and comments are not counted as top-level packages. +#[test] +fn component_wit_ignores_nested_packages_and_comments() { + let wit = r#"// package miden:commented@0.1.0; +package miden:outer@0.1.0; + +package miden:nested@0.1.0 { + interface api { + get: func() -> u64; + } +} +"#; + + assert_eq!(count_top_level_wit_packages(wit), 1); +} + /// Ensures the frontend metadata entries emitted across a component's core modules are collected /// into one list — in particular the several `#[account_procedure]` entries of an account /// component. diff --git a/sdk/base-macros/src/component_macro/sibling.rs b/sdk/base-macros/src/component_macro/sibling.rs index 5ab6a22e9..9e9fb2add 100644 --- a/sdk/base-macros/src/component_macro/sibling.rs +++ b/sdk/base-macros/src/component_macro/sibling.rs @@ -5,8 +5,8 @@ //! expands to a generated Rust trait named after the interface whose default methods call the //! wit-bindgen imports of the sibling's WIT interface. Those imports lower to direct //! cross-context `call`s — the same mechanism note scripts use to call the account — and resolve -//! at link time against the dependency package, so unlike FPI no `.masp` artifact is read at -//! macro expansion time. +//! at link time against the dependency package. During macro expansion the sibling's compiled +//! `.masp` supplies only its embedded WIT; unlike FPI, no procedure roots are read from it. //! //! The generated traits attach to the component's storage struct through an empty blanket impl //! bound on [`NativeAccount`](https://docs.rs/miden), which `#[component_storage]` implements: diff --git a/sdk/base-macros/src/dependency_package.rs b/sdk/base-macros/src/dependency_package.rs index 6aa61d2fd..90cbcbaa3 100644 --- a/sdk/base-macros/src/dependency_package.rs +++ b/sdk/base-macros/src/dependency_package.rs @@ -95,7 +95,10 @@ pub(crate) fn read_package_wit(package_path: &Path) -> Result { format!( "dependency package '{}' does not embed component WIT (missing package section \ '{PACKAGE_WIT_SECTION_ID}'); it was likely built with an older Miden toolchain. \ - Rebuild the dependency with the current `cargo miden build`.", + Rebuild the dependency with the current `cargo miden build`. For manually \ + authored components (a hand-written `wit/` directory with a bare \ + `miden::generate!()`), the WIT is embedded only when the `wit/` directory \ + contains exactly one `.wit` file.", package_path.display() ), )); diff --git a/sdk/base-macros/src/generate.rs b/sdk/base-macros/src/generate.rs index cf867bc3c..c86fc1bcf 100644 --- a/sdk/base-macros/src/generate.rs +++ b/sdk/base-macros/src/generate.rs @@ -18,7 +18,7 @@ use wit_bindgen_core::{ }; use wit_bindgen_rust::{Opts, WithOption}; -use crate::{dependency_package::DependencyWitSource, fpi, manifest_paths}; +use crate::{fpi, manifest_paths}; /// Fully-qualified WIT interface path for Miden SDK core types. pub(crate) const CORE_TYPES_INTERFACE: &str = "miden:base/core-types@1.0.0"; @@ -114,16 +114,6 @@ pub(crate) fn expand(input: proc_macro::TokenStream) -> proc_macro::TokenStream match manifest_paths::resolve_wit_paths(resolve_opts) { Ok(config) => { - if config.paths.is_empty() { - return Error::new( - Span::call_site(), - "no WIT dependencies declared under \ - [package.metadata.component.target.dependencies]", - ) - .to_compile_error() - .into(); - } - let inline_world = args .inline .as_ref() @@ -203,8 +193,7 @@ fn generate_bindings( world: Option<&str>, ) -> Result { generate_bindings_from_sources( - &config.paths, - &config.dependency_sources, + config, args.inline.as_ref().map(|src| src.value()).as_deref(), world, &args.with_entries, @@ -222,8 +211,7 @@ pub(crate) fn generate_inline_fpi_bindings( with_entries: &[(String, WithOption)], ) -> Result { generate_bindings_from_sources( - &config.paths, - &config.dependency_sources, + config, Some(inline_source), Some(world), with_entries, @@ -243,8 +231,7 @@ pub(crate) fn generate_inline_import_bindings( with_entries: &[(String, WithOption)], ) -> Result { generate_bindings_from_sources( - &config.paths, - &config.dependency_sources, + config, Some(inline_source), Some(world), with_entries, @@ -255,15 +242,14 @@ pub(crate) fn generate_inline_import_bindings( /// Generates WIT bindings from resolved source paths and optional inline source. fn generate_bindings_from_sources( - paths: &[String], - dependency_sources: &[DependencyWitSource], + config: &manifest_paths::ResolvedWit, inline_source: Option<&str>, world: Option<&str>, with_entries: &[(String, WithOption)], fpi_imports: &[fpi::FpiImportSpec], scope_component_type_sections: bool, ) -> Result { - let mut wit_sources = load_wit_sources(paths, dependency_sources, inline_source)?; + let mut wit_sources = load_wit_sources(config, inline_source)?; let world_id = wit_sources .resolve @@ -514,9 +500,12 @@ struct LoadedWitSources { } /// Loads WIT sources from file paths, dependency packages, and optionally an inline source. +/// +/// Sources are pushed in dependency order — SDK prelude paths, then WIT embedded in dependency +/// packages, then the crate's local `wit/` directory — because the resolver eagerly resolves each +/// pushed package against the ones already present, and local WIT may import dependency packages. fn load_wit_sources( - paths: &[String], - dependency_sources: &[DependencyWitSource], + config: &manifest_paths::ResolvedWit, inline_source: Option<&str>, ) -> Result { let manifest_dir = env::var("CARGO_MANIFEST_DIR").map_err(|err| { @@ -528,14 +517,15 @@ fn load_wit_sources( let mut packages = Vec::new(); let mut files = Vec::new(); - // Load WIT definitions from file paths. These are always loaded to populate the resolver - // with type definitions that the inline source may depend on. - for path in paths { - let path_buf = PathBuf::from(path); - let absolute = if path_buf.is_absolute() { - path_buf + let push_path = |resolve: &mut Resolve, + packages: &mut Vec, + files: &mut Vec, + path: PathBuf| + -> Result<(), Error> { + let absolute = if path.is_absolute() { + path } else { - manifest_dir.join(path_buf) + manifest_dir.join(path) }; let normalized = fs::canonicalize(&absolute).unwrap_or(absolute); let (pkg, sources) = resolve.push_path(normalized.clone()).map_err(|err| { @@ -546,12 +536,19 @@ fn load_wit_sources( })?; packages.push(pkg); files.extend(sources.paths().map(|p| p.to_owned())); + Ok(()) + }; + + // Load WIT definitions from file paths (the SDK prelude). These are always loaded to + // populate the resolver with type definitions the other sources may depend on. + for path in &config.paths { + push_path(&mut resolve, &mut packages, &mut files, PathBuf::from(path))?; } // Load WIT definitions embedded in the compiled packages of Miden path dependencies. The // `.masp` paths are recorded like read files so rustc recompiles when a dependency package // changes. - for source in dependency_sources { + for source in &config.dependency_sources { let pkg = resolve.push_str(format!("{}.wit", source.name), &source.wit).map_err(|err| { Error::new( Span::call_site(), @@ -565,6 +562,11 @@ fn load_wit_sources( files.push(source.package_path.clone()); } + // Load the crate's own `wit/` directory last so it can reference the dependency packages. + if let Some(local_wit_root) = &config.local_wit_root { + push_path(&mut resolve, &mut packages, &mut files, local_wit_root.clone())?; + } + if let Some(src) = inline_source { // When inline source is provided, it becomes the primary package for world selection. // We clear previously collected package IDs because the inline source defines the world diff --git a/sdk/base-macros/src/manifest_paths.rs b/sdk/base-macros/src/manifest_paths.rs index 397072448..dcb3a781c 100644 --- a/sdk/base-macros/src/manifest_paths.rs +++ b/sdk/base-macros/src/manifest_paths.rs @@ -23,9 +23,13 @@ pub(crate) const SDK_WIT_SOURCE: &str = include_str!("../wit/miden.wit"); /// WIT metadata extracted from the consuming crate. pub(crate) struct ResolvedWit { + /// WIT search paths loaded before any dependency source (the SDK prelude). pub paths: Vec, /// WIT sources read from the compiled packages of Miden path dependencies. pub dependency_sources: Vec, + /// The crate's local `wit/` directory, loaded after the dependency sources so its WIT can + /// import the dependency packages. + pub local_wit_root: Option, pub world: Option, /// The world-defining local WIT file, present when it is the crate's only WIT file and can /// therefore be embedded verbatim as the component's public WIT. @@ -63,30 +67,24 @@ pub(crate) fn resolve_wit_paths(options: ResolveOptions) -> Result PathBuf { let mut manifest_dir = env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR is not set"); @@ -73,22 +70,17 @@ pub(crate) fn generate_frontend_link_section(entries: &[FrontendMetadata]) -> To } /// Embeds the component's public WIT source into the dedicated Wasm custom section. +/// +/// No linker uniqueness guard is emitted: custom-section bytes never reach executable data, so a +/// guard export would be the only runtime cost of WIT embedding. Linking two component +/// implementations concatenates their identically named sections instead, which the Wasm frontend +/// rejects with a dedicated diagnostic when it parses the section. pub(crate) fn generate_wit_link_section(wit_source: &str) -> TokenStream2 { let wit_bytes = wit_source.as_bytes(); let wit_len = wit_bytes.len(); let encoded_bytes = Literal::byte_string(wit_bytes); quote! { - const _: () = { - // A linked binary may contain exactly one component implementation. Reusing a fixed - // symbol name lets the linker reject duplicates across modules or crates, which would - // otherwise concatenate into one unparseable WIT custom section. - #[doc(hidden)] - #[used] - #[unsafe(export_name = #COMPONENT_WIT_UNIQUENESS_GUARD_SYMBOL)] - static __miden_component_wit_uniqueness_guard: u8 = 0; - }; - #[unsafe( // Keep the Mach-O-friendly `segment,section` naming scheme used by the other metadata // sections so the linker preserves these bytes in test and release builds. diff --git a/sdk/base-macros/src/wit_world.rs b/sdk/base-macros/src/wit_world.rs index 9dfaaea17..33bd75298 100644 --- a/sdk/base-macros/src/wit_world.rs +++ b/sdk/base-macros/src/wit_world.rs @@ -229,7 +229,8 @@ impl ManifestPackage { self.project_kind.as_deref() == Some("authentication-component") } - /// Resolves fully-qualified imports exported by `package.metadata.miden.dependencies`. + /// Resolves fully-qualified imports exported by the compiled packages of the + /// `miden-project.toml` path dependencies. pub(crate) fn collect_miden_dependency_imports( &self, error_span: Span, diff --git a/sdk/sdk/MIGRATION.md b/sdk/sdk/MIGRATION.md index 36c673595..927291c48 100644 --- a/sdk/sdk/MIGRATION.md +++ b/sdk/sdk/MIGRATION.md @@ -277,7 +277,10 @@ A `.masp` built by an older SDK has no embedded WIT and is rejected during macro "does not embed component WIT" error; rebuild each dependency with the current toolchain (`cargo miden build`). Components written without the `#[component]` macro — a hand-written `wit/` directory and a bare `miden::generate!()` — embed their WIT automatically when the `wit/` -directory contains a single `.wit` file, so no changes are needed there. +directory contains a single `.wit` file, so no changes are needed there. WIT split across multiple +files, or referencing packages under `wit/deps/` other than the bundled SDK WIT, cannot be +embedded verbatim yet; consolidate it into one self-contained file if the component is consumed as +a Miden dependency. ## 0.13.0 -> 0.13.1 diff --git a/tests/integration/src/sdk/macros.rs b/tests/integration/src/sdk/macros.rs index dc27aeceb..8b47ba740 100644 --- a/tests/integration/src/sdk/macros.rs +++ b/tests/integration/src/sdk/macros.rs @@ -1020,6 +1020,55 @@ impl TestComponent for TestComponentStorage { assert!(stderr.contains("cargo miden build"), "unexpected stderr: {stderr}"); } +#[test] +fn bare_generate_local_wit_imports_dependency_package() { + // A manually authored crate's local `wit/` world may import a Miden dependency's interface. + // The dependency WIT (read from its compiled `.masp`) must be in the resolver before the + // local WIT is parsed, or resolution fails with a bare "package not found". + let lib_rs = r#"#![no_std] + +#[global_allocator] +static ALLOC: miden::BumpAlloc = miden::BumpAlloc::new(); + +#[cfg(not(test))] +#[panic_handler] +fn panic(_info: &core::panic::PanicInfo) -> ! { + loop {} +} + +miden::generate!(); + +pub fn use_import() -> miden::Felt { + crate::bindings::miden::test_sibling::test_sibling::get_value() +} +"#; + + let cargo_proj = account_component_project_with_sibling_dep_inner( + "bare_generate_local_wit_imports_dep", + lib_rs, + Some(TEST_SIBLING_GENERATED_WIT), + ); + let wit_dir = cargo_proj.root().join("wit"); + std::fs::create_dir_all(&wit_dir).expect("local wit directory must be created"); + std::fs::write( + wit_dir.join("consumer.wit"), + r#"package miden:wit-consumer@0.0.1; + +world consumer { + import miden:test-sibling/test-sibling@0.0.1; +} +"#, + ) + .expect("local wit fixture must be written"); + + let output = cargo_check_miden_target(&cargo_proj); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + output.status.success(), + "expected local WIT importing a dependency package to resolve: {stderr}" + ); +} + #[test] fn component_sibling_call_passes_an_interface_owned_record() { // The sibling interface owns the `point` record and passes it across a sibling call. This From b43934b53c412ca88d6e286d409016fba442a39a Mon Sep 17 00:00:00 2001 From: Denys Zadorozhnyi Date: Fri, 3 Jul 2026 14:10:27 +0300 Subject: [PATCH 04/12] fix: validate embedded WIT self-containment and harden dependency package lookup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A second review round surfaced holes in the WIT embedding and the `.masp` lookup. Bare `miden::generate!()` embedded the local WIT file verbatim, imports included — but consumers resolve embedded WIT against the bundled SDK WIT alone, so such a package failed downstream with a misleading "rebuild the dependency" hint. The package search consulted ambient directories (`CARGO_TARGET_DIR`, `OUT_DIR`, cwd targets) with a name-blind solitary-`.masp` fallback, which could silently bind the wrong package and made the missing-package tests fail when a stray workspace artifact shared the fixture's name. The concatenated-section detector hard-errored on valid WIT with block-commented `package` lines and missed true concatenation when a blob lacked a trailing newline, and a repeated same-named metadata section within one module silently overwrote the first. Producers now parse the candidate local WIT against the SDK prelude and skip embedding when it is not self-contained or exports nothing, routing consumers to the accurate "does not embed component WIT" error; the consumer diagnostic names the self-containment requirement when embedded WIT references a package that is not embedded alongside it. Package lookup prefers the freshest name-matched artifact across profile directories (Cargo never sets `PROFILE` for proc macros, so profile order alone lets a stale debug package shadow a fresh release build) and accepts a solitary `.masp` only in the dependency's own target directories; the unit fixtures use fixture-unique dependency names. Embedded WIT payloads are wrapped in boundary newlines so section concatenation always keeps `package` declarations on their own lines, and the declaration counter strips nested `/* */` block comments. Both the account-metadata and WIT section arms now reject a repeated section per core module, and the translator merges all package-section payloads through one `collect_package_sections` helper with a uniform at-most-one-module error, replacing the per-payload gather-and-assert blocks. --- frontend/wasm/src/component/translator.rs | 39 +---- frontend/wasm/src/module/module_env.rs | 108 ++++++++++++- frontend/wasm/src/module/module_env/tests.rs | 29 ++++ sdk/base-macros/src/dependency_package.rs | 152 +++++++++++++++---- sdk/base-macros/src/generate.rs | 66 ++++++++ sdk/base-macros/src/util.rs | 37 +++++ sdk/base-macros/src/wit_world.rs | 86 +++++++++-- 7 files changed, 432 insertions(+), 85 deletions(-) diff --git a/frontend/wasm/src/component/translator.rs b/frontend/wasm/src/component/translator.rs index 8e169ba1e..4c18b22f2 100644 --- a/frontend/wasm/src/component/translator.rs +++ b/frontend/wasm/src/component/translator.rs @@ -1,9 +1,7 @@ use std::rc::Rc; use cranelift_entity::PrimaryMap; -use midenc_frontend_wasm_metadata::{ - FrontendMetadata, PackageSections, ProtocolExportKind, WASM_COMPONENT_WIT_CUSTOM_SECTION_NAME, -}; +use midenc_frontend_wasm_metadata::{FrontendMetadata, ProtocolExportKind}; use midenc_hir::{ self as hir2, BuilderExt, Context, FxHashMap, FxHashSet, Ident, SymbolNameComponent, SymbolPath, @@ -35,7 +33,8 @@ use crate::{ build_ir::build_ir_module, instance::ModuleArgument, module_env::{ - ParsedModule, merge_frontend_metadata, validate_lifted_frontend_metadata_exports, + ParsedModule, collect_package_sections, merge_frontend_metadata, + validate_lifted_frontend_metadata_exports, }, module_translation_state::ModuleTranslationState, types::{EntityIndex, FuncIndex}, @@ -175,38 +174,12 @@ impl<'a> ComponentTranslator<'a> { &self.lifted_export_names, )?; - let component_wit_bytes_vec: Vec> = self - .nested_modules - .iter() - .flat_map(|t| t.1.component_wit_bytes.map(|slice| slice.to_vec())) - .collect(); - if component_wit_bytes_vec.len() > 1 { - return Err(Report::msg(format!( - "found {} '{WASM_COMPONENT_WIT_CUSTOM_SECTION_NAME}' custom sections across the \ - component's core modules; a component may embed at most one component WIT section", - component_wit_bytes_vec.len(), - ))); - } - let component_wit_bytes = component_wit_bytes_vec.first().map(ToOwned::to_owned); - - let account_component_metadata_bytes_vec: Vec> = self - .nested_modules - .into_iter() - .flat_map(|t| t.1.account_component_metadata_bytes.map(|slice| slice.to_vec())) - .collect(); - assert!( - account_component_metadata_bytes_vec.len() <= 1, - "unexpected multiple core Wasm module to have account component metadata section", - ); - let account_component_metadata_bytes = - account_component_metadata_bytes_vec.first().map(ToOwned::to_owned); + let sections = + collect_package_sections(self.nested_modules.iter().map(|(_, module)| module))?; let output = FrontendOutput { component: self.result.component, - sections: PackageSections { - account_component_metadata: account_component_metadata_bytes, - component_wit: component_wit_bytes, - }, + sections, }; Ok(output) } diff --git a/frontend/wasm/src/module/module_env.rs b/frontend/wasm/src/module/module_env.rs index 55b7c7a44..0593532df 100644 --- a/frontend/wasm/src/module/module_env.rs +++ b/frontend/wasm/src/module/module_env.rs @@ -4,7 +4,7 @@ use std::path::PathBuf; use cranelift_entity::{PrimaryMap, packed_option::ReservedValue}; use midenc_frontend_wasm_metadata::{ - FrontendMetadata, WASM_COMPONENT_WIT_CUSTOM_SECTION_NAME, + FrontendMetadata, PackageSections, WASM_COMPONENT_WIT_CUSTOM_SECTION_NAME, WASM_FRONTEND_METADATA_CUSTOM_SECTION_NAME, decode_section, }; use midenc_hir::{FxHashMap, FxHashSet, Ident, interner::Symbol}; @@ -129,16 +129,92 @@ fn validate_component_wit_section( /// Counts top-level `package ;` declarations in WIT source. /// -/// Nested package declarations (`package { ... }`) are excluded; a well-formed embedded WIT -/// source contains exactly one top-level declaration, so a higher count indicates concatenated -/// sections from multiple component implementations. +/// Comments — including nested `/* */` block comments — are stripped first so commented-out +/// declarations are not counted, and nested package declarations (`package { ... }`) are +/// excluded. A well-formed embedded WIT source contains exactly one top-level declaration, so a +/// higher count indicates concatenated sections from multiple component implementations. fn count_top_level_wit_packages(wit: &str) -> usize { - wit.lines() - .map(|line| line.split_once("//").map(|(before, _)| before).unwrap_or(line).trim()) + strip_wit_comments(wit) + .lines() + .map(str::trim) .filter(|line| line.starts_with("package ") && line.contains(';') && !line.contains('{')) .count() } +/// Strips `//` line comments and nested `/* */` block comments, preserving line structure. +fn strip_wit_comments(wit: &str) -> String { + let mut stripped = String::with_capacity(wit.len()); + let mut chars = wit.chars().peekable(); + let mut block_depth = 0usize; + while let Some(ch) = chars.next() { + match ch { + '/' if block_depth == 0 && chars.peek() == Some(&'/') => { + for next in chars.by_ref() { + if next == '\n' { + stripped.push('\n'); + break; + } + } + } + '/' if chars.peek() == Some(&'*') => { + chars.next(); + block_depth += 1; + } + '*' if block_depth > 0 && chars.peek() == Some(&'/') => { + chars.next(); + block_depth -= 1; + } + '\n' => stripped.push('\n'), + _ if block_depth == 0 => stripped.push(ch), + _ => {} + } + } + stripped +} + +/// Merges the package-section payloads of all core modules that feed one component. +/// +/// Each payload is a singleton of the final package, so at most one module may supply it. +pub(crate) fn collect_package_sections<'data>( + modules: impl Iterator>, +) -> WasmResult { + let mut account_component_metadata = None; + let mut component_wit = None; + for module in modules { + merge_section_payload( + &mut account_component_metadata, + module.account_component_metadata_bytes, + "rodata,miden_account", + )?; + merge_section_payload( + &mut component_wit, + module.component_wit_bytes, + WASM_COMPONENT_WIT_CUSTOM_SECTION_NAME, + )?; + } + Ok(PackageSections { + account_component_metadata: account_component_metadata.map(<[u8]>::to_vec), + component_wit: component_wit.map(<[u8]>::to_vec), + }) +} + +/// Records a module's section payload, rejecting a second module supplying the same section. +fn merge_section_payload<'data>( + merged: &mut Option<&'data [u8]>, + payload: Option<&'data [u8]>, + section_name: &str, +) -> WasmResult<()> { + if let Some(payload) = payload + && merged.replace(payload).is_some() + { + return Err(Report::msg(format!( + "found multiple '{section_name}' custom sections across the component's core modules; \ + only one is allowed per component" + ))); + } + Ok(()) +} + /// Collects the frontend metadata entries emitted by all core modules of one component. /// /// A component's metadata is single-kind by construction: the SDK macros emit either one @@ -360,11 +436,27 @@ impl<'a, 'data> ModuleEnvironment<'a, 'data> { } Payload::CustomSection(s) if s.name().starts_with(".debug_") => self.dwarf_section(&s), Payload::CustomSection(s) if s.name() == "rodata,miden_account" => { - self.result.account_component_metadata_bytes = Some(s.data()); + if self.result.account_component_metadata_bytes.replace(s.data()).is_some() { + return Err(diagnostics + .diagnostic(Severity::Error) + .with_message( + "wasm error: multiple 'rodata,miden_account' custom sections were \ + found; only one is allowed per core Wasm module", + ) + .into_report()); + } } Payload::CustomSection(s) if s.name() == WASM_COMPONENT_WIT_CUSTOM_SECTION_NAME => { validate_component_wit_section(s.data(), diagnostics)?; - self.result.component_wit_bytes = Some(s.data()); + if self.result.component_wit_bytes.replace(s.data()).is_some() { + return Err(diagnostics + .diagnostic(Severity::Error) + .with_message(format!( + "wasm error: multiple '{WASM_COMPONENT_WIT_CUSTOM_SECTION_NAME}' \ + custom sections were found; only one is allowed per core Wasm module" + )) + .into_report()); + } } Payload::CustomSection(s) if s.name() == WASM_FRONTEND_METADATA_CUSTOM_SECTION_NAME => { let metadata = decode_section(s.data()).map_err(|err| { diff --git a/frontend/wasm/src/module/module_env/tests.rs b/frontend/wasm/src/module/module_env/tests.rs index 8d6edf906..57aed99d9 100644 --- a/frontend/wasm/src/module/module_env/tests.rs +++ b/frontend/wasm/src/module/module_env/tests.rs @@ -52,6 +52,35 @@ package miden:nested@0.1.0 { assert_eq!(count_top_level_wit_packages(wit), 1); } +/// Ensures declarations inside (nested) block comments are not counted as top-level packages. +#[test] +fn component_wit_ignores_block_commented_packages() { + let wit = r#"/* legacy: +package miden:old@0.1.0; +/* nested comment with package miden:inner@0.1.0; */ +still commented +*/ +package miden:new@0.1.0; +"#; + + assert_eq!(count_top_level_wit_packages(wit), 1); +} + +/// Documents the detector's boundary: byte-wise gluing without a newline hides the second +/// declaration. The producers therefore wrap every embedded WIT payload in boundary newlines +/// (`normalize_embedded_wit` in the SDK macros), which keeps concatenated sections detectable. +#[test] +fn component_wit_concatenation_needs_boundary_newlines() { + let first_without_trailing_newline = "package miden:first@0.1.0;\n\nworld first-world {\n}"; + let second = "package miden:second@0.1.0;\n\nworld second-world {\n}\n"; + + let glued = format!("{first_without_trailing_newline}{second}"); + assert_eq!(count_top_level_wit_packages(&glued), 1); + + let normalized = format!("\n{first_without_trailing_newline}\n\n{second}\n"); + assert_eq!(count_top_level_wit_packages(&normalized), 2); +} + /// Ensures the frontend metadata entries emitted across a component's core modules are collected /// into one list — in particular the several `#[account_procedure]` entries of an account /// component. diff --git a/sdk/base-macros/src/dependency_package.rs b/sdk/base-macros/src/dependency_package.rs index 90cbcbaa3..9f5f84ad6 100644 --- a/sdk/base-macros/src/dependency_package.rs +++ b/sdk/base-macros/src/dependency_package.rs @@ -137,9 +137,8 @@ pub(crate) fn resolve_dependency_package_path(name: &str, root: &Path) -> Result // nowhere else; searching the conventional output directories would find stale artifacts. if let Some(filesystem_cache_dir) = env::var_os("MIDENC_PACKAGE_CACHE") { let filesystem_cache_dir = PathBuf::from(filesystem_cache_dir); - if let Some(package) = find_dependency_package_in_dir(&filesystem_cache_dir, &package_stems)? - { - return Ok(package.clone()); + if let Some(package) = find_stem_match_in_dir(&filesystem_cache_dir, &package_stems)? { + return Ok(package); } return Err(Error::new( @@ -154,9 +153,30 @@ pub(crate) fn resolve_dependency_package_path(name: &str, root: &Path) -> Result } let output_dirs = dependency_output_dirs(root, &profiles); - for dir in &output_dirs { - if let Some(package) = find_dependency_package_in_dir(dir, &package_stems)? { - return Ok(package.clone()); + + // Prefer the freshest name-matched package among the dependency's own output directories: + // `PROFILE` is never set for proc macros, so profile order alone would let a stale debug + // package shadow a fresh release build. + let own_matches = output_dirs + .own + .iter() + .filter_map(|dir| find_stem_match_in_dir(dir, &package_stems).transpose()) + .collect::, _>>()?; + if let Some(package) = latest_modified(own_matches) { + return Ok(package); + } + + // A solitary `.masp` is accepted only in the dependency's own directories, where it cannot + // belong to anything else; an ambient directory may hold an unrelated package. + for dir in &output_dirs.own { + if let Some(package) = find_solitary_package_in_dir(dir)? { + return Ok(package); + } + } + + for dir in &output_dirs.ambient { + if let Some(package) = find_stem_match_in_dir(dir, &package_stems)? { + return Ok(package); } } @@ -191,16 +211,29 @@ fn missing_cached_dependency_package_message( ) } +/// Returns the most recently modified of the given package paths. +/// +/// Ties (including unreadable timestamps) resolve to the earliest candidate, i.e. the most +/// precise search directory. +fn latest_modified(packages: Vec) -> Option { + packages + .into_iter() + .rev() + .max_by_key(|path| fs::metadata(path).and_then(|metadata| metadata.modified()).ok()) +} + /// Formats the diagnostic emitted when a dependency's compiled package cannot be located. fn missing_dependency_package_message( name: &str, root: &Path, package_stems: &[String], - output_dirs: &[PathBuf], + output_dirs: &DependencyOutputDirs, profiles: &[String], ) -> String { let searched = output_dirs + .own .iter() + .chain(output_dirs.ambient.iter()) .map(|dir| format!("'{}'", dir.display())) .collect::>() .join(", "); @@ -240,34 +273,43 @@ fn dependency_build_hint(root: &Path) -> String { } } -/// Returns candidate output directories where a dependency `.masp` may have been written. -fn dependency_output_dirs(root: &Path, profiles: &[String]) -> Vec { - let mut dirs = Vec::new(); +/// Candidate output directories where a dependency `.masp` may have been written. +struct DependencyOutputDirs { + /// Directories derived from the dependency's own root; a package found here belongs to it. + own: Vec, + /// Ambient directories (`CARGO_TARGET_DIR`, `OUT_DIR`, cwd targets) that may also hold + /// packages of unrelated projects. + ambient: Vec, +} +/// Returns candidate output directories where a dependency `.masp` may have been written. +fn dependency_output_dirs(root: &Path, profiles: &[String]) -> DependencyOutputDirs { // The dependency root is the most precise location for path dependencies. Prefer it over // ambient target directories so restored or previously built artifacts cannot shadow the // package that belongs to the dependency being wrapped. - push_profile_dirs(&mut dirs, root.join("target"), profiles); - push_manifest_ancestor_target_profile_dirs(&mut dirs, root, profiles); - push_ancestor_target_profile_dirs(&mut dirs, root, profiles); + let mut own = Vec::new(); + push_profile_dirs(&mut own, root.join("target"), profiles); + push_manifest_ancestor_target_profile_dirs(&mut own, root, profiles); + push_ancestor_target_profile_dirs(&mut own, root, profiles); + let mut ambient = Vec::new(); if let Ok(target_dir) = env::var("CARGO_TARGET_DIR") { - push_profile_dirs(&mut dirs, PathBuf::from(target_dir), profiles); + push_profile_dirs(&mut ambient, PathBuf::from(target_dir), profiles); } if let Ok(out_dir) = env::var("OUT_DIR") { for ancestor in Path::new(&out_dir).ancestors() { - push_profile_dirs(&mut dirs, ancestor.to_path_buf(), profiles); + push_profile_dirs(&mut ambient, ancestor.to_path_buf(), profiles); } } if let Ok(current_dir) = env::current_dir() { - push_profile_dirs(&mut dirs, current_dir.join("target"), profiles); - push_manifest_ancestor_target_profile_dirs(&mut dirs, ¤t_dir, profiles); - push_ancestor_target_profile_dirs(&mut dirs, ¤t_dir, profiles); + push_profile_dirs(&mut ambient, current_dir.join("target"), profiles); + push_manifest_ancestor_target_profile_dirs(&mut ambient, ¤t_dir, profiles); + push_ancestor_target_profile_dirs(&mut ambient, ¤t_dir, profiles); } - dirs + DependencyOutputDirs { own, ambient } } /// Adds `target/miden/` directories while preserving insertion order. @@ -302,13 +344,10 @@ fn push_manifest_ancestor_target_profile_dirs( } } -/// Finds a dependency package in `dir`, preferring filenames that match the package name. -fn find_dependency_package_in_dir( - dir: &Path, - package_stems: &[String], -) -> Result, Error> { +/// Lists the `.masp` packages in `dir`, sorted by path. +fn packages_in_dir(dir: &Path) -> Result, Error> { if !dir.is_dir() { - return Ok(None); + return Ok(Vec::new()); } let mut packages = fs::read_dir(dir) @@ -333,7 +372,12 @@ fn find_dependency_package_in_dir( }) .collect::>(); packages.sort(); + Ok(packages) +} +/// Finds a package in `dir` whose filename matches one of the dependency's name stems. +fn find_stem_match_in_dir(dir: &Path, package_stems: &[String]) -> Result, Error> { + let packages = packages_in_dir(dir)?; for stem in package_stems { if let Some(package) = packages.iter().find(|path| { path.file_stem() @@ -343,8 +387,13 @@ fn find_dependency_package_in_dir( return Ok(Some(package.clone())); } } + Ok(None) +} - Ok((packages.len() == 1).then(|| packages[0].clone())) +/// Returns the package in `dir` when it is the directory's only one, regardless of its name. +fn find_solitary_package_in_dir(dir: &Path) -> Result, Error> { + let mut packages = packages_in_dir(dir)?; + Ok((packages.len() == 1).then(|| packages.remove(0))) } /// Returns likely `.masp` filename stems for a dependency. @@ -450,6 +499,51 @@ mod tests { std::fs::remove_dir_all(temp_root).unwrap(); } + #[test] + fn prefers_the_freshest_stem_match_across_profile_dirs() { + let temp_root = + env::temp_dir().join(format!("midenc-dep-package-freshest-{}", std::process::id())); + let debug_dir = temp_root.join("target/miden/debug"); + let release_dir = temp_root.join("target/miden/release"); + std::fs::create_dir_all(&debug_dir).unwrap(); + std::fs::create_dir_all(&release_dir).unwrap(); + let debug_path = debug_dir.join("dep_fixture.masp"); + let release_path = release_dir.join("dep_fixture.masp"); + std::fs::write(&debug_path, b"stale").unwrap(); + std::fs::write(&release_path, b"fresh").unwrap(); + // `PROFILE` is unset for proc macros, so the debug dir is searched first; only the + // freshest-match rule makes the newer release artifact win. + let stale = std::time::SystemTime::now() - std::time::Duration::from_secs(600); + std::fs::File::options() + .write(true) + .open(&debug_path) + .unwrap() + .set_modified(stale) + .unwrap(); + + let resolved = resolve_dependency_package_path("dep-fixture", &temp_root).unwrap(); + + assert_eq!(resolved, release_path); + + std::fs::remove_dir_all(temp_root).unwrap(); + } + + #[test] + fn accepts_a_solitary_package_in_the_dependency_own_dirs() { + let temp_root = + env::temp_dir().join(format!("midenc-dep-package-solitary-{}", std::process::id())); + let debug_dir = temp_root.join("target/miden/debug"); + std::fs::create_dir_all(&debug_dir).unwrap(); + let package_path = debug_dir.join("oddly_named.masp"); + std::fs::write(&package_path, b"package bytes").unwrap(); + + let resolved = resolve_dependency_package_path("dep-fixture", &temp_root).unwrap(); + + assert_eq!(resolved, package_path); + + std::fs::remove_dir_all(temp_root).unwrap(); + } + #[test] fn missing_dependency_package_message_explains_macro_time_requirement() { let temp_root = @@ -459,8 +553,10 @@ mod tests { let profiles = vec!["release".to_string(), "debug".to_string()]; let stems = vec!["counter".to_string(), "counter_component".to_string()]; - let output_dirs = - vec![temp_root.join("target/miden/release"), temp_root.join("target/miden/debug")]; + let output_dirs = DependencyOutputDirs { + own: vec![temp_root.join("target/miden/release"), temp_root.join("target/miden/debug")], + ambient: Vec::new(), + }; let message = missing_dependency_package_message( "counter", diff --git a/sdk/base-macros/src/generate.rs b/sdk/base-macros/src/generate.rs index c86fc1bcf..067ee3de2 100644 --- a/sdk/base-macros/src/generate.rs +++ b/sdk/base-macros/src/generate.rs @@ -162,6 +162,12 @@ pub(crate) fn expand(input: proc_macro::TokenStream) -> proc_macro::TokenStream /// Inline invocations come from other SDK macros (which embed the WIT themselves when the crate /// is a component), and multi-file `wit/` directories cannot be embedded verbatim, so both yield /// no section. +/// +/// The WIT is embedded verbatim, and consumers resolve it against the bundled SDK WIT alone — so +/// a file that is not self-contained (it imports other packages, e.g. from `wit/deps/`) or that +/// exports no interface is skipped as well: embedding it would produce a package whose WIT no +/// consumer can parse, while skipping routes consumers to the accurate "does not embed component +/// WIT" diagnostic. fn local_wit_link_section( args: &GenerateArgs, config: &manifest_paths::ResolvedWit, @@ -179,6 +185,9 @@ fn local_wit_link_section( format!("failed to read WIT file '{}': {err}", local_wit_path.display()), ) })?; + if crate::wit_world::parse_dependency_wit_source(&wit_source).is_err() { + return Ok(TokenStream2::new()); + } Ok(crate::util::generate_wit_link_section(&wit_source)) } @@ -1691,4 +1700,61 @@ interface api { (resolve, world) } + + /// Builds a `ResolvedWit` whose embeddable local WIT is a temp file with the given source. + fn resolved_wit_with_local_file(name: &str, wit: &str) -> manifest_paths::ResolvedWit { + let dir = env::temp_dir() + .join(format!("miden-base-macros-generate-{name}-{}", std::process::id())); + fs::create_dir_all(&dir).expect("local WIT fixture directory must be created"); + let path = dir.join("component.wit"); + fs::write(&path, wit).expect("local WIT fixture must be written"); + manifest_paths::ResolvedWit { + paths: Vec::new(), + dependency_sources: Vec::new(), + local_wit_root: Some(dir), + world: None, + embeddable_local_wit: Some(path), + } + } + + #[test] + fn local_wit_link_section_embeds_self_contained_wit() { + let config = resolved_wit_with_local_file( + "self-contained", + r#"package miden:self-contained@0.1.0; + +interface api { + get: func() -> u64; +} + +world api-world { + export api; +} +"#, + ); + + let tokens = local_wit_link_section(&GenerateArgs::default(), &config).unwrap(); + + assert!(!tokens.is_empty(), "self-contained local WIT must be embedded"); + } + + #[test] + fn local_wit_link_section_skips_non_self_contained_wit() { + // Consumers resolve embedded WIT against the SDK prelude alone, so a file importing + // another package must not be embedded — its package would be unusable as a dependency + // with a misleading parse error. + let config = resolved_wit_with_local_file( + "importing", + r#"package miden:importing@0.1.0; + +world importer { + import miden:not-embedded/api@0.1.0; +} +"#, + ); + + let tokens = local_wit_link_section(&GenerateArgs::default(), &config).unwrap(); + + assert!(tokens.is_empty(), "non-self-contained local WIT must not be embedded"); + } } diff --git a/sdk/base-macros/src/util.rs b/sdk/base-macros/src/util.rs index a8d1cc072..d26977662 100644 --- a/sdk/base-macros/src/util.rs +++ b/sdk/base-macros/src/util.rs @@ -76,6 +76,7 @@ pub(crate) fn generate_frontend_link_section(entries: &[FrontendMetadata]) -> To /// implementations concatenates their identically named sections instead, which the Wasm frontend /// rejects with a dedicated diagnostic when it parses the section. pub(crate) fn generate_wit_link_section(wit_source: &str) -> TokenStream2 { + let wit_source = normalize_embedded_wit(wit_source); let wit_bytes = wit_source.as_bytes(); let wit_len = wit_bytes.len(); let encoded_bytes = Literal::byte_string(wit_bytes); @@ -92,6 +93,24 @@ pub(crate) fn generate_wit_link_section(wit_source: &str) -> TokenStream2 { } } +/// Wraps embedded WIT in newlines so section boundaries stay line boundaries. +/// +/// The linker concatenates identically named custom sections byte-wise; without the padding a +/// blob missing a trailing newline would glue the next blob's `package ...;` declaration onto its +/// last line, hiding the concatenation from the frontend's duplicate-implementation detector. +fn normalize_embedded_wit(wit_source: &str) -> String { + let mut normalized = + String::with_capacity(wit_source.len() + 2 - usize::from(wit_source.starts_with('\n'))); + if !wit_source.starts_with('\n') { + normalized.push('\n'); + } + normalized.push_str(wit_source); + if !wit_source.ends_with('\n') { + normalized.push('\n'); + } + normalized +} + /// Strips line comments starting with `//` from the provided source line. /// /// Returns the portion of the line before the comment, or the entire line if no comment exists. @@ -104,3 +123,21 @@ pub fn strip_line_comment(line: &str) -> &str { None => line, } } + +#[cfg(test)] +mod tests { + use super::normalize_embedded_wit; + + #[test] + fn embedded_wit_gains_boundary_newlines() { + assert_eq!(normalize_embedded_wit("package miden:a@0.1.0;"), "\npackage miden:a@0.1.0;\n"); + } + + #[test] + fn embedded_wit_with_boundary_newlines_is_unchanged() { + assert_eq!( + normalize_embedded_wit("\npackage miden:a@0.1.0;\n"), + "\npackage miden:a@0.1.0;\n" + ); + } +} diff --git a/sdk/base-macros/src/wit_world.rs b/sdk/base-macros/src/wit_world.rs index 33bd75298..79d6bee86 100644 --- a/sdk/base-macros/src/wit_world.rs +++ b/sdk/base-macros/src/wit_world.rs @@ -388,11 +388,20 @@ fn collect_miden_dependencies( /// Formats the dependency WIT diagnostic emitted by SDK macros. fn dependency_wit_error_message(source: &DependencyWitSource, details: &str) -> String { + // A "package not found" from wit-parser means the embedded WIT itself references another + // package: the rebuild advice cannot fix that, so name the self-containment requirement. + let guidance = if details.contains("not found") { + "The dependency's embedded WIT references a package that is not embedded alongside it; \ + embedded WIT must be self-contained apart from the bundled SDK WIT (`miden:base`)." + } else { + "The SDK macros read the dependency's component WIT embedded in the `.masp` package during \ + Rust macro expansion to construct dependency imports; rebuild the dependency with the \ + current `cargo miden build`." + }; + format!( "failed to load dependency WIT metadata for dependency '{}' (root '{}') from its compiled \ - package '{}': {details}. The SDK macros read the dependency's component WIT embedded in \ - the `.masp` package during Rust macro expansion to construct dependency imports; rebuild \ - the dependency with the current `cargo miden build`.", + package '{}': {details}. {guidance}", source.name, source.root.display(), source.package_path.display(), @@ -401,12 +410,16 @@ fn dependency_wit_error_message(source: &DependencyWitSource, details: &str) -> /// WIT metadata extracted from a dependency package. #[derive(Debug)] -struct DependencyWit { +pub(crate) struct DependencyWit { interfaces: Vec, } /// Parses dependency WIT source and returns metadata for its exported interfaces. -fn parse_dependency_wit_source(wit_source: &str) -> Result { +/// +/// The source is resolved against the bundled SDK WIT alone, which makes this doubly useful: it +/// extracts the exported interfaces of a dependency's embedded WIT, and it is the self-containment +/// check a WIT source must pass before being embedded in the first place. +pub(crate) fn parse_dependency_wit_source(wit_source: &str) -> Result { let mut resolve = Resolve::default(); resolve .push_str("miden.wit", crate::manifest_paths::SDK_WIT_SOURCE) @@ -571,7 +584,7 @@ world basic-wallet-world { ) .expect("fixture module must parse"); let mut package = Assembler::new(source_manager) - .assemble_library("basic-wallet", module, None::>) + .assemble_library("wit-world-fixture", module, None::>) .expect("fixture library must assemble"); package.version = "0.1.0".parse().expect("fixture version must parse"); if let Some(wit) = wit { @@ -588,11 +601,15 @@ world basic-wallet-world { } /// Creates a dependency project root with a compiled package under `target/miden/debug`. - fn basic_wallet_fixture_root() -> PathBuf { + /// + /// The dependency and its artifact carry fixture-unique names: the package search consults + /// ambient directories (`CARGO_TARGET_DIR`, cwd targets), so a name shared with a real + /// workspace artifact could make these tests observe it instead of the fixture. + fn dependency_fixture_root() -> PathBuf { let unique = unique_fixture_suffix(); let root = std::env::temp_dir().join(format!("miden-base-macros-wit-world-{unique}")); write_masp_fixture( - &root.join("target/miden/debug/basic_wallet.masp"), + &root.join("target/miden/debug/wit_world_fixture_dep.masp"), Some(BASIC_WALLET_GENERATED_WIT), ); root @@ -613,7 +630,7 @@ world basic-wallet-world { Uri::new("lib/src.rs"), ); let dependency = miden_project::Dependency::new( - MidenSpan::unknown(Arc::::from("basic-wallet")), + MidenSpan::unknown(Arc::::from("wit-world-fixture-dep")), miden_project::DependencyVersionScheme::Path { path: MidenSpan::unknown(miden_project::Uri::new(package_path.to_string_lossy())), version: None, @@ -788,7 +805,7 @@ world empty-export-world { #[test] fn collects_dependency_interfaces_from_compiled_package() { - let fixture_root = basic_wallet_fixture_root(); + let fixture_root = dependency_fixture_root(); let dependency_root = fixture_root.clone(); let package = package_with_dependency(dependency_root.clone()); @@ -802,7 +819,9 @@ world empty-export-world { assert_eq!(dependencies[0].interfaces[0].import, "miden:basic-wallet/basic-wallet@0.1.0"); assert!(dependencies[0].interfaces[0].types.is_empty()); assert!( - dependencies[0].package_path.ends_with("target/miden/debug/basic_wallet.masp"), + dependencies[0] + .package_path + .ends_with("target/miden/debug/wit_world_fixture_dep.masp"), "unexpected package path: {}", dependencies[0].package_path.display() ); @@ -815,7 +834,7 @@ world empty-export-world { // A dependency that points directly at a `.masp` file is self-contained: the embedded WIT // is read from that package with no additional manifest metadata. let fixture_root = empty_fixture_root(); - let package_path = fixture_root.join("prebuilt/basic_wallet.masp"); + let package_path = fixture_root.join("prebuilt/wit_world_fixture_dep.masp"); write_masp_fixture(&package_path, Some(BASIC_WALLET_GENERATED_WIT)); let package = package_with_dependency(package_path.clone()); @@ -837,7 +856,7 @@ world empty-export-world { #[test] fn missing_dependency_package_reports_actionable_error() { let fixture_root = empty_fixture_root(); - let dependency_root = fixture_root.join("basic-wallet"); + let dependency_root = fixture_root.join("wit-world-fixture-dep"); fs::create_dir_all(&dependency_root).expect("dependency fixture directory must be created"); let package = package_with_dependency(dependency_root); @@ -851,7 +870,7 @@ world empty-export-world { "unexpected error: {message}" ); assert!( - message.contains("Miden dependency 'basic-wallet'"), + message.contains("Miden dependency 'wit-world-fixture-dep'"), "unexpected error: {message}" ); assert!(message.contains("during Rust macro expansion"), "unexpected error: {message}"); @@ -863,8 +882,11 @@ world empty-export-world { #[test] fn package_without_wit_section_reports_rebuild_error() { let fixture_root = empty_fixture_root(); - let dependency_root = fixture_root.join("basic-wallet"); - write_masp_fixture(&dependency_root.join("target/miden/debug/basic_wallet.masp"), None); + let dependency_root = fixture_root.join("wit-world-fixture-dep"); + write_masp_fixture( + &dependency_root.join("target/miden/debug/wit_world_fixture_dep.masp"), + None, + ); let package = package_with_dependency(dependency_root); let error = @@ -878,4 +900,36 @@ world empty-export-world { fs::remove_dir_all(fixture_root).expect("temporary fixture directory must be removed"); } + + #[test] + fn non_self_contained_embedded_wit_reports_self_containment_error() { + // A foreign-produced package may embed WIT that imports another package; the diagnostic + // must name the self-containment requirement instead of suggesting a rebuild. + let importing_wit = r#"package miden:importing@0.1.0; + +world importer { + import miden:not-embedded/api@0.1.0; +} +"#; + let fixture_root = empty_fixture_root(); + let dependency_root = fixture_root.join("wit-world-fixture-dep"); + write_masp_fixture( + &dependency_root.join("target/miden/debug/wit_world_fixture_dep.masp"), + Some(importing_wit), + ); + let package = package_with_dependency(dependency_root); + + let error = + collect_miden_dependencies(&fixture_root, &package, proc_macro2::Span::call_site()) + .expect_err("embedded WIT referencing a foreign package must fail metadata load"); + let message = error.to_string(); + + assert!( + message.contains("references a package that is not embedded alongside it"), + "unexpected error: {message}" + ); + assert!(message.contains("must be self-contained"), "unexpected error: {message}"); + + fs::remove_dir_all(fixture_root).expect("temporary fixture directory must be removed"); + } } From ec71daf6ef4a91d56d4b758487e2410d87114625 Mon Sep 17 00:00:00 2001 From: Denys Zadorozhnyi Date: Sun, 5 Jul 2026 17:18:43 +0300 Subject: [PATCH 05/12] fix: verify the package id when resolving dependency .masp artifacts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The dependency package search could still adopt the wrong artifact: the solitary-`.masp` fallback treated shared ancestor target directories (a workspace's `target/`, which holds every member's packages) as private to the dependency, stem matching returned the first alias per directory so a stale Cargo-named `dep_fixture.masp` shadowed a fresh `dep-fixture.masp` regardless of age, and ambient directories never got the freshest-match rule at all. Deserialize every candidate found by searching and accept it only when its package id matches the dependency's name (normalizing hyphens/underscores); rejected candidates are listed in the not-found error. Order name matches freshest-first across profile directories and stem aliases — own directories before ambient — and confine the name-blind solitary fallback to the dependency's private `/target` directories. An explicit `.masp` file dependency remains the manifest's choice and skips the id check, since the manifest key need not equal the prebuilt package's id. The shared `read_package` helper also replaces the duplicated reader in the FPI flow, and the package fixture writer moves to a common test-support module. Also require exactly one top-level WIT package declaration in the embedded section (a zero-declaration payload now fails at the producing crate instead of in a consumer), reuse the section merge helper for core-module inputs, name the exports-an-interface requirement in the missing-WIT error, and document the editor workflow in MIGRATION.md: `cargo check` of a dependency no longer regenerates its WIT as a side effect, so dependencies need one `cargo miden build` before checking dependents. --- frontend/wasm/src/module/build_ir.rs | 10 +- frontend/wasm/src/module/module_env.rs | 26 +- sdk/base-macros/src/dependency_package.rs | 388 ++++++++++++++++------ sdk/base-macros/src/fpi.rs | 16 +- sdk/base-macros/src/generate.rs | 9 +- sdk/base-macros/src/lib.rs | 2 + sdk/base-macros/src/manifest_paths.rs | 2 + sdk/base-macros/src/test_support.rs | 33 ++ sdk/base-macros/src/util.rs | 3 +- sdk/base-macros/src/wit_world.rs | 28 +- sdk/sdk/MIGRATION.md | 5 + sdk/wasm-metadata/src/lib.rs | 4 +- 12 files changed, 365 insertions(+), 161 deletions(-) create mode 100644 sdk/base-macros/src/test_support.rs diff --git a/frontend/wasm/src/module/build_ir.rs b/frontend/wasm/src/module/build_ir.rs index df166047d..b155fcbf2 100644 --- a/frontend/wasm/src/module/build_ir.rs +++ b/frontend/wasm/src/module/build_ir.rs @@ -1,7 +1,6 @@ use core::{mem, str::FromStr}; use std::rc::Rc; -use midenc_frontend_wasm_metadata::PackageSections; use midenc_hir::{ Builder, BuilderExt, Context, FunctionIdent, FxHashMap, Ident, Op, OpBuilder, SymbolPath, Visibility, @@ -24,7 +23,7 @@ use crate::{ DefinedFuncIndex, func_translator::FuncTranslator, linker_stubs::{is_unreachable_stub, maybe_lower_linker_stub}, - module_env::{FunctionBodyData, ModuleEnvironment, ParsedModule}, + module_env::{FunctionBodyData, ModuleEnvironment, ParsedModule, collect_package_sections}, types::ir_type, }, }; @@ -53,12 +52,7 @@ pub fn translate_module_as_component( if let Some(name_override) = config.override_name.as_ref() { parsed_module.module.set_name_override(name_override.clone()); } - let sections = PackageSections { - account_component_metadata: parsed_module - .account_component_metadata_bytes - .map(|bytes| bytes.to_vec()), - component_wit: parsed_module.component_wit_bytes.map(|bytes| bytes.to_vec()), - }; + let sections = collect_package_sections(core::iter::once(&parsed_module))?; let module_types = module_types_builder; // If a world wasn't provided to us, create one diff --git a/frontend/wasm/src/module/module_env.rs b/frontend/wasm/src/module/module_env.rs index 0593532df..87d829fa2 100644 --- a/frontend/wasm/src/module/module_env.rs +++ b/frontend/wasm/src/module/module_env.rs @@ -96,8 +96,9 @@ pub struct ParsedModule<'data> { /// Validates that a component WIT custom section holds exactly one top-level WIT package. /// /// Linking two `#[component]` implementations into one binary concatenates their identically -/// named custom sections into a single section whose merged text is not valid WIT; name the -/// actual cause here instead of surfacing an opaque parse error in dependent crates. +/// named custom sections into a single section whose merged text is not valid WIT, and a section +/// without any package declaration cannot be consumed either; name the actual cause here, at the +/// producing crate, instead of surfacing an opaque parse error in dependent crates. fn validate_component_wit_section( bytes: &[u8], diagnostics: &DiagnosticsHandler, @@ -112,19 +113,24 @@ fn validate_component_wit_section( .into_report()); }; - let package_declarations = count_top_level_wit_packages(wit); - if package_declarations > 1 { - return Err(diagnostics + match count_top_level_wit_packages(wit) { + 1 => Ok(()), + 0 => Err(diagnostics + .diagnostic(Severity::Error) + .with_message(format!( + "wasm error: the '{WASM_COMPONENT_WIT_CUSTOM_SECTION_NAME}' custom section does \ + not contain a top-level WIT package declaration" + )) + .into_report()), + package_declarations => Err(diagnostics .diagnostic(Severity::Error) .with_message(format!( "wasm error: found {package_declarations} top-level WIT package declarations in \ the '{WASM_COMPONENT_WIT_CUSTOM_SECTION_NAME}' custom section; a linked binary \ may contain at most one `#[component]` implementation" )) - .into_report()); + .into_report()), } - - Ok(()) } /// Counts top-level `package ;` declarations in WIT source. @@ -175,8 +181,8 @@ fn strip_wit_comments(wit: &str) -> String { /// Merges the package-section payloads of all core modules that feed one component. /// /// Each payload is a singleton of the final package, so at most one module may supply it. -pub(crate) fn collect_package_sections<'data>( - modules: impl Iterator>, +pub(crate) fn collect_package_sections<'a, 'data: 'a>( + modules: impl Iterator>, ) -> WasmResult { let mut account_component_metadata = None; let mut component_wit = None; diff --git a/sdk/base-macros/src/dependency_package.rs b/sdk/base-macros/src/dependency_package.rs index 9f5f84ad6..d684ace3d 100644 --- a/sdk/base-macros/src/dependency_package.rs +++ b/sdk/base-macros/src/dependency_package.rs @@ -9,7 +9,6 @@ use std::{ }; use miden_mast_package::{Package, SectionId}; -use miden_protocol::utils::serde::Deserializable; use midenc_frontend_wasm_metadata::PACKAGE_WIT_SECTION_ID; use proc_macro2::Span; use syn::Error; @@ -48,17 +47,19 @@ pub(crate) fn collect_dependency_wit_sources( ), ) })?; - let package_path = - resolve_dependency_package_path(dependency.name().as_ref(), &dependency_root)?; - let wit = read_package_wit(&package_path)?; + let resolved = + resolve_dependency_package(dependency.name().as_ref(), &dependency_root)?; + let wit = package_wit(&resolved.package, &resolved.path)?; sources.push(DependencyWitSource { name: dependency.name().to_string(), root: dependency_root, - package_path, + package_path: resolved.path, wit, }); } - // TODO(pauls): We should also handle git dependencies at some point + // Registry dependencies are MASM base libraries (`miden-core`, `miden-protocol`) + // consumed at link time only, so they carry no component WIT. Git and workspace + // schemes are not yet supported at macro expansion time (TODO(pauls)). _ => continue, } } @@ -72,8 +73,8 @@ pub(crate) fn wit_section_id() -> SectionId { .expect("the WIT section id must be a valid custom section id") } -/// Reads the component WIT embedded in a compiled Miden package. -pub(crate) fn read_package_wit(package_path: &Path) -> Result { +/// Reads and deserializes a compiled Miden package. +pub(crate) fn read_package(package_path: &Path) -> Result, Error> { let error_span = Span::call_site(); let package_bytes = fs::read(package_path).map_err(|err| { Error::new( @@ -81,13 +82,17 @@ pub(crate) fn read_package_wit(package_path: &Path) -> Result { format!("failed to read dependency package '{}': {err}", package_path.display()), ) })?; - let package = Package::read_from_bytes(&package_bytes).map_err(|err| { + Package::read_from_bytes_unchecked(&package_bytes).map(Box::new).map_err(|err| { Error::new( error_span, format!("failed to deserialize dependency package '{}': {err}", package_path.display()), ) - })?; + }) +} +/// Extracts the component WIT embedded in a compiled Miden package. +fn package_wit(package: &Package, package_path: &Path) -> Result { + let error_span = Span::call_site(); let wit_section_id = wit_section_id(); let Some(section) = package.sections.iter().find(|section| section.id == wit_section_id) else { return Err(Error::new( @@ -98,7 +103,7 @@ pub(crate) fn read_package_wit(package_path: &Path) -> Result { Rebuild the dependency with the current `cargo miden build`. For manually \ authored components (a hand-written `wit/` directory with a bare \ `miden::generate!()`), the WIT is embedded only when the `wit/` directory \ - contains exactly one `.wit` file.", + contains exactly one `.wit` file that is self-contained and exports an interface.", package_path.display() ), )); @@ -116,10 +121,37 @@ pub(crate) fn read_package_wit(package_path: &Path) -> Result { }) } -/// Finds the `.masp` package artifact for the dependency named `name` rooted at `root`. -pub(crate) fn resolve_dependency_package_path(name: &str, root: &Path) -> Result { +/// A located, deserialized, and identity-checked dependency package. +pub(crate) struct ResolvedDependencyPackage { + /// Path of the `.masp` file the package was read from. + pub(crate) path: PathBuf, + /// The deserialized package. + pub(crate) package: Box, +} + +impl core::fmt::Debug for ResolvedDependencyPackage { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + f.debug_struct("ResolvedDependencyPackage") + .field("path", &self.path) + .finish_non_exhaustive() + } +} + +/// Finds and reads the `.masp` package artifact for the dependency named `name` rooted at `root`. +/// +/// Every candidate located by searching is deserialized and accepted only when its package id +/// matches the dependency's name, so a renamed or unrelated artifact is never adopted. A `root` +/// that is itself a `.masp` file is the manifest's explicit choice and is read without the id +/// check (the manifest key need not equal the prebuilt package's id). +pub(crate) fn resolve_dependency_package( + name: &str, + root: &Path, +) -> Result { if root.is_file() { - return Ok(root.to_path_buf()); + return Ok(ResolvedDependencyPackage { + path: root.to_path_buf(), + package: read_package(root)?, + }); } let preferred_profile = env::var("PROFILE").unwrap_or_else(|_| "debug".to_string()); @@ -137,8 +169,19 @@ pub(crate) fn resolve_dependency_package_path(name: &str, root: &Path) -> Result // nowhere else; searching the conventional output directories would find stale artifacts. if let Some(filesystem_cache_dir) = env::var_os("MIDENC_PACKAGE_CACHE") { let filesystem_cache_dir = PathBuf::from(filesystem_cache_dir); - if let Some(package) = find_stem_match_in_dir(&filesystem_cache_dir, &package_stems)? { - return Ok(package); + let candidates = + sort_by_freshness(stem_matches_in_dir(&filesystem_cache_dir, &package_stems)?); + + let mut id_mismatches = Vec::new(); + for candidate in candidates { + let package = read_package(&candidate)?; + if package_id_matches(&package, &package_stems) { + return Ok(ResolvedDependencyPackage { + path: candidate, + package, + }); + } + id_mismatches.push((candidate, package.name.to_string())); } return Err(Error::new( @@ -148,41 +191,65 @@ pub(crate) fn resolve_dependency_package_path(name: &str, root: &Path) -> Result root, &package_stems, &filesystem_cache_dir, + &id_mismatches, ), )); } let output_dirs = dependency_output_dirs(root, &profiles); - // Prefer the freshest name-matched package among the dependency's own output directories: - // `PROFILE` is never set for proc macros, so profile order alone would let a stale debug - // package shadow a fresh release build. - let own_matches = output_dirs - .own - .iter() - .filter_map(|dir| find_stem_match_in_dir(dir, &package_stems).transpose()) - .collect::, _>>()?; - if let Some(package) = latest_modified(own_matches) { - return Ok(package); + // Candidates in preference order. Name matches are ordered freshest-first within each + // directory class: `PROFILE` is never set for proc macros, so profile order alone would let + // a stale debug package shadow a fresh release build. + let mut candidates = Vec::new(); + + let mut own_matches = Vec::new(); + for dir in output_dirs.private.iter().chain(output_dirs.shared.iter()) { + own_matches.extend(stem_matches_in_dir(dir, &package_stems)?); } + candidates.extend(sort_by_freshness(own_matches)); - // A solitary `.masp` is accepted only in the dependency's own directories, where it cannot - // belong to anything else; an ambient directory may hold an unrelated package. - for dir in &output_dirs.own { - if let Some(package) = find_solitary_package_in_dir(dir)? { - return Ok(package); - } + // A solitary `.masp` is considered only in the dependency's private `/target` + // directories: a shared workspace or ambient target directory may hold a package of an + // unrelated project. + for dir in &output_dirs.private { + candidates.extend(find_solitary_package_in_dir(dir)?); } + let mut ambient_matches = Vec::new(); for dir in &output_dirs.ambient { - if let Some(package) = find_stem_match_in_dir(dir, &package_stems)? { - return Ok(package); + ambient_matches.extend(stem_matches_in_dir(dir, &package_stems)?); + } + candidates.extend(sort_by_freshness(ambient_matches)); + + let mut id_mismatches = Vec::new(); + let mut seen = Vec::new(); + for candidate in candidates { + if seen.contains(&candidate) { + continue; + } + seen.push(candidate.clone()); + + let package = read_package(&candidate)?; + if package_id_matches(&package, &package_stems) { + return Ok(ResolvedDependencyPackage { + path: candidate, + package, + }); } + id_mismatches.push((candidate, package.name.to_string())); } Err(Error::new( Span::call_site(), - missing_dependency_package_message(name, root, &package_stems, &output_dirs, &profiles), + missing_dependency_package_message( + name, + root, + &package_stems, + &output_dirs, + &profiles, + &id_mismatches, + ), )) } @@ -192,18 +259,29 @@ fn missing_cached_dependency_package_message( root: &Path, package_stems: &[String], filesystem_cache_dir: &Path, + id_mismatches: &[(PathBuf, String)], ) -> String { let expected_files = package_stems .iter() .map(|stem| format!("'{stem}.masp'")) .collect::>() .join(", "); + let rejected = if id_mismatches.is_empty() { + String::new() + } else { + let rejected = id_mismatches + .iter() + .map(|(path, id)| format!("'{}' (package id '{id}')", path.display())) + .collect::>() + .join(", "); + format!(" Rejected candidates whose package id does not match: {rejected}.") + }; format!( "could not find a built `.masp` package for Miden dependency '{name}' (root '{}'). The \ SDK macros need the dependency package during Rust macro expansion to read its embedded \ WIT and procedure roots. Expected one of these package names: {expected_files}. Searched \ - MIDENC_PACKAGE_CACHE directory '{}'. This cache is populated by the enclosing \ + MIDENC_PACKAGE_CACHE directory '{}'.{rejected} This cache is populated by the enclosing \ midenc-driven build; compile this crate as part of that build so its dependency packages \ are available during macro expansion.", root.display(), @@ -211,15 +289,29 @@ fn missing_cached_dependency_package_message( ) } -/// Returns the most recently modified of the given package paths. +/// Returns true when the package's id matches one of the dependency's name stems. +/// +/// Hyphens and underscores are interchangeable between manifest keys, Cargo package names, and +/// Miden package ids, so the comparison normalizes them. +fn package_id_matches(package: &Package, package_stems: &[String]) -> bool { + let package_id = package.name.to_string().replace('-', "_"); + package_stems.iter().any(|stem| stem.replace('-', "_") == package_id) +} + +/// Sorts package paths by modification time, freshest first. /// -/// Ties (including unreadable timestamps) resolve to the earliest candidate, i.e. the most -/// precise search directory. -fn latest_modified(packages: Vec) -> Option { - packages +/// Ties (including unreadable timestamps, which sort last) preserve the input order, i.e. the +/// most precise search directory. +fn sort_by_freshness(packages: Vec) -> Vec { + let mut packages_with_mtime = packages .into_iter() - .rev() - .max_by_key(|path| fs::metadata(path).and_then(|metadata| metadata.modified()).ok()) + .map(|path| { + let mtime = fs::metadata(&path).and_then(|metadata| metadata.modified()).ok(); + (path, mtime) + }) + .collect::>(); + packages_with_mtime.sort_by_key(|(_, mtime)| core::cmp::Reverse(*mtime)); + packages_with_mtime.into_iter().map(|(path, _)| path).collect() } /// Formats the diagnostic emitted when a dependency's compiled package cannot be located. @@ -229,10 +321,12 @@ fn missing_dependency_package_message( package_stems: &[String], output_dirs: &DependencyOutputDirs, profiles: &[String], + id_mismatches: &[(PathBuf, String)], ) -> String { let searched = output_dirs - .own + .private .iter() + .chain(output_dirs.shared.iter()) .chain(output_dirs.ambient.iter()) .map(|dir| format!("'{}'", dir.display())) .collect::>() @@ -242,13 +336,23 @@ fn missing_dependency_package_message( .flat_map(|stem| profiles.iter().map(move |profile| format!("{stem}.masp in {profile}"))) .collect::>() .join(", "); + let rejected = if id_mismatches.is_empty() { + String::new() + } else { + let rejected = id_mismatches + .iter() + .map(|(path, id)| format!("'{}' (package id '{id}')", path.display())) + .collect::>() + .join(", "); + format!(" Rejected candidates whose package id does not match: {rejected}.") + }; let build_hint = dependency_build_hint(root); format!( "could not find a built `.masp` package for Miden dependency '{name}' (root '{}'). The \ SDK macros need the dependency package during Rust macro expansion to read its embedded \ - WIT and procedure roots. Expected one of: {expected_files}. Searched: {searched}. \ - {build_hint}", + WIT and procedure roots. Expected one of: {expected_files}. Searched: \ + {searched}.{rejected} {build_hint}", root.display(), ) } @@ -275,22 +379,27 @@ fn dependency_build_hint(root: &Path) -> String { /// Candidate output directories where a dependency `.masp` may have been written. struct DependencyOutputDirs { - /// Directories derived from the dependency's own root; a package found here belongs to it. - own: Vec, - /// Ambient directories (`CARGO_TARGET_DIR`, `OUT_DIR`, cwd targets) that may also hold - /// packages of unrelated projects. + /// The dependency's own `/target` directories; a package found here belongs to it. + private: Vec, + /// Target directories of the dependency root's ancestors — a surrounding workspace's target + /// holds packages of all its members, so a package here needs a name/id match. + shared: Vec, + /// Ambient directories (`CARGO_TARGET_DIR`, `OUT_DIR`, cwd targets) that may hold packages + /// of entirely unrelated projects. ambient: Vec, } /// Returns candidate output directories where a dependency `.masp` may have been written. fn dependency_output_dirs(root: &Path, profiles: &[String]) -> DependencyOutputDirs { // The dependency root is the most precise location for path dependencies. Prefer it over - // ambient target directories so restored or previously built artifacts cannot shadow the - // package that belongs to the dependency being wrapped. - let mut own = Vec::new(); - push_profile_dirs(&mut own, root.join("target"), profiles); - push_manifest_ancestor_target_profile_dirs(&mut own, root, profiles); - push_ancestor_target_profile_dirs(&mut own, root, profiles); + // shared and ambient target directories so restored or previously built artifacts cannot + // shadow the package that belongs to the dependency being wrapped. + let mut private = Vec::new(); + push_profile_dirs(&mut private, root.join("target"), profiles); + + let mut shared = Vec::new(); + push_manifest_ancestor_target_profile_dirs(&mut shared, root, profiles); + push_ancestor_target_profile_dirs(&mut shared, root, profiles); let mut ambient = Vec::new(); if let Ok(target_dir) = env::var("CARGO_TARGET_DIR") { @@ -309,7 +418,11 @@ fn dependency_output_dirs(root: &Path, profiles: &[String]) -> DependencyOutputD push_ancestor_target_profile_dirs(&mut ambient, ¤t_dir, profiles); } - DependencyOutputDirs { own, ambient } + DependencyOutputDirs { + private, + shared, + ambient, + } } /// Adds `target/miden/` directories while preserving insertion order. @@ -375,19 +488,15 @@ fn packages_in_dir(dir: &Path) -> Result, Error> { Ok(packages) } -/// Finds a package in `dir` whose filename matches one of the dependency's name stems. -fn find_stem_match_in_dir(dir: &Path, package_stems: &[String]) -> Result, Error> { - let packages = packages_in_dir(dir)?; - for stem in package_stems { - if let Some(package) = packages.iter().find(|path| { - path.file_stem() - .and_then(|value| value.to_str()) - .is_some_and(|file_stem| file_stem == stem) - }) { - return Ok(Some(package.clone())); - } - } - Ok(None) +/// Returns all packages in `dir` whose filename matches one of the dependency's name stems. +fn stem_matches_in_dir(dir: &Path, package_stems: &[String]) -> Result, Error> { + let mut packages = packages_in_dir(dir)?; + packages.retain(|path| { + path.file_stem() + .and_then(|value| value.to_str()) + .is_some_and(|file_stem| package_stems.iter().any(|stem| stem == file_stem)) + }); + Ok(packages) } /// Returns the package in `dir` when it is the directory's only one, regardless of its name. @@ -454,6 +563,7 @@ mod tests { Path::new("/projects/counter"), &stems, cache_dir, + &[], ); assert!(message.contains("'counter.masp'")); @@ -499,64 +609,140 @@ mod tests { std::fs::remove_dir_all(temp_root).unwrap(); } - #[test] - fn prefers_the_freshest_stem_match_across_profile_dirs() { - let temp_root = - env::temp_dir().join(format!("midenc-dep-package-freshest-{}", std::process::id())); - let debug_dir = temp_root.join("target/miden/debug"); - let release_dir = temp_root.join("target/miden/release"); - std::fs::create_dir_all(&debug_dir).unwrap(); - std::fs::create_dir_all(&release_dir).unwrap(); - let debug_path = debug_dir.join("dep_fixture.masp"); - let release_path = release_dir.join("dep_fixture.masp"); - std::fs::write(&debug_path, b"stale").unwrap(); - std::fs::write(&release_path, b"fresh").unwrap(); - // `PROFILE` is unset for proc macros, so the debug dir is searched first; only the - // freshest-match rule makes the newer release artifact win. + use crate::test_support::write_masp_fixture; + + /// Creates a unique fixture root under the temp dir. + fn fixture_root(name: &str) -> PathBuf { + let root = + env::temp_dir().join(format!("midenc-dep-package-{name}-{}", std::process::id())); + std::fs::create_dir_all(&root).unwrap(); + root + } + + /// Backdates a package file so a sibling artifact is strictly fresher. + fn backdate(path: &Path) { let stale = std::time::SystemTime::now() - std::time::Duration::from_secs(600); std::fs::File::options() .write(true) - .open(&debug_path) + .open(path) .unwrap() .set_modified(stale) .unwrap(); + } + + #[test] + fn prefers_the_freshest_stem_match_across_profile_dirs() { + let temp_root = fixture_root("freshest"); + let debug_path = temp_root.join("target/miden/debug/dep_fixture.masp"); + let release_path = temp_root.join("target/miden/release/dep_fixture.masp"); + write_masp_fixture(&debug_path, "dep-fixture", None); + write_masp_fixture(&release_path, "dep-fixture", None); + // `PROFILE` is unset for proc macros, so the debug dir is searched first; only the + // freshest-match rule makes the newer release artifact win. + backdate(&debug_path); - let resolved = resolve_dependency_package_path("dep-fixture", &temp_root).unwrap(); + let resolved = resolve_dependency_package("dep-fixture", &temp_root).unwrap(); - assert_eq!(resolved, release_path); + assert_eq!(resolved.path, release_path); std::fs::remove_dir_all(temp_root).unwrap(); } #[test] - fn accepts_a_solitary_package_in_the_dependency_own_dirs() { - let temp_root = - env::temp_dir().join(format!("midenc-dep-package-solitary-{}", std::process::id())); - let debug_dir = temp_root.join("target/miden/debug"); - std::fs::create_dir_all(&debug_dir).unwrap(); - let package_path = debug_dir.join("oddly_named.masp"); - std::fs::write(&package_path, b"package bytes").unwrap(); + fn prefers_the_freshest_match_across_stem_aliases_in_one_dir() { + // The Cargo-name (underscore) and package-id (hyphen) naming schemes have both been used + // for `.masp` artifacts; a stale artifact under one alias must not shadow a fresh one + // under the other. + let temp_root = fixture_root("stem-aliases"); + let stale_path = temp_root.join("target/miden/debug/dep_fixture.masp"); + let fresh_path = temp_root.join("target/miden/debug/dep-fixture.masp"); + write_masp_fixture(&stale_path, "dep-fixture", None); + write_masp_fixture(&fresh_path, "dep-fixture", None); + backdate(&stale_path); - let resolved = resolve_dependency_package_path("dep-fixture", &temp_root).unwrap(); + let resolved = resolve_dependency_package("dep-fixture", &temp_root).unwrap(); - assert_eq!(resolved, package_path); + assert_eq!(resolved.path, fresh_path); + + std::fs::remove_dir_all(temp_root).unwrap(); + } + + #[test] + fn accepts_a_solitary_package_in_the_dependency_private_dirs() { + let temp_root = fixture_root("solitary"); + let package_path = temp_root.join("target/miden/debug/oddly_named.masp"); + write_masp_fixture(&package_path, "dep-fixture", None); + + let resolved = resolve_dependency_package("dep-fixture", &temp_root).unwrap(); + + assert_eq!(resolved.path, package_path); + + std::fs::remove_dir_all(temp_root).unwrap(); + } + + #[test] + fn rejects_a_solitary_package_with_a_mismatched_id() { + let temp_root = fixture_root("solitary-mismatch"); + let package_path = temp_root.join("target/miden/debug/oddly_named.masp"); + write_masp_fixture(&package_path, "other-package", None); + + let error = resolve_dependency_package("dep-fixture", &temp_root) + .expect_err("a solitary package with a foreign id must not be adopted"); + let message = error.to_string(); + + assert!( + message.contains("could not find a built `.masp` package"), + "unexpected error: {message}" + ); + assert!(message.contains("package id 'other-package'"), "unexpected error: {message}"); + + std::fs::remove_dir_all(temp_root).unwrap(); + } + + #[test] + fn does_not_adopt_a_solitary_package_from_a_shared_workspace_target() { + // The workspace-level target dir holds packages of all members; a solitary unrelated + // package there must not be adopted for a member that was never built. + let temp_root = fixture_root("workspace-solitary"); + let workspace_root = temp_root.join("workspace"); + let dependency_root = workspace_root.join("dep"); + std::fs::create_dir_all(&dependency_root).unwrap(); + std::fs::write(workspace_root.join("Cargo.lock"), "").unwrap(); + write_masp_fixture( + &workspace_root.join("target/miden/debug/unrelated.masp"), + "unrelated", + None, + ); + + let error = resolve_dependency_package("dep-fixture", &dependency_root) + .expect_err("an unrelated solitary package in the workspace target must be ignored"); + let message = error.to_string(); + + assert!( + message.contains("could not find a built `.masp` package"), + "unexpected error: {message}" + ); std::fs::remove_dir_all(temp_root).unwrap(); } #[test] fn missing_dependency_package_message_explains_macro_time_requirement() { - let temp_root = - env::temp_dir().join(format!("midenc-fpi-missing-package-{}", std::process::id())); - std::fs::create_dir_all(&temp_root).unwrap(); + let temp_root = fixture_root("missing-message"); std::fs::write(temp_root.join("Cargo.toml"), "[package]\nname = \"counter\"\n").unwrap(); let profiles = vec!["release".to_string(), "debug".to_string()]; let stems = vec!["counter".to_string(), "counter_component".to_string()]; let output_dirs = DependencyOutputDirs { - own: vec![temp_root.join("target/miden/release"), temp_root.join("target/miden/debug")], + private: vec![ + temp_root.join("target/miden/release"), + temp_root.join("target/miden/debug"), + ], + shared: Vec::new(), ambient: Vec::new(), }; + let id_mismatches = + vec![(temp_root.join("target/miden/debug/stray.masp"), "stray".to_string())]; let message = missing_dependency_package_message( "counter", @@ -564,6 +750,7 @@ mod tests { &stems, &output_dirs, &profiles, + &id_mismatches, ); assert!(message.contains("could not find a built `.masp` package")); @@ -571,6 +758,7 @@ mod tests { assert!(message.contains("embedded WIT and procedure roots")); assert!(message.contains("counter.masp in release")); assert!(message.contains("counter_component.masp in debug")); + assert!(message.contains("package id 'stray'")); assert!(message.contains("cargo miden build --manifest-path")); assert!(message.contains(&temp_root.display().to_string())); diff --git a/sdk/base-macros/src/fpi.rs b/sdk/base-macros/src/fpi.rs index 55e190dd2..c8b503981 100644 --- a/sdk/base-macros/src/fpi.rs +++ b/sdk/base-macros/src/fpi.rs @@ -3,13 +3,12 @@ use std::{ collections::{HashMap, HashSet}, fmt::Write as _, - fs, path::PathBuf, }; use heck::{ToKebabCase, ToSnakeCase}; use miden_assembly_syntax::ast::{Path as MasmPath, PathComponent}; -use miden_mast_package::{Package, PackageExport}; +use miden_mast_package::PackageExport; use miden_protocol::crypto::hash::blake::Blake3_256; use proc_macro2::{Span, TokenStream as TokenStream2}; use quote::{ToTokens, quote}; @@ -1501,18 +1500,7 @@ fn load_dependency( let import = dependency.import().to_owned(); let module_path = import_module_path(&import); let package_path = dependency.package_path.clone(); - let package_bytes = fs::read(&package_path).map_err(|err| { - Error::new( - Span::call_site(), - format!("failed to read dependency package '{}': {err}", package_path.display()), - ) - })?; - let package = Package::read_from_bytes_unchecked(&package_bytes).map_err(|err| { - Error::new( - Span::call_site(), - format!("failed to deserialize dependency package '{}': {err}", package_path.display()), - ) - })?; + let package = crate::dependency_package::read_package(&package_path)?; let mut roots = HashMap::new(); for export in package.manifest.exports() { diff --git a/sdk/base-macros/src/generate.rs b/sdk/base-macros/src/generate.rs index 067ee3de2..27f586e32 100644 --- a/sdk/base-macros/src/generate.rs +++ b/sdk/base-macros/src/generate.rs @@ -501,7 +501,8 @@ struct LoadedWitSources { /// The resolved WIT definitions containing all types, interfaces, and worlds. resolve: Resolve, /// Package IDs to use for world selection. When inline source is provided, this contains - /// only the inline package; otherwise it contains all packages from file paths. + /// only the inline package; otherwise it contains the packages of every loaded source (the + /// SDK prelude paths, the dependency packages' embedded WIT, and the local `wit/` directory). packages: Vec, /// File paths that were read during WIT parsing. Used to generate dummy `include_bytes!` /// calls so rustc knows to recompile when these files change. @@ -1736,6 +1737,9 @@ world api-world { let tokens = local_wit_link_section(&GenerateArgs::default(), &config).unwrap(); assert!(!tokens.is_empty(), "self-contained local WIT must be embedded"); + + fs::remove_dir_all(config.local_wit_root.expect("fixture has a local wit root")) + .expect("temporary fixture directory must be removed"); } #[test] @@ -1756,5 +1760,8 @@ world importer { let tokens = local_wit_link_section(&GenerateArgs::default(), &config).unwrap(); assert!(tokens.is_empty(), "non-self-contained local WIT must not be embedded"); + + fs::remove_dir_all(config.local_wit_root.expect("fixture has a local wit root")) + .expect("temporary fixture directory must be removed"); } } diff --git a/sdk/base-macros/src/lib.rs b/sdk/base-macros/src/lib.rs index 285c7842e..1a3577b31 100644 --- a/sdk/base-macros/src/lib.rs +++ b/sdk/base-macros/src/lib.rs @@ -76,6 +76,8 @@ mod generate; mod manifest_paths; mod note; mod script; +#[cfg(test)] +mod test_support; mod types; mod util; mod wit_builder; diff --git a/sdk/base-macros/src/manifest_paths.rs b/sdk/base-macros/src/manifest_paths.rs index dcb3a781c..cf8d5445f 100644 --- a/sdk/base-macros/src/manifest_paths.rs +++ b/sdk/base-macros/src/manifest_paths.rs @@ -30,6 +30,8 @@ pub(crate) struct ResolvedWit { /// The crate's local `wit/` directory, loaded after the dependency sources so its WIT can /// import the dependency packages. pub local_wit_root: Option, + /// The `package/world` id detected in the local WIT directory, used for wit-bindgen world + /// selection. pub world: Option, /// The world-defining local WIT file, present when it is the crate's only WIT file and can /// therefore be embedded verbatim as the component's public WIT. diff --git a/sdk/base-macros/src/test_support.rs b/sdk/base-macros/src/test_support.rs new file mode 100644 index 000000000..3813c1274 --- /dev/null +++ b/sdk/base-macros/src/test_support.rs @@ -0,0 +1,33 @@ +//! Shared fixtures for base-macros unit tests. + +use std::{fs, path::Path, sync::Arc}; + +use miden_assembly::{Assembler, DefaultSourceManager, ModuleParser, ast::ModuleKind}; +use miden_protocol::utils::serde::Serializable; + +/// Writes a minimal `.masp` package fixture with the given package id, optionally embedding +/// `wit` in the WIT section. The fixture version is `0.1.0`. +pub(crate) fn write_masp_fixture(package_path: &Path, package_id: &str, wit: Option<&str>) { + let source_manager = Arc::new(DefaultSourceManager::default()); + let module = ModuleParser::new(Some(ModuleKind::Library)) + .parse_str( + Some(miden_assembly::Path::new("dep")), + "pub proc callee(a: felt) -> felt\n add.1\nend", + source_manager.clone(), + ) + .expect("fixture module must parse"); + let mut package = Assembler::new(source_manager) + .assemble_library(package_id, module, None::>) + .expect("fixture library must assemble"); + package.version = "0.1.0".parse().expect("fixture version must parse"); + if let Some(wit) = wit { + package.sections.push(miden_mast_package::Section::new( + crate::dependency_package::wit_section_id(), + wit.as_bytes().to_vec(), + )); + } + + fs::create_dir_all(package_path.parent().expect("package path must have a parent")) + .expect("package directory must be created"); + fs::write(package_path, package.to_bytes()).expect("package fixture must be written"); +} diff --git a/sdk/base-macros/src/util.rs b/sdk/base-macros/src/util.rs index d26977662..b5965c396 100644 --- a/sdk/base-macros/src/util.rs +++ b/sdk/base-macros/src/util.rs @@ -97,7 +97,8 @@ pub(crate) fn generate_wit_link_section(wit_source: &str) -> TokenStream2 { /// /// The linker concatenates identically named custom sections byte-wise; without the padding a /// blob missing a trailing newline would glue the next blob's `package ...;` declaration onto its -/// last line, hiding the concatenation from the frontend's duplicate-implementation detector. +/// last line, hiding the concatenation from the frontend's duplicate-implementation detector +/// (`count_top_level_wit_packages` in `midenc-frontend-wasm`), which scans line-wise. fn normalize_embedded_wit(wit_source: &str) -> String { let mut normalized = String::with_capacity(wit_source.len() + 2 - usize::from(wit_source.starts_with('\n'))); diff --git a/sdk/base-macros/src/wit_world.rs b/sdk/base-macros/src/wit_world.rs index 79d6bee86..2ff991b6e 100644 --- a/sdk/base-macros/src/wit_world.rs +++ b/sdk/base-macros/src/wit_world.rs @@ -571,33 +571,9 @@ world basic-wallet-world { format!("{pid}-{nanos}-{count}") } - /// Writes a minimal `.masp` package fixture, optionally embedding `wit` in the WIT section. + /// Writes a minimal `.masp` package fixture named after the fixture dependency. fn write_masp_fixture(package_path: &Path, wit: Option<&str>) { - use miden_assembly::{Assembler, DefaultSourceManager, ModuleParser, ast::ModuleKind}; - - let source_manager = Arc::new(DefaultSourceManager::default()); - let module = ModuleParser::new(Some(ModuleKind::Library)) - .parse_str( - Some(miden_assembly::Path::new("dep")), - "pub proc callee(a: felt) -> felt\n add.1\nend", - source_manager.clone(), - ) - .expect("fixture module must parse"); - let mut package = Assembler::new(source_manager) - .assemble_library("wit-world-fixture", module, None::>) - .expect("fixture library must assemble"); - package.version = "0.1.0".parse().expect("fixture version must parse"); - if let Some(wit) = wit { - package.sections.push(miden_mast_package::Section::new( - crate::dependency_package::wit_section_id(), - wit.as_bytes().to_vec(), - )); - } - - use miden_protocol::utils::serde::Serializable; - fs::create_dir_all(package_path.parent().expect("package path must have a parent")) - .expect("package directory must be created"); - fs::write(package_path, package.to_bytes()).expect("package fixture must be written"); + crate::test_support::write_masp_fixture(package_path, "wit-world-fixture-dep", wit); } /// Creates a dependency project root with a compiled package under `target/miden/debug`. diff --git a/sdk/sdk/MIGRATION.md b/sdk/sdk/MIGRATION.md index 927291c48..0bcbadd58 100644 --- a/sdk/sdk/MIGRATION.md +++ b/sdk/sdk/MIGRATION.md @@ -282,6 +282,11 @@ files, or referencing packages under `wit/deps/` other than the bundled SDK WIT, embedded verbatim yet; consolidate it into one self-contained file if the component is consumed as a Miden dependency. +Note for the editor workflow: previously, `cargo check` (or rust-analyzer) of the dependency crate +regenerated its WIT under `target/generated-wit` as a macro side effect. Dependency WIT now comes +from the compiled package, so run `cargo miden build` for the dependency once (and again after +changing its interface) before checking a dependent crate. + ## 0.13.0 -> 0.13.1 ### `*_note::get_metadata` returns a single-word `NoteMetadata` diff --git a/sdk/wasm-metadata/src/lib.rs b/sdk/wasm-metadata/src/lib.rs index e77660850..0e05c972f 100644 --- a/sdk/wasm-metadata/src/lib.rs +++ b/sdk/wasm-metadata/src/lib.rs @@ -1,4 +1,6 @@ -//! Shared encoding for frontend-only Wasm metadata emitted by SDK macros. +//! Shared definitions for the out-of-band metadata exchanged between the Miden SDK macros and +//! the compiler: Wasm custom-section names and encodings, and the package-section payloads +//! carried through the compiler pipeline into the compiled Miden package (`.masp`). #![deny(warnings)] #![deny(missing_docs)] From 1b968cd7a5d4bd3b00703e5dee486315fcfb4fd7 Mon Sep 17 00:00:00 2001 From: Denys Zadorozhnyi Date: Mon, 6 Jul 2026 09:30:00 +0300 Subject: [PATCH 06/12] fix: count one-line WIT package declarations correctly MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The concatenation detector disqualified any line containing a `{`, so a valid whitespace-insensitive declaration like `package miden:x@1.0.0; interface api { ... }` counted as zero packages. Such WIT passes the producer's real-parser self-containment check and gets embedded, after which the exactly-one validation failed the whole build with a misleading "does not contain a top-level WIT package declaration" — and two concatenated one-line-style packages produced the same wrong message instead of the dedicated duplicate-implementation diagnostic. Reject a `{` only when it appears before the first `;`, which still excludes nested `package { ... }` declarations. Also wrap the cross-module duplicate-section error in the typed `WasmError::Unsupported` used by the neighboring frontend-metadata merge instead of a bare report. --- frontend/wasm/src/module/module_env.rs | 17 +++++++++++------ frontend/wasm/src/module/module_env/tests.rs | 18 ++++++++++++++++++ 2 files changed, 29 insertions(+), 6 deletions(-) diff --git a/frontend/wasm/src/module/module_env.rs b/frontend/wasm/src/module/module_env.rs index 87d829fa2..87fdc75dc 100644 --- a/frontend/wasm/src/module/module_env.rs +++ b/frontend/wasm/src/module/module_env.rs @@ -136,14 +136,19 @@ fn validate_component_wit_section( /// Counts top-level `package ;` declarations in WIT source. /// /// Comments — including nested `/* */` block comments — are stripped first so commented-out -/// declarations are not counted, and nested package declarations (`package { ... }`) are -/// excluded. A well-formed embedded WIT source contains exactly one top-level declaration, so a -/// higher count indicates concatenated sections from multiple component implementations. +/// declarations are not counted, and nested package declarations (`package { ... }`, whose +/// `{` precedes any `;`) are excluded. WIT is whitespace-insensitive, so other items may follow +/// the declaration on the same line. A well-formed embedded WIT source contains exactly one +/// top-level declaration; a higher count indicates concatenated sections from multiple component +/// implementations. fn count_top_level_wit_packages(wit: &str) -> usize { strip_wit_comments(wit) .lines() .map(str::trim) - .filter(|line| line.starts_with("package ") && line.contains(';') && !line.contains('{')) + .filter(|line| { + line.starts_with("package ") + && line.find(';').is_some_and(|semi| !line[..semi].contains('{')) + }) .count() } @@ -213,10 +218,10 @@ fn merge_section_payload<'data>( if let Some(payload) = payload && merged.replace(payload).is_some() { - return Err(Report::msg(format!( + return Err(Report::from(WasmError::Unsupported(format!( "found multiple '{section_name}' custom sections across the component's core modules; \ only one is allowed per component" - ))); + )))); } Ok(()) } diff --git a/frontend/wasm/src/module/module_env/tests.rs b/frontend/wasm/src/module/module_env/tests.rs index 57aed99d9..fcf07689d 100644 --- a/frontend/wasm/src/module/module_env/tests.rs +++ b/frontend/wasm/src/module/module_env/tests.rs @@ -52,6 +52,24 @@ package miden:nested@0.1.0 { assert_eq!(count_top_level_wit_packages(wit), 1); } +/// Ensures a one-line package declaration followed by other items on the same line is counted. +/// +/// WIT is whitespace-insensitive; the `{` of a following item must not disqualify the +/// declaration, while a nested `package { ... }` (whose `{` precedes any `;`) still must. +#[test] +fn component_wit_counts_one_line_package_declarations() { + let wit = + "package miden:x@1.0.0; interface api { get: func() -> u64; }\n\nworld w { export api; }\n"; + assert_eq!(count_top_level_wit_packages(wit), 1); + + let nested_one_liner = "package miden:nested@0.1.0 { interface api { get: func() -> u64; } }\n"; + assert_eq!(count_top_level_wit_packages(nested_one_liner), 0); + + let concatenated = "package miden:first@0.1.0; world first-world { }\npackage \ + miden:second@0.1.0; world second-world { }\n"; + assert_eq!(count_top_level_wit_packages(concatenated), 2); +} + /// Ensures declarations inside (nested) block comments are not counted as top-level packages. #[test] fn component_wit_ignores_block_commented_packages() { From 7b35c2650b8ce248b406d5c22ed6152f5ba5532c Mon Sep 17 00:00:00 2001 From: Denys Zadorozhnyi Date: Mon, 6 Jul 2026 09:35:42 +0300 Subject: [PATCH 07/12] fix: verify version pins and reuse the resolved dependency package MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dependency package resolution verified the package id but ignored a `Path { version: ... }` manifest pin, so a right-name/wrong-version artifact was adopted silently, and a format-skewed `.masp` (the likely failure after a toolchain upgrade) surfaced a bare deserialization error with no action attached. The package deserialized for the id check was also dropped and re-read from disk by the FPI flow, wasting a full MAST-forest decode and extracting procedure roots from a different read than the one that was identity-checked. Check semantic and exact version pins against the candidate's version during resolution — digest pins stay with the assembler, which enforces them at link time — and fold both id and version rejections into the not-found diagnostic. Append the rebuild-with-current-toolchain guidance to deserialization failures. Carry the resolved package (now an `Arc`) through `DependencyWitSource`/`SelectedDependency` so FPI extracts procedure roots from the verified read instead of re-reading the file. Also drop the dependency's own target directories from the shared ancestor list (the ancestor walks start at the root, re-discovering them, so each was scanned and reported twice), split the package-fixture builder so tests can construct in-memory packages, and retire the stale `midenc-fpi-` fixture prefix left over from the code's previous home. --- .../src/component_macro/sibling.rs | 1 + sdk/base-macros/src/dependency_package.rs | 162 ++++++++++++++---- sdk/base-macros/src/fpi.rs | 7 +- sdk/base-macros/src/test_support.rs | 13 +- sdk/base-macros/src/wit_world.rs | 8 + 5 files changed, 153 insertions(+), 38 deletions(-) diff --git a/sdk/base-macros/src/component_macro/sibling.rs b/sdk/base-macros/src/component_macro/sibling.rs index 9e9fb2add..ee5695020 100644 --- a/sdk/base-macros/src/component_macro/sibling.rs +++ b/sdk/base-macros/src/component_macro/sibling.rs @@ -279,6 +279,7 @@ mod tests { package_path: std::path::PathBuf::from( "/tmp/pausable/target/miden/debug/pausable.masp", ), + package: crate::test_support::build_package("pausable", None), interface: crate::wit_world::DependencyInterface { name: "pausable".to_string(), import: "miden:pausable/pausable@0.1.0".to_string(), diff --git a/sdk/base-macros/src/dependency_package.rs b/sdk/base-macros/src/dependency_package.rs index d684ace3d..d1271a44f 100644 --- a/sdk/base-macros/src/dependency_package.rs +++ b/sdk/base-macros/src/dependency_package.rs @@ -6,6 +6,7 @@ use std::{ env, fs, path::{Path, PathBuf}, + sync::Arc, }; use miden_mast_package::{Package, SectionId}; @@ -21,6 +22,9 @@ pub(crate) struct DependencyWitSource { pub(crate) root: PathBuf, /// Path of the compiled `.masp` package the WIT was read from. pub(crate) package_path: PathBuf, + /// The deserialized package, shared so later consumers (FPI procedure-root extraction) reuse + /// the exact read the package id was verified against. + pub(crate) package: Arc, /// The component WIT source embedded in the package. pub(crate) wit: String, } @@ -35,7 +39,7 @@ pub(crate) fn collect_dependency_wit_sources( for dependency in package.dependencies() { match dependency.scheme() { - miden_project::DependencyVersionScheme::Path { path, .. } => { + miden_project::DependencyVersionScheme::Path { path, version } => { let absolute_path = manifest_dir.join(path.path()); let dependency_root = fs::canonicalize(&absolute_path).map_err(|err| { Error::new( @@ -47,13 +51,17 @@ pub(crate) fn collect_dependency_wit_sources( ), ) })?; - let resolved = - resolve_dependency_package(dependency.name().as_ref(), &dependency_root)?; + let resolved = resolve_dependency_package( + dependency.name().as_ref(), + &dependency_root, + version.as_ref(), + )?; let wit = package_wit(&resolved.package, &resolved.path)?; sources.push(DependencyWitSource { name: dependency.name().to_string(), root: dependency_root, package_path: resolved.path, + package: resolved.package, wit, }); } @@ -74,7 +82,7 @@ pub(crate) fn wit_section_id() -> SectionId { } /// Reads and deserializes a compiled Miden package. -pub(crate) fn read_package(package_path: &Path) -> Result, Error> { +pub(crate) fn read_package(package_path: &Path) -> Result, Error> { let error_span = Span::call_site(); let package_bytes = fs::read(package_path).map_err(|err| { Error::new( @@ -82,10 +90,15 @@ pub(crate) fn read_package(package_path: &Path) -> Result, Error> { format!("failed to read dependency package '{}': {err}", package_path.display()), ) })?; - Package::read_from_bytes_unchecked(&package_bytes).map(Box::new).map_err(|err| { + Package::read_from_bytes_unchecked(&package_bytes).map(Arc::new).map_err(|err| { Error::new( error_span, - format!("failed to deserialize dependency package '{}': {err}", package_path.display()), + format!( + "failed to deserialize dependency package '{}': {err}. The package may have been \ + produced by a different Miden toolchain version; rebuild the dependency with the \ + current `cargo miden build`.", + package_path.display() + ), ) }) } @@ -126,9 +139,11 @@ pub(crate) struct ResolvedDependencyPackage { /// Path of the `.masp` file the package was read from. pub(crate) path: PathBuf, /// The deserialized package. - pub(crate) package: Box, + pub(crate) package: Arc, } +// Manual impl: required by `expect_err` in tests, without requiring `Package: Debug` (which +// would dump the whole MAST forest). impl core::fmt::Debug for ResolvedDependencyPackage { fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { f.debug_struct("ResolvedDependencyPackage") @@ -140,12 +155,14 @@ impl core::fmt::Debug for ResolvedDependencyPackage { /// Finds and reads the `.masp` package artifact for the dependency named `name` rooted at `root`. /// /// Every candidate located by searching is deserialized and accepted only when its package id -/// matches the dependency's name, so a renamed or unrelated artifact is never adopted. A `root` -/// that is itself a `.masp` file is the manifest's explicit choice and is read without the id -/// check (the manifest key need not equal the prebuilt package's id). +/// matches the dependency's name — and, when the manifest pins a version, when its version +/// satisfies the pin — so a renamed, unrelated, or outdated artifact is never adopted. A `root` +/// that is itself a `.masp` file is the manifest's explicit choice and is read without those +/// checks (the manifest key need not equal the prebuilt package's id). pub(crate) fn resolve_dependency_package( name: &str, root: &Path, + version: Option<&miden_project::VersionRequirement>, ) -> Result { if root.is_file() { return Ok(ResolvedDependencyPackage { @@ -222,7 +239,7 @@ pub(crate) fn resolve_dependency_package( } candidates.extend(sort_by_freshness(ambient_matches)); - let mut id_mismatches = Vec::new(); + let mut rejected = Vec::new(); let mut seen = Vec::new(); for candidate in candidates { if seen.contains(&candidate) { @@ -231,13 +248,21 @@ pub(crate) fn resolve_dependency_package( seen.push(candidate.clone()); let package = read_package(&candidate)?; - if package_id_matches(&package, &package_stems) { - return Ok(ResolvedDependencyPackage { - path: candidate, - package, - }); + if !package_id_matches(&package, &package_stems) { + rejected.push((candidate, format!("package id '{}'", package.name))); + continue; + } + if !package_version_matches(&package, version) { + rejected.push(( + candidate, + format!("version {} does not satisfy the manifest requirement", package.version), + )); + continue; } - id_mismatches.push((candidate, package.name.to_string())); + return Ok(ResolvedDependencyPackage { + path: candidate, + package, + }); } Err(Error::new( @@ -248,7 +273,7 @@ pub(crate) fn resolve_dependency_package( &package_stems, &output_dirs, &profiles, - &id_mismatches, + &rejected, ), )) } @@ -298,6 +323,23 @@ fn package_id_matches(package: &Package, package_stems: &[String]) -> bool { package_stems.iter().any(|stem| stem.replace('-', "_") == package_id) } +/// Returns true when the package's version satisfies the manifest's version pin, if any. +/// +/// Digest pins are enforced by the assembler at link time; checking them here would require +/// computing content digests during macro expansion, so they are accepted as-is. +fn package_version_matches( + package: &Package, + version: Option<&miden_project::VersionRequirement>, +) -> bool { + match version { + Some(miden_project::VersionRequirement::Semantic(requirement)) => { + requirement.inner().matches(&package.version) + } + Some(miden_project::VersionRequirement::Exact(exact)) => exact.version == package.version, + Some(miden_project::VersionRequirement::Digest(_)) | None => true, + } +} + /// Sorts package paths by modification time, freshest first. /// /// Ties (including unreadable timestamps, which sort last) preserve the input order, i.e. the @@ -321,7 +363,7 @@ fn missing_dependency_package_message( package_stems: &[String], output_dirs: &DependencyOutputDirs, profiles: &[String], - id_mismatches: &[(PathBuf, String)], + rejected: &[(PathBuf, String)], ) -> String { let searched = output_dirs .private @@ -336,15 +378,15 @@ fn missing_dependency_package_message( .flat_map(|stem| profiles.iter().map(move |profile| format!("{stem}.masp in {profile}"))) .collect::>() .join(", "); - let rejected = if id_mismatches.is_empty() { + let rejected = if rejected.is_empty() { String::new() } else { - let rejected = id_mismatches + let rejected = rejected .iter() - .map(|(path, id)| format!("'{}' (package id '{id}')", path.display())) + .map(|(path, reason)| format!("'{}' ({reason})", path.display())) .collect::>() .join(", "); - format!(" Rejected candidates whose package id does not match: {rejected}.") + format!(" Rejected candidates that do not match the dependency: {rejected}.") }; let build_hint = dependency_build_hint(root); @@ -400,6 +442,8 @@ fn dependency_output_dirs(root: &Path, profiles: &[String]) -> DependencyOutputD let mut shared = Vec::new(); push_manifest_ancestor_target_profile_dirs(&mut shared, root, profiles); push_ancestor_target_profile_dirs(&mut shared, root, profiles); + // The ancestor walks start at the root itself, re-discovering the private dirs. + shared.retain(|dir| !private.contains(dir)); let mut ambient = Vec::new(); if let Ok(target_dir) = env::var("CARGO_TARGET_DIR") { @@ -585,8 +629,7 @@ mod tests { #[test] fn dependency_output_dirs_include_manifest_ancestor_targets() { - let temp_root = env::temp_dir() - .join(format!("midenc-fpi-dependency-output-dirs-{}", std::process::id())); + let temp_root = fixture_root("output-dirs"); let workspace_root = temp_root.join("workspace"); let dependency_root = workspace_root.join("tests/fixtures/dependency"); std::fs::create_dir_all(&dependency_root).unwrap(); @@ -641,7 +684,7 @@ mod tests { // freshest-match rule makes the newer release artifact win. backdate(&debug_path); - let resolved = resolve_dependency_package("dep-fixture", &temp_root).unwrap(); + let resolved = resolve_dependency_package("dep-fixture", &temp_root, None).unwrap(); assert_eq!(resolved.path, release_path); @@ -660,7 +703,7 @@ mod tests { write_masp_fixture(&fresh_path, "dep-fixture", None); backdate(&stale_path); - let resolved = resolve_dependency_package("dep-fixture", &temp_root).unwrap(); + let resolved = resolve_dependency_package("dep-fixture", &temp_root, None).unwrap(); assert_eq!(resolved.path, fresh_path); @@ -673,7 +716,7 @@ mod tests { let package_path = temp_root.join("target/miden/debug/oddly_named.masp"); write_masp_fixture(&package_path, "dep-fixture", None); - let resolved = resolve_dependency_package("dep-fixture", &temp_root).unwrap(); + let resolved = resolve_dependency_package("dep-fixture", &temp_root, None).unwrap(); assert_eq!(resolved.path, package_path); @@ -686,7 +729,7 @@ mod tests { let package_path = temp_root.join("target/miden/debug/oddly_named.masp"); write_masp_fixture(&package_path, "other-package", None); - let error = resolve_dependency_package("dep-fixture", &temp_root) + let error = resolve_dependency_package("dep-fixture", &temp_root, None) .expect_err("a solitary package with a foreign id must not be adopted"); let message = error.to_string(); @@ -714,7 +757,7 @@ mod tests { None, ); - let error = resolve_dependency_package("dep-fixture", &dependency_root) + let error = resolve_dependency_package("dep-fixture", &dependency_root, None) .expect_err("an unrelated solitary package in the workspace target must be ignored"); let message = error.to_string(); @@ -726,6 +769,57 @@ mod tests { std::fs::remove_dir_all(temp_root).unwrap(); } + #[test] + fn verifies_a_manifest_version_pin() { + // The test fixture package carries version 0.1.0. + let temp_root = fixture_root("version-pin"); + let package_path = temp_root.join("target/miden/debug/dep_fixture.masp"); + write_masp_fixture(&package_path, "dep-fixture", None); + + let satisfied = miden_project::VersionRequirement::Semantic( + miden_assembly_syntax::debuginfo::Span::unknown("^0.1".parse().unwrap()), + ); + let resolved = + resolve_dependency_package("dep-fixture", &temp_root, Some(&satisfied)).unwrap(); + assert_eq!(resolved.path, package_path); + + let unsatisfied = miden_project::VersionRequirement::Semantic( + miden_assembly_syntax::debuginfo::Span::unknown("^2.0".parse().unwrap()), + ); + let error = resolve_dependency_package("dep-fixture", &temp_root, Some(&unsatisfied)) + .expect_err("a version outside the manifest pin must reject the candidate"); + let message = error.to_string(); + + assert!( + message.contains("does not satisfy the manifest requirement"), + "unexpected error: {message}" + ); + assert!(message.contains("0.1.0"), "unexpected error: {message}"); + + std::fs::remove_dir_all(temp_root).unwrap(); + } + + #[test] + fn corrupt_dependency_package_reports_rebuild_hint() { + let temp_root = fixture_root("corrupt"); + let package_path = temp_root.join("target/miden/debug/dep_fixture.masp"); + std::fs::create_dir_all(package_path.parent().unwrap()).unwrap(); + std::fs::write(&package_path, b"garbage").unwrap(); + + let error = resolve_dependency_package("dep-fixture", &temp_root, None) + .expect_err("a corrupt dependency package must fail resolution"); + let message = error.to_string(); + + assert!(message.contains("failed to deserialize"), "unexpected error: {message}"); + assert!( + message.contains("different Miden toolchain version"), + "unexpected error: {message}" + ); + assert!(message.contains("cargo miden build"), "unexpected error: {message}"); + + std::fs::remove_dir_all(temp_root).unwrap(); + } + #[test] fn missing_dependency_package_message_explains_macro_time_requirement() { let temp_root = fixture_root("missing-message"); @@ -741,8 +835,10 @@ mod tests { shared: Vec::new(), ambient: Vec::new(), }; - let id_mismatches = - vec![(temp_root.join("target/miden/debug/stray.masp"), "stray".to_string())]; + let rejected = vec![( + temp_root.join("target/miden/debug/stray.masp"), + "package id 'stray'".to_string(), + )]; let message = missing_dependency_package_message( "counter", @@ -750,7 +846,7 @@ mod tests { &stems, &output_dirs, &profiles, - &id_mismatches, + &rejected, ); assert!(message.contains("could not find a built `.masp` package")); diff --git a/sdk/base-macros/src/fpi.rs b/sdk/base-macros/src/fpi.rs index c8b503981..11f8be212 100644 --- a/sdk/base-macros/src/fpi.rs +++ b/sdk/base-macros/src/fpi.rs @@ -1492,7 +1492,10 @@ fn procedure_root_tokens(root: ProcedureRoot) -> TokenStream2 { quote!(::miden::Word::new([#(#felts),*])) } -/// Loads a single dependency package and extracts exported procedure roots. +/// Extracts exported procedure roots from a selected dependency's package. +/// +/// The package was deserialized (and identity-checked) once during dependency resolution and is +/// reused here rather than re-read from disk. fn load_dependency( dependency: SelectedDependency, trait_ident: syn::Ident, @@ -1500,7 +1503,7 @@ fn load_dependency( let import = dependency.import().to_owned(); let module_path = import_module_path(&import); let package_path = dependency.package_path.clone(); - let package = crate::dependency_package::read_package(&package_path)?; + let package = dependency.package.clone(); let mut roots = HashMap::new(); for export in package.manifest.exports() { diff --git a/sdk/base-macros/src/test_support.rs b/sdk/base-macros/src/test_support.rs index 3813c1274..0d9d4021e 100644 --- a/sdk/base-macros/src/test_support.rs +++ b/sdk/base-macros/src/test_support.rs @@ -3,11 +3,12 @@ use std::{fs, path::Path, sync::Arc}; use miden_assembly::{Assembler, DefaultSourceManager, ModuleParser, ast::ModuleKind}; +use miden_mast_package::Package; use miden_protocol::utils::serde::Serializable; -/// Writes a minimal `.masp` package fixture with the given package id, optionally embedding -/// `wit` in the WIT section. The fixture version is `0.1.0`. -pub(crate) fn write_masp_fixture(package_path: &Path, package_id: &str, wit: Option<&str>) { +/// Builds a minimal package fixture with the given package id, optionally embedding `wit` in the +/// WIT section. The fixture version is `0.1.0`. +pub(crate) fn build_package(package_id: &str, wit: Option<&str>) -> Arc { let source_manager = Arc::new(DefaultSourceManager::default()); let module = ModuleParser::new(Some(ModuleKind::Library)) .parse_str( @@ -26,7 +27,13 @@ pub(crate) fn write_masp_fixture(package_path: &Path, package_id: &str, wit: Opt wit.as_bytes().to_vec(), )); } + Arc::from(package) +} +/// Writes a minimal `.masp` package fixture with the given package id, optionally embedding +/// `wit` in the WIT section. +pub(crate) fn write_masp_fixture(package_path: &Path, package_id: &str, wit: Option<&str>) { + let package = build_package(package_id, wit); fs::create_dir_all(package_path.parent().expect("package path must have a parent")) .expect("package directory must be created"); fs::write(package_path, package.to_bytes()).expect("package fixture must be written"); diff --git a/sdk/base-macros/src/wit_world.rs b/sdk/base-macros/src/wit_world.rs index 2ff991b6e..52717a37d 100644 --- a/sdk/base-macros/src/wit_world.rs +++ b/sdk/base-macros/src/wit_world.rs @@ -266,6 +266,9 @@ pub(crate) struct MidenDependency { pub(crate) name: String, /// Path of the compiled `.masp` package the dependency metadata was read from. pub(crate) package_path: PathBuf, + /// The deserialized package, shared so FPI procedure-root extraction reuses the exact read + /// the package identity was verified against. + pub(crate) package: Arc, /// Exported WIT interfaces loaded from the dependency metadata. pub(crate) interfaces: Vec, } @@ -278,6 +281,7 @@ impl MidenDependency { .find(|interface| interface.name == interface_name) .map(|interface| SelectedDependency { package_path: self.package_path.clone(), + package: self.package.clone(), interface: interface.clone(), }) } @@ -296,6 +300,8 @@ impl MidenDependency { pub(crate) struct SelectedDependency { /// Path of the compiled `.masp` package the dependency metadata was read from. pub(crate) package_path: PathBuf, + /// The deserialized package the metadata was read from. + pub(crate) package: Arc, /// The selected exported WIT interface. pub(crate) interface: DependencyInterface, } @@ -377,6 +383,7 @@ fn collect_miden_dependencies( dependencies.push(MidenDependency { name: source.name, package_path: source.package_path, + package: source.package, interfaces: dependency_wit.interfaces, }); } @@ -726,6 +733,7 @@ world multi-account-world { let dependency = super::MidenDependency { name: "multi-account".to_string(), package_path: PathBuf::from("/tmp/multi-account/target/miden/debug/multi_account.masp"), + package: crate::test_support::build_package("multi-account", None), interfaces: dependency_wit.interfaces, }; From daca767af26d964d4b38e659e164a58684cf0abf Mon Sep 17 00:00:00 2001 From: Denys Zadorozhnyi Date: Mon, 6 Jul 2026 09:36:51 +0300 Subject: [PATCH 08/12] refactor: name the account-metadata custom section with a shared constant MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The account-component metadata section name was a bare "rodata,miden_account" literal repeated at the producing macro, the frontend match guard, its duplicate-section diagnostic, and the cross-module merge label, while its sibling WIT section already had a shared constant — the exact producer/consumer drift the constants exist to prevent. Add `WASM_ACCOUNT_COMPONENT_METADATA_CUSTOM_SECTION_NAME` to `midenc-frontend-wasm-metadata` next to the other section names and use it at every site; the literal now exists only in the constant's definition. --- frontend/wasm/src/module/module_env.rs | 20 ++++++++++++-------- sdk/base-macros/src/component_macro/mod.rs | 8 +++++--- sdk/wasm-metadata/src/lib.rs | 3 +++ 3 files changed, 20 insertions(+), 11 deletions(-) diff --git a/frontend/wasm/src/module/module_env.rs b/frontend/wasm/src/module/module_env.rs index 87fdc75dc..25e08fe39 100644 --- a/frontend/wasm/src/module/module_env.rs +++ b/frontend/wasm/src/module/module_env.rs @@ -4,8 +4,9 @@ use std::path::PathBuf; use cranelift_entity::{PrimaryMap, packed_option::ReservedValue}; use midenc_frontend_wasm_metadata::{ - FrontendMetadata, PackageSections, WASM_COMPONENT_WIT_CUSTOM_SECTION_NAME, - WASM_FRONTEND_METADATA_CUSTOM_SECTION_NAME, decode_section, + FrontendMetadata, PackageSections, WASM_ACCOUNT_COMPONENT_METADATA_CUSTOM_SECTION_NAME, + WASM_COMPONENT_WIT_CUSTOM_SECTION_NAME, WASM_FRONTEND_METADATA_CUSTOM_SECTION_NAME, + decode_section, }; use midenc_hir::{FxHashMap, FxHashSet, Ident, interner::Symbol}; use midenc_session::diagnostics::{DiagnosticsHandler, IntoDiagnostic, Report, Severity}; @@ -195,7 +196,7 @@ pub(crate) fn collect_package_sections<'a, 'data: 'a>( merge_section_payload( &mut account_component_metadata, module.account_component_metadata_bytes, - "rodata,miden_account", + WASM_ACCOUNT_COMPONENT_METADATA_CUSTOM_SECTION_NAME, )?; merge_section_payload( &mut component_wit, @@ -446,14 +447,17 @@ impl<'a, 'data> ModuleEnvironment<'a, 'data> { } } Payload::CustomSection(s) if s.name().starts_with(".debug_") => self.dwarf_section(&s), - Payload::CustomSection(s) if s.name() == "rodata,miden_account" => { + Payload::CustomSection(s) + if s.name() == WASM_ACCOUNT_COMPONENT_METADATA_CUSTOM_SECTION_NAME => + { if self.result.account_component_metadata_bytes.replace(s.data()).is_some() { return Err(diagnostics .diagnostic(Severity::Error) - .with_message( - "wasm error: multiple 'rodata,miden_account' custom sections were \ - found; only one is allowed per core Wasm module", - ) + .with_message(format!( + "wasm error: multiple \ + '{WASM_ACCOUNT_COMPONENT_METADATA_CUSTOM_SECTION_NAME}' custom \ + sections were found; only one is allowed per core Wasm module" + )) .into_report()); } } diff --git a/sdk/base-macros/src/component_macro/mod.rs b/sdk/base-macros/src/component_macro/mod.rs index 74290da7a..9dbebdd4c 100644 --- a/sdk/base-macros/src/component_macro/mod.rs +++ b/sdk/base-macros/src/component_macro/mod.rs @@ -6,7 +6,9 @@ use std::{ use heck::{ToKebabCase, ToSnakeCase}; use miden_project::TargetType; use miden_protocol::utils::serde::Serializable; -use midenc_frontend_wasm_metadata::FrontendMetadata; +use midenc_frontend_wasm_metadata::{ + FrontendMetadata, WASM_ACCOUNT_COMPONENT_METADATA_CUSTOM_SECTION_NAME, +}; use proc_macro::Span; use proc_macro2::{Ident, Literal, Span as Span2, TokenStream as TokenStream2}; use quote::{format_ident, quote}; @@ -1394,7 +1396,7 @@ fn account_procedure_frontend_metadata( } } -/// Emits the static metadata blob inside the `rodata,miden_account` link section. +/// Emits the static metadata blob inside the account-component metadata link section. fn generate_link_section(metadata_bytes: &[u8]) -> proc_macro2::TokenStream { let link_section_bytes_len = metadata_bytes.len(); let encoded_bytes_str = Literal::byte_string(metadata_bytes); @@ -1403,7 +1405,7 @@ fn generate_link_section(metadata_bytes: &[u8]) -> proc_macro2::TokenStream { #[unsafe( // to test it in the integration(this crate) tests the section name needs to make mach-o section // specifier happy and to have "segment and section separated by comma" - link_section = "rodata,miden_account" + link_section = #WASM_ACCOUNT_COMPONENT_METADATA_CUSTOM_SECTION_NAME )] #[doc(hidden)] #[allow(clippy::octal_escapes)] diff --git a/sdk/wasm-metadata/src/lib.rs b/sdk/wasm-metadata/src/lib.rs index 0e05c972f..f5ad44e8f 100644 --- a/sdk/wasm-metadata/src/lib.rs +++ b/sdk/wasm-metadata/src/lib.rs @@ -17,6 +17,9 @@ use serde::{Deserialize, Serialize}; pub const WASM_FRONTEND_METADATA_CUSTOM_SECTION_NAME: &str = "rodata,miden_account_component_frontend"; +/// Name of the Wasm custom section used to store the serialized AccountComponentMetadata. +pub const WASM_ACCOUNT_COMPONENT_METADATA_CUSTOM_SECTION_NAME: &str = "rodata,miden_account"; + /// Name of the Wasm custom section used to store the component's public WIT source. pub const WASM_COMPONENT_WIT_CUSTOM_SECTION_NAME: &str = "rodata,miden_wit"; From 00cc9be5e9a47b434edffdb60cdace2202af9074 Mon Sep 17 00:00:00 2001 From: Denys Zadorozhnyi Date: Fri, 10 Jul 2026 12:12:21 +0300 Subject: [PATCH 09/12] fix: restore the wit manifest key as a fallback for packages without embedded WIT The package.metadata.miden.dependencies..wit key in miden-project.toml is consulted only when the dependency package has no embedded WIT section, as an escape hatch for packages produced by toolchains that do not embed WIT. The key may name a .wit file or a directory containing exactly one top-level .wit file, and the override must satisfy the same self-containment rule as embedded WIT. Setting the key for a package that embeds WIT is an error, and the .masp package remains required in all cases. --- CHANGELOG.md | 8 +- sdk/base-macros/src/dependency_package.rs | 204 +++++++++++++++++++-- sdk/base-macros/src/wit_world.rs | 209 +++++++++++++++++++++- sdk/sdk/MIGRATION.md | 23 +-- tests/integration/src/sdk/macros.rs | 87 ++++++++- 5 files changed, 498 insertions(+), 33 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a6a4b18d4..8185f1a69 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,9 +21,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - BREAKING: The component WIT generated by `#[component]` is now embedded in the compiled Miden package (a `wit` section of the `.masp`) instead of being written to `target/generated-wit/`, and the SDK macros read dependency WIT from the dependency's compiled package. The - `wit = "..."` keys in `miden-project.toml` are no longer read, and dependency packages built by - older toolchains are rejected. See the [migration guide](./sdk/sdk/MIGRATION.md) for the - manifest edits and rebuild steps #1248 + `wit = "..."` keys in `miden-project.toml` are now only a fallback for dependency packages + without embedded WIT (e.g. produced by other toolchains): setting the key for a package that + embeds WIT is an error, and packages built by older Miden toolchains are rejected unless the + key supplies their WIT. See the [migration guide](./sdk/sdk/MIGRATION.md) for the manifest + edits and rebuild steps #1248 ## [0.10.0-rc.1] diff --git a/sdk/base-macros/src/dependency_package.rs b/sdk/base-macros/src/dependency_package.rs index d1271a44f..9ab1ca660 100644 --- a/sdk/base-macros/src/dependency_package.rs +++ b/sdk/base-macros/src/dependency_package.rs @@ -25,11 +25,17 @@ pub(crate) struct DependencyWitSource { /// The deserialized package, shared so later consumers (FPI procedure-root extraction) reuse /// the exact read the package id was verified against. pub(crate) package: Arc, - /// The component WIT source embedded in the package. + /// The component WIT source: embedded in the package, or supplied by the dependency's `wit` + /// manifest key when the package embeds none. pub(crate) wit: String, } -/// Reads the embedded WIT of every Miden path dependency's compiled package. +/// Reads the WIT of every Miden path dependency's compiled package. +/// +/// Embedded WIT is authoritative. A dependency whose package embeds none may supply it manually +/// through the `package.metadata.miden.dependencies..wit` key in `miden-project.toml` — the +/// escape hatch for packages produced by toolchains that do not embed WIT. Setting the key for a +/// package that embeds WIT is an error. pub(crate) fn collect_dependency_wit_sources( manifest_dir: &Path, package: &miden_project::Package, @@ -56,7 +62,36 @@ pub(crate) fn collect_dependency_wit_sources( &dependency_root, version.as_ref(), )?; - let wit = package_wit(&resolved.package, &resolved.path)?; + let wit_override = dependency_wit_override(package, dependency.name().as_ref())?; + let wit = match (package_wit(&resolved.package, &resolved.path)?, wit_override) { + (Some(_), Some(_)) => { + return Err(Error::new( + error_span, + format!( + "dependency '{}': package '{}' embeds component WIT, but \ + miden-project.toml also sets \ + package.metadata.miden.dependencies.{}.wit; remove the `wit` key \ + — embedded WIT is authoritative", + dependency.name(), + resolved.path.display(), + dependency.name(), + ), + )); + } + (Some(wit), None) => wit, + (None, Some(wit_override)) => { + read_wit_override(&wit_override, manifest_dir, dependency.name().as_ref())? + } + (None, None) => { + return Err(Error::new( + error_span, + missing_embedded_wit_message( + &resolved.path, + dependency.name().as_ref(), + ), + )); + } + }; sources.push(DependencyWitSource { name: dependency.name().to_string(), root: dependency_root, @@ -75,6 +110,149 @@ pub(crate) fn collect_dependency_wit_sources( Ok(sources) } +/// Returns the raw WIT override path from `package.metadata.miden.dependencies..wit`. +fn dependency_wit_override( + package: &miden_project::Package, + dependency_name: &str, +) -> Result, Error> { + let Some(wit_value) = package + .metadata() + .get("miden") + .and_then(|meta| meta.get("dependencies")) + .and_then(|value| value.as_table()) + .and_then(|dependencies| dependencies.get(dependency_name)) + .and_then(|config| config.as_table()) + .and_then(|config| config.get("wit")) + else { + return Ok(None); + }; + let wit_path = wit_value.as_str().ok_or_else(|| { + Error::new( + Span::call_site(), + format!( + "invalid miden-project.toml configuration: expected \ + package.metadata.miden.dependencies.{dependency_name}.wit to be a string" + ), + ) + })?; + Ok(Some(wit_path.to_string())) +} + +/// Reads a dependency's manually provided WIT from a `.wit` file or a directory containing +/// exactly one top-level `.wit` file. +/// +/// The override is validated like embedded WIT: it must resolve against the bundled SDK WIT alone +/// and export an interface, so every macro flow gets the accurate diagnostic at the source. +fn read_wit_override( + wit_path: &str, + manifest_dir: &Path, + dependency_name: &str, +) -> Result { + let error_span = Span::call_site(); + let raw_path = Path::new(wit_path); + let absolute_path = if raw_path.is_absolute() { + raw_path.to_path_buf() + } else { + manifest_dir.join(raw_path) + }; + let path = fs::canonicalize(&absolute_path).map_err(|err| { + Error::new( + error_span, + format!( + "failed to resolve the WIT override for dependency '{dependency_name}' from \ + package.metadata.miden.dependencies.{dependency_name}.wit = '{wit_path}': '{}': \ + {err}", + absolute_path.display() + ), + ) + })?; + + let file = if path.is_dir() { + let mut wit_files = fs::read_dir(&path) + .map_err(|err| { + Error::new( + error_span, + format!( + "failed to read the WIT override directory '{}' for dependency \ + '{dependency_name}': {err}", + path.display() + ), + ) + })? + .collect::, _>>() + .map_err(|err| { + Error::new( + error_span, + format!( + "failed to iterate the WIT override directory '{}' for dependency \ + '{dependency_name}': {err}", + path.display() + ), + ) + })? + .into_iter() + .map(|entry| entry.path()) + .filter(|path| { + path.is_file() && path.extension().is_some_and(|extension| extension == "wit") + }) + .collect::>(); + wit_files.sort(); + match wit_files.len() { + 1 => wit_files.remove(0), + count => { + return Err(Error::new( + error_span, + format!( + "the WIT override directory '{}' for dependency '{dependency_name}' \ + contains {count} `.wit` files; point \ + package.metadata.miden.dependencies.{dependency_name}.wit at a single \ + self-contained `.wit` file", + path.display() + ), + )); + } + } + } else { + path.to_path_buf() + }; + + let wit = fs::read_to_string(&file).map_err(|err| { + Error::new( + error_span, + format!( + "failed to read the WIT override '{}' for dependency '{dependency_name}': {err}", + file.display() + ), + ) + })?; + crate::wit_world::parse_dependency_wit_source(&wit).map_err(|details| { + Error::new( + error_span, + format!( + "invalid WIT override for dependency '{dependency_name}' at '{}': {details}. The \ + override must be self-contained apart from the bundled SDK WIT (`miden:base`) \ + and export an interface.", + file.display() + ), + ) + })?; + Ok(wit) +} + +/// Formats the diagnostic for a dependency package that embeds no WIT and has no override. +fn missing_embedded_wit_message(package_path: &Path, dependency_name: &str) -> String { + format!( + "dependency package '{}' does not embed component WIT (missing package section \ + '{PACKAGE_WIT_SECTION_ID}'); it was likely built with an older Miden toolchain. Rebuild \ + the dependency with the current `cargo miden build`, or provide the WIT manually via \ + package.metadata.miden.dependencies.{dependency_name}.wit in miden-project.toml. For \ + manually authored components (a hand-written `wit/` directory with a bare \ + `miden::generate!()`), the WIT is embedded only when the `wit/` directory contains \ + exactly one `.wit` file that is self-contained and exports an interface.", + package_path.display() + ) +} + /// Returns the package section id carrying the embedded component WIT. pub(crate) fn wit_section_id() -> SectionId { SectionId::custom(PACKAGE_WIT_SECTION_ID) @@ -104,25 +282,17 @@ pub(crate) fn read_package(package_path: &Path) -> Result, Error> { } /// Extracts the component WIT embedded in a compiled Miden package. -fn package_wit(package: &Package, package_path: &Path) -> Result { +/// +/// Returns `Ok(None)` when the package has no WIT section; a section that is present but not +/// valid UTF-8 is an error (the package claims its own WIT, so nothing may substitute it). +fn package_wit(package: &Package, package_path: &Path) -> Result, Error> { let error_span = Span::call_site(); let wit_section_id = wit_section_id(); let Some(section) = package.sections.iter().find(|section| section.id == wit_section_id) else { - return Err(Error::new( - error_span, - format!( - "dependency package '{}' does not embed component WIT (missing package section \ - '{PACKAGE_WIT_SECTION_ID}'); it was likely built with an older Miden toolchain. \ - Rebuild the dependency with the current `cargo miden build`. For manually \ - authored components (a hand-written `wit/` directory with a bare \ - `miden::generate!()`), the WIT is embedded only when the `wit/` directory \ - contains exactly one `.wit` file that is self-contained and exports an interface.", - package_path.display() - ), - )); + return Ok(None); }; - String::from_utf8(section.data.to_vec()).map_err(|err| { + String::from_utf8(section.data.to_vec()).map(Some).map_err(|err| { Error::new( error_span, format!( diff --git a/sdk/base-macros/src/wit_world.rs b/sdk/base-macros/src/wit_world.rs index 52717a37d..6e7c0bd8a 100644 --- a/sdk/base-macros/src/wit_world.rs +++ b/sdk/base-macros/src/wit_world.rs @@ -367,7 +367,8 @@ pub(crate) fn write_world_block( /// Collects dependency metadata needed for SDK-generated dependency imports. /// /// The dependency's exported interfaces are read from the component WIT embedded in its compiled -/// `.masp` package, which cargo-miden materializes before the dependent crate's macros expand. +/// `.masp` package, which cargo-miden materializes before the dependent crate's macros expand +/// (or from the dependency's `wit` manifest key when the package embeds none). fn collect_miden_dependencies( manifest_dir: &Path, package: &miden_project::Package, @@ -539,6 +540,7 @@ mod tests { use miden_assembly_syntax::{ast, debuginfo::Span as MidenSpan}; use miden_project::Uri; use proc_macro2::Span; + use toml::{Value, value::Table}; use super::{ProjectPackageMetadata, collect_miden_dependencies, parse_dependency_wit_source}; @@ -623,6 +625,39 @@ world basic-wallet-world { miden_project::Package::new("consumer", target).with_dependencies([dependency]) } + /// Like [`package_with_dependency`], but with the dependency's WIT override key set to + /// `wit_path` (`package.metadata.miden.dependencies.wit-world-fixture-dep.wit`). + fn package_with_dependency_and_wit_key( + package_path: PathBuf, + wit_path: &Path, + ) -> Box { + package_with_dependency(package_path).with_metadata(miden_metadata_dependencies( + "wit-world-fixture-dep", + wit_path.to_string_lossy().as_ref(), + )) + } + + fn miden_metadata_dependencies( + dependency_name: &str, + wit_path: &str, + ) -> miden_project::MetadataSet { + let mut dependency_config = Table::new(); + dependency_config.insert("wit".to_string(), Value::String(wit_path.to_string())); + + let mut dependencies = Table::new(); + dependencies.insert(dependency_name.to_string(), Value::Table(dependency_config)); + + let mut miden_metadata = miden_project::Metadata::default(); + miden_metadata.insert( + MidenSpan::unknown(Arc::::from("dependencies")), + MidenSpan::unknown(Value::Table(dependencies)), + ); + + let mut metadata = miden_project::MetadataSet::default(); + metadata.insert(MidenSpan::unknown(Arc::::from("miden")), miden_metadata); + metadata + } + #[test] fn project_package_metadata_defaults_without_miden_project_manifest() { let fixture_root = empty_fixture_root(); @@ -881,6 +916,178 @@ world empty-export-world { assert!(message.contains("does not embed component WIT"), "unexpected error: {message}"); assert!(message.contains("older Miden toolchain"), "unexpected error: {message}"); assert!(message.contains("cargo miden build"), "unexpected error: {message}"); + assert!(message.contains("provide the WIT manually via"), "unexpected error: {message}"); + assert!( + message.contains("package.metadata.miden.dependencies.wit-world-fixture-dep.wit"), + "unexpected error: {message}" + ); + + fs::remove_dir_all(fixture_root).expect("temporary fixture directory must be removed"); + } + + #[test] + fn wit_override_file_supplies_missing_embedded_wit() { + // The escape hatch: a package without a WIT section takes its WIT from the `.wit` file + // named by the dependency's `wit` key in miden-project.toml. + let fixture_root = empty_fixture_root(); + let dependency_root = fixture_root.join("wit-world-fixture-dep"); + write_masp_fixture( + &dependency_root.join("target/miden/debug/wit_world_fixture_dep.masp"), + None, + ); + let override_path = fixture_root.join("overrides/basic-wallet.wit"); + fs::create_dir_all(override_path.parent().unwrap()) + .expect("override fixture directory must be created"); + fs::write(&override_path, BASIC_WALLET_GENERATED_WIT) + .expect("override fixture must be written"); + let package = package_with_dependency_and_wit_key(dependency_root, &override_path); + + let dependencies = + collect_miden_dependencies(&fixture_root, &package, proc_macro2::Span::call_site()) + .unwrap(); + + assert_eq!(dependencies.len(), 1); + assert_eq!(dependencies[0].interface_names(), vec!["basic-wallet"]); + assert_eq!(dependencies[0].interfaces[0].import, "miden:basic-wallet/basic-wallet@0.1.0"); + + fs::remove_dir_all(fixture_root).expect("temporary fixture directory must be removed"); + } + + #[test] + fn wit_override_directory_supplies_missing_embedded_wit() { + // The `wit` key may name a directory holding exactly one top-level `.wit` file. + let fixture_root = empty_fixture_root(); + let dependency_root = fixture_root.join("wit-world-fixture-dep"); + write_masp_fixture( + &dependency_root.join("target/miden/debug/wit_world_fixture_dep.masp"), + None, + ); + let override_dir = fixture_root.join("overrides"); + fs::create_dir_all(&override_dir).expect("override fixture directory must be created"); + fs::write(override_dir.join("basic-wallet.wit"), BASIC_WALLET_GENERATED_WIT) + .expect("override fixture must be written"); + let package = package_with_dependency_and_wit_key(dependency_root, &override_dir); + + let dependencies = + collect_miden_dependencies(&fixture_root, &package, proc_macro2::Span::call_site()) + .unwrap(); + + assert_eq!(dependencies.len(), 1); + assert_eq!(dependencies[0].interface_names(), vec!["basic-wallet"]); + + fs::remove_dir_all(fixture_root).expect("temporary fixture directory must be removed"); + } + + #[test] + fn wit_override_conflicting_with_embedded_wit_reports_error() { + // A `wit` key set for a package that embeds WIT is a configuration conflict, reported + // even before the key's path is inspected (the path here does not exist). + let fixture_root = empty_fixture_root(); + let dependency_root = fixture_root.join("wit-world-fixture-dep"); + write_masp_fixture( + &dependency_root.join("target/miden/debug/wit_world_fixture_dep.masp"), + Some(BASIC_WALLET_GENERATED_WIT), + ); + let override_path = fixture_root.join("overrides/does-not-exist.wit"); + let package = package_with_dependency_and_wit_key(dependency_root, &override_path); + + let error = + collect_miden_dependencies(&fixture_root, &package, proc_macro2::Span::call_site()) + .expect_err("a wit key alongside embedded WIT must fail metadata load"); + let message = error.to_string(); + + assert!(message.contains("embeds component WIT"), "unexpected error: {message}"); + assert!(message.contains("remove the `wit` key"), "unexpected error: {message}"); + + fs::remove_dir_all(fixture_root).expect("temporary fixture directory must be removed"); + } + + #[test] + fn wit_override_with_missing_path_reports_error() { + let fixture_root = empty_fixture_root(); + let dependency_root = fixture_root.join("wit-world-fixture-dep"); + write_masp_fixture( + &dependency_root.join("target/miden/debug/wit_world_fixture_dep.masp"), + None, + ); + let override_path = fixture_root.join("overrides/does-not-exist.wit"); + let package = package_with_dependency_and_wit_key(dependency_root, &override_path); + + let error = + collect_miden_dependencies(&fixture_root, &package, proc_macro2::Span::call_site()) + .expect_err("a wit key pointing at a missing path must fail metadata load"); + let message = error.to_string(); + + assert!( + message.contains("failed to resolve the WIT override"), + "unexpected error: {message}" + ); + assert!( + message.contains("package.metadata.miden.dependencies.wit-world-fixture-dep.wit"), + "unexpected error: {message}" + ); + + fs::remove_dir_all(fixture_root).expect("temporary fixture directory must be removed"); + } + + #[test] + fn wit_override_directory_with_multiple_wit_files_reports_error() { + let fixture_root = empty_fixture_root(); + let dependency_root = fixture_root.join("wit-world-fixture-dep"); + write_masp_fixture( + &dependency_root.join("target/miden/debug/wit_world_fixture_dep.masp"), + None, + ); + let override_dir = fixture_root.join("overrides"); + fs::create_dir_all(&override_dir).expect("override fixture directory must be created"); + fs::write(override_dir.join("first.wit"), BASIC_WALLET_GENERATED_WIT) + .expect("override fixture must be written"); + fs::write(override_dir.join("second.wit"), BASIC_WALLET_GENERATED_WIT) + .expect("override fixture must be written"); + let package = package_with_dependency_and_wit_key(dependency_root, &override_dir); + + let error = + collect_miden_dependencies(&fixture_root, &package, proc_macro2::Span::call_site()) + .expect_err("an override directory with two .wit files must fail metadata load"); + let message = error.to_string(); + + assert!(message.contains("contains 2 `.wit` files"), "unexpected error: {message}"); + assert!( + message.contains("a single self-contained `.wit` file"), + "unexpected error: {message}" + ); + + fs::remove_dir_all(fixture_root).expect("temporary fixture directory must be removed"); + } + + #[test] + fn non_self_contained_wit_override_reports_error() { + // The override obeys the same self-containment rule as embedded WIT. + let importing_wit = r#"package miden:importing@0.1.0; + +world importer { + import miden:not-embedded/api@0.1.0; +} +"#; + let fixture_root = empty_fixture_root(); + let dependency_root = fixture_root.join("wit-world-fixture-dep"); + write_masp_fixture( + &dependency_root.join("target/miden/debug/wit_world_fixture_dep.masp"), + None, + ); + let override_path = fixture_root.join("overrides/importing.wit"); + fs::create_dir_all(override_path.parent().unwrap()) + .expect("override fixture directory must be created"); + fs::write(&override_path, importing_wit).expect("override fixture must be written"); + let package = package_with_dependency_and_wit_key(dependency_root, &override_path); + + let error = + collect_miden_dependencies(&fixture_root, &package, proc_macro2::Span::call_site()) + .expect_err("an override referencing a foreign package must fail metadata load"); + let message = error.to_string(); + + assert!(message.contains("invalid WIT override"), "unexpected error: {message}"); + assert!(message.contains("must be self-contained"), "unexpected error: {message}"); fs::remove_dir_all(fixture_root).expect("temporary fixture directory must be removed"); } diff --git a/sdk/sdk/MIGRATION.md b/sdk/sdk/MIGRATION.md index 0bcbadd58..134b29e55 100644 --- a/sdk/sdk/MIGRATION.md +++ b/sdk/sdk/MIGRATION.md @@ -249,12 +249,15 @@ method (see the `basic-wallet` example's `create_note`). The component WIT generated by `#[component]` is now embedded in the compiled Miden package (a `wit` section of the `.masp`) instead of being written to `target/generated-wit/`. The `#[account(...)]`, sibling `#[component(pkg::Interface)]`, `#[note]`, and `#[tx_script]` macros -read dependency WIT from the dependency's compiled `.masp`, so WIT path metadata is no longer read -and every Miden path dependency must be a built package (cargo-miden builds path dependencies -automatically; a dependency that points directly at a prebuilt `.masp` file is self-contained). +read dependency WIT from the dependency's compiled `.masp`, and every Miden path dependency must +be a built package (cargo-miden builds path dependencies automatically; a dependency that points +directly at a prebuilt `.masp` file is self-contained). Remove the `wit = "..."` entries from `[package.metadata.miden.dependencies]` in -`miden-project.toml`. +`miden-project.toml` for dependencies whose packages embed WIT — a leftover key is now an error +("remove the `wit` key"). The key remains available as an escape hatch for dependency packages +*without* embedded WIT (e.g. produced by another toolchain); it must point at a single +self-contained `.wit` file, or a directory containing exactly one top-level `.wit` file. Before: @@ -275,12 +278,12 @@ basic-wallet = { path = "../basic-wallet" } A `.masp` built by an older SDK has no embedded WIT and is rejected during macro expansion with a "does not embed component WIT" error; rebuild each dependency with the current toolchain -(`cargo miden build`). Components written without the `#[component]` macro — a hand-written `wit/` -directory and a bare `miden::generate!()` — embed their WIT automatically when the `wit/` -directory contains a single `.wit` file, so no changes are needed there. WIT split across multiple -files, or referencing packages under `wit/deps/` other than the bundled SDK WIT, cannot be -embedded verbatim yet; consolidate it into one self-contained file if the component is consumed as -a Miden dependency. +(`cargo miden build`), or supply the WIT manually via the `wit` key as above. Components written +without the `#[component]` macro — a hand-written `wit/` directory and a bare +`miden::generate!()` — embed their WIT automatically when the `wit/` directory contains a single +`.wit` file, so no changes are needed there. WIT split across multiple files, or referencing +packages under `wit/deps/` other than the bundled SDK WIT, cannot be embedded verbatim yet; +consolidate it into one self-contained file if the component is consumed as a Miden dependency. Note for the editor workflow: previously, `cargo check` (or rust-analyzer) of the dependency crate regenerated its WIT under `target/generated-wit` as a macro side effect. Dependency WIT now comes diff --git a/tests/integration/src/sdk/macros.rs b/tests/integration/src/sdk/macros.rs index 8b47ba740..3930f5497 100644 --- a/tests/integration/src/sdk/macros.rs +++ b/tests/integration/src/sdk/macros.rs @@ -656,19 +656,32 @@ fn account_component_project_with_sibling_dep_inner( lib_rs: &str, sibling_wit: Option<&str>, ) -> crate::cargo_proj::Project { - let cargo_proj = account_component_project_with_sibling_dep_root(name, lib_rs); + let cargo_proj = account_component_project_with_sibling_dep_root(name, lib_rs, None); write_sibling_package(&cargo_proj, sibling_wit); cargo_proj } /// Builds the sibling-dependency project skeleton without a compiled dependency package. +/// +/// `sibling_wit_key` optionally sets the dependency's manual WIT path +/// (`package.metadata.miden.dependencies.test-sibling.wit`) in `miden-project.toml`, relative to +/// the project root. fn account_component_project_with_sibling_dep_root( name: &str, lib_rs: &str, + sibling_wit_key: Option<&str>, ) -> crate::cargo_proj::Project { let sdk_path = sdk_crate_path(); let namespace = base::account_component_namespace(name, "test-component"); let component_package = format!("miden:{}", name.replace('_', "-")); + let sibling_wit_entry = sibling_wit_key + .map(|wit_path| { + format!( + "\n[package.metadata.miden.dependencies]\ntest-sibling = {{ wit = \"{wit_path}\" \ + }}\n" + ) + }) + .unwrap_or_default(); let miden_project_toml = format!( r#" [package] @@ -684,7 +697,7 @@ path = "src/lib.rs" miden-core = "*" miden-protocol = "*" test-sibling = {{ path = "dep" }} -"# +{sibling_wit_entry}"# ); let cargo_toml = format!( r#" @@ -964,6 +977,7 @@ impl TestComponent for TestComponentStorage { let cargo_proj = account_component_project_with_sibling_dep_root( "component_sibling_missing_dep_package", lib_rs, + None, ); let output = cargo_check_miden_target(&cargo_proj); assert!( @@ -1018,6 +1032,75 @@ impl TestComponent for TestComponentStorage { assert!(stderr.contains("does not embed component WIT"), "unexpected stderr: {stderr}"); assert!(stderr.contains("cargo miden build"), "unexpected stderr: {stderr}"); + assert!(stderr.contains("provide the WIT manually"), "unexpected stderr: {stderr}"); +} + +/// The sibling-consumer source shared by the `wit`-key escape-hatch tests. +const SIBLING_WIT_KEY_LIB_RS: &str = r#"#![no_std] +#![feature(alloc_error_handler)] + +use miden::{component, component_storage, felt, native_account::NativeAccount, Felt}; + +#[component_storage] +struct TestComponentStorage; + +#[component(test_sibling::TestSibling)] +trait TestComponent: NativeAccount + TestSibling { + fn value(&mut self) -> Felt; +} + +#[component] +impl TestComponent for TestComponentStorage { + fn value(&mut self) -> Felt { + self.get_value() + } +} +"#; + +#[test] +fn component_sibling_wit_key_supplies_missing_embedded_wit() { + // The escape hatch end-to-end: the dependency package embeds no WIT (e.g. produced by a + // foreign toolchain), so the `wit` key in miden-project.toml supplies it and the build + // succeeds. + let cargo_proj = account_component_project_with_sibling_dep_root( + "component_sibling_wit_key_fallback", + SIBLING_WIT_KEY_LIB_RS, + Some("sibling-wit/test-sibling.wit"), + ); + write_sibling_package(&cargo_proj, None); + let override_path = cargo_proj.root().join("sibling-wit/test-sibling.wit"); + std::fs::create_dir_all(override_path.parent().unwrap()) + .expect("the WIT override directory must be created"); + std::fs::write(&override_path, TEST_SIBLING_GENERATED_WIT) + .expect("the WIT override fixture must be written"); + + let output = cargo_check_miden_target(&cargo_proj); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + output.status.success(), + "expected the wit key to supply the missing embedded WIT: {stderr}" + ); +} + +#[test] +fn component_sibling_wit_key_conflicts_with_embedded_wit() { + // A `wit` key set for a dependency whose package embeds WIT is a configuration conflict. + let cargo_proj = account_component_project_with_sibling_dep_root( + "component_sibling_wit_key_conflict", + SIBLING_WIT_KEY_LIB_RS, + Some("sibling-wit/test-sibling.wit"), + ); + write_sibling_package(&cargo_proj, Some(TEST_SIBLING_GENERATED_WIT)); + + let output = cargo_check_miden_target(&cargo_proj); + assert!( + !output.status.success(), + "expected a wit key alongside embedded WIT to fail the build" + ); + let stderr = String::from_utf8_lossy(&output.stderr); + + assert!(stderr.contains("embeds component WIT"), "unexpected stderr: {stderr}"); + assert!(stderr.contains("remove the `wit` key"), "unexpected stderr: {stderr}"); } #[test] From 166c43874c54339528e69ac6ab9f63677f4f82d9 Mon Sep 17 00:00:00 2001 From: Denys Zadorozhnyi Date: Fri, 31 Jul 2026 17:54:02 +0300 Subject: [PATCH 10/12] fix(tests): extract the MIDENC_EMIT_WIT dump from the compiled package The MIDENC_EMIT_WIT public-WIT dump read target/generated-wit, which this branch no longer produces, so the helper silently did nothing while the README kept documenting the feature. Extract the wit section from the compiled package instead, and move the hook from the Cargo-fixture builder (where no package exists yet) into CompilerTest::compile, right after the package is stored. The dump now fires for every route that assembles a package, is named per artifact, and skips packages that embed no WIT; the stale generated-wit timing comment at the old call site is gone. --- Cargo.lock | 1 + README.md | 10 ++--- tests/support/Cargo.toml | 1 + tests/support/src/compiler_test.rs | 64 ++++++++---------------------- 4 files changed, 23 insertions(+), 53 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index dfe72c2a4..6602acd5b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3983,6 +3983,7 @@ dependencies = [ "midenc-compile", "midenc-expect-test", "midenc-frontend-wasm", + "midenc-frontend-wasm-metadata", "midenc-hir", "midenc-session", "proptest", diff --git a/README.md b/README.md index 4fba7bb2a..997401320 100644 --- a/README.md +++ b/README.md @@ -58,11 +58,11 @@ This will run all of the unit tests in the workspace, as well as all of our `lit (comma-delimited), where `PATH` is treated either as folder e.g. `MIDENC_EMIT=ir=target/emit` or file `MIDENC_EMIT=hir=my_name.hir`. - `MIDENC_EMIT_MACRO_EXPAND[=]`: When set, integration tests dump `cargo expand` output for Rust fixtures to `.expanded.rs` files in `` (or the CWD if empty/`1`). -- `MIDENC_EMIT_WIT[=]`: When set, integration tests emit public component WIT as - `.wit` and resolved macro-generated inline worlds as `..inline.wit` in - `` (or the CWD if empty/`1`). Resolved FPI worlds include their injected synthetic packages - and `fpi-*` functions. Generated SDK integration fixtures enable the internal WIT-printer - feature in their Cargo manifests. +- `MIDENC_EMIT_WIT[=]`: When set, integration tests emit the public component WIT embedded + in each compiled package as `.wit` and resolved macro-generated inline worlds as + `..inline.wit` in `` (or the CWD if empty/`1`). Resolved FPI worlds include + their injected synthetic packages and `fpi-*` functions. Generated SDK integration fixtures + enable the internal WIT-printer feature in their Cargo manifests. ## Docs diff --git a/tests/support/Cargo.toml b/tests/support/Cargo.toml index 4178ec012..e77386315 100644 --- a/tests/support/Cargo.toml +++ b/tests/support/Cargo.toml @@ -31,6 +31,7 @@ miden-processor.workspace = true miden-debug = { workspace = true, features = ["proptest", "tui"] } midenc-expect-test.workspace = true midenc-frontend-wasm.workspace = true +midenc-frontend-wasm-metadata.workspace = true midenc-hir = { workspace = true, features = ["logging"] } midenc-session.workspace = true midenc-compile.workspace = true diff --git a/tests/support/src/compiler_test.rs b/tests/support/src/compiler_test.rs index b4b20f74b..56f9e8c7f 100644 --- a/tests/support/src/compiler_test.rs +++ b/tests/support/src/compiler_test.rs @@ -355,12 +355,6 @@ impl CompilerTestBuilder { // dependencies the crate declares are the ones the build uses. Extracting the // WebAssembly here and re-entering the compiler with it — which is what this did // — synthesized a project from the session instead, and the two disagreed. - // - // Keep generated WIT available when Cargo fails after macro expansion but before - // producing the final Wasm artifact. It is emitted during the build, which now - // happens inside `compile`, so this is a no-op here for a failure that has not - // occurred yet; it stays because the dump is keyed on the fixture, not on timing. - maybe_dump_public_generated_wit(&config); let artifact_name = config .project_dir @@ -1084,6 +1078,9 @@ impl CompilerTest { Ok(_) => None, Err(err) => Some(Err(format_report(err))), }; + if let Some(Ok(package)) = self.package.as_ref() { + maybe_dump_public_package_wit(&self.artifact_name, package); + } } } @@ -1205,55 +1202,26 @@ fn get_workspace_dir() -> String { compiler_workspace_dir.to_string() } -/// Copies public component WIT for a Cargo test fixture when `MIDENC_EMIT_WIT[=]` is set. +/// Writes the component WIT embedded in a compiled package when `MIDENC_EMIT_WIT[=]` is set. /// -/// An empty value or `1` writes `.wit` to the current working directory. Any other -/// non-empty value is treated as the output directory. -fn maybe_dump_public_generated_wit(test: &CargoTest) { +/// An empty value or `1` writes `.wit` to the current working directory. Any other +/// non-empty value is treated as the output directory. A package without a WIT section (a fixture +/// with no `#[component]`) is skipped. +fn maybe_dump_public_package_wit(artifact_name: &str, package: &miden_mast_package::Package) { let Some(out_dir) = emit_output_dir("MIDENC_EMIT_WIT") else { return; }; - let generated_wit_dir = cargo_test_project_dir(test).join("target/generated-wit"); - let mut wit_files = match fs::read_dir(&generated_wit_dir) { - Ok(entries) => entries - .map(|entry| { - entry.unwrap_or_else(|err| { - panic!( - "failed to inspect generated WIT directory '{}': {err}", - generated_wit_dir.display() - ) - }) - }) - .map(|entry| entry.path()) - .filter(|path| path.extension().and_then(|ext| ext.to_str()) == Some("wit")) - .collect::>(), - Err(err) if err.kind() == std::io::ErrorKind::NotFound => return, - Err(err) => { - panic!( - "failed to read generated WIT directory '{}': {err}", - generated_wit_dir.display() - ) - } - }; - wit_files.sort(); - - let [wit_file] = wit_files.as_slice() else { - if wit_files.is_empty() { - return; - } - panic!( - "expected one generated WIT file in '{}', found {}", - generated_wit_dir.display(), - wit_files.len() - ); + let wit_section_id = miden_mast_package::SectionId::custom( + midenc_frontend_wasm_metadata::PACKAGE_WIT_SECTION_ID, + ) + .expect("the WIT section id must be a valid custom section id"); + let Some(section) = package.sections.iter().find(|section| section.id == wit_section_id) else { + return; }; - let out_file = out_dir.join(format!("{}.wit", sanitize_filename_component(test.name.as_ref()))); - let wit_source = fs::read(wit_file).unwrap_or_else(|err| { - panic!("failed to read generated WIT file '{}': {err}", wit_file.display()) - }); - fs::write(&out_file, wit_source).unwrap_or_else(|err| { + let out_file = out_dir.join(format!("{}.wit", sanitize_filename_component(artifact_name))); + fs::write(&out_file, section.data.as_ref()).unwrap_or_else(|err| { panic!("failed to write generated WIT to '{}': {err}", out_file.display()) }); eprintln!("wrote generated WIT to '{}'", out_file.display()); From 0bfcaabdc2536ff831673eab2b43b47062483995 Mon Sep 17 00:00:00 2001 From: Denys Zadorozhnyi Date: Fri, 7 Aug 2026 16:21:36 +0300 Subject: [PATCH 11/12] feat: populate the package cache from a contract build script in plain cargo builds Builds that midenc does not drive (cargo check, cargo build, IDE analysis) expand the SDK macros without a populated package cache, so dependency packages could not be resolved and the editor showed errors (#1215). Every contract template and example now ships a std-only build.rs. Outside a midenc-driven build it locates the project's fingerprinted cache with the new `cargo miden package-cache` subcommand, populates it with a nested `cargo miden build --release` when the project has source dependencies, and exports MIDENC_PACKAGE_CACHE to the crate's macro expansion. Inside a midenc-driven build the inherited variable short-circuits the script, which is also the recursion guard. The nested build runs against a dedicated cargo target directory because the outer cargo holds its build-dir lock while build scripts run. The subcommand prints the cache directory, the number of dependencies compiled into the cache, and the watch paths a build script must observe, backed by the new `Session::package_cache_build_inputs` API. The watch list covers the manifest closure, dependency sources, and the cache directory itself, so dependency edits, compiler updates, and cache pruning re-run the script while root-source edits do not. The template miden-project.toml manifests also gain the `[lib].path` key the VM v0.25 project model requires; without it, projects generated from the templates failed to parse in both driven builds and macro expansion. The cargo-expand dump helper now passes the session's cache path so expansion resolves the same packages the driven build used. Closes #1298 --- CHANGELOG.md | 18 + examples/auth-component-no-auth/build.rs | 121 +++++++ .../auth-component-rpo-falcon512/build.rs | 121 +++++++ examples/basic-wallet-tx-script/build.rs | 121 +++++++ examples/basic-wallet/build.rs | 121 +++++++ examples/collatz/build.rs | 121 +++++++ examples/counter-contract/build.rs | 121 +++++++ examples/counter-note/build.rs | 121 +++++++ examples/fibonacci/build.rs | 121 +++++++ examples/is-prime/build.rs | 121 +++++++ examples/p2id-note/build.rs | 121 +++++++ examples/p2ide-note/build.rs | 121 +++++++ examples/storage-example/build.rs | 121 +++++++ extra/templates/project/CLAUDE.md | 5 + extra/templates/project/README.md | 5 + .../contracts/counter-account/build.rs | 121 +++++++ .../counter-account/miden-project.toml | 1 + .../project/contracts/increment-note/build.rs | 121 +++++++ .../increment-note/miden-project.toml | 1 + .../templates/rust/account/template/build.rs | 121 +++++++ .../rust/account/template/miden-project.toml | 1 + .../rust/auth-component/template/build.rs | 121 +++++++ .../template/miden-project.toml | 1 + extra/templates/rust/note/template/build.rs | 121 +++++++ .../rust/note/template/miden-project.toml | 1 + .../templates/rust/program/template/build.rs | 121 +++++++ .../rust/tx-script/template/build.rs | 121 +++++++ .../tx-script/template/miden-project.toml | 1 + midenc-session/src/lib.rs | 53 ++- midenc-session/src/package_cache.rs | 137 +++++++- sdk/sdk/MIGRATION.md | 17 + tests/integration/src/sdk/build_script.rs | 315 ++++++++++++++++++ tests/integration/src/sdk/mod.rs | 53 +-- tests/support/src/compiler_test.rs | 22 +- tools/cargo-miden/src/cli.rs | 7 +- tools/cargo-miden/src/commands/mod.rs | 2 + .../cargo-miden/src/commands/package_cache.rs | 65 ++++ tools/cargo-miden/src/lib.rs | 4 + tools/cargo-miden/tests/mod.rs | 1 + tools/cargo-miden/tests/package_cache_cmd.rs | 92 +++++ 40 files changed, 3059 insertions(+), 42 deletions(-) create mode 100644 examples/auth-component-no-auth/build.rs create mode 100644 examples/auth-component-rpo-falcon512/build.rs create mode 100644 examples/basic-wallet-tx-script/build.rs create mode 100644 examples/basic-wallet/build.rs create mode 100644 examples/collatz/build.rs create mode 100644 examples/counter-contract/build.rs create mode 100644 examples/counter-note/build.rs create mode 100644 examples/fibonacci/build.rs create mode 100644 examples/is-prime/build.rs create mode 100644 examples/p2id-note/build.rs create mode 100644 examples/p2ide-note/build.rs create mode 100644 examples/storage-example/build.rs create mode 100644 extra/templates/project/contracts/counter-account/build.rs create mode 100644 extra/templates/project/contracts/increment-note/build.rs create mode 100644 extra/templates/rust/account/template/build.rs create mode 100644 extra/templates/rust/auth-component/template/build.rs create mode 100644 extra/templates/rust/note/template/build.rs create mode 100644 extra/templates/rust/program/template/build.rs create mode 100644 extra/templates/rust/tx-script/template/build.rs create mode 100644 tests/integration/src/sdk/build_script.rs create mode 100644 tools/cargo-miden/src/commands/package_cache.rs create mode 100644 tools/cargo-miden/tests/package_cache_cmd.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 8185f1a69..619ac7ff7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### `cargo-miden` + +- Added `cargo miden package-cache`, which prints the project's fingerprinted package-cache + directory, the number of direct dependencies resolved from that cache, and the input paths a + contract build script must watch #1298 +- Contract templates and the repository examples now include a `build.rs` that populates the + package cache for builds `midenc` does not drive: outside a midenc-driven build it locates + the cache with `cargo miden package-cache`, fills it with a nested + `cargo miden build --release` when the project has source dependencies, and exports + `MIDENC_PACKAGE_CACHE` to macro expansion. Plain `cargo check` and IDE analysis now resolve + dependency packages instead of reporting missing packages (#1215). The script uses + `cargo miden` from `PATH`, or the binary named by the `CARGO_MIDEN` environment variable + #1298 +- Fixed the contract templates' `miden-project.toml` manifests, which were missing the + `[lib].path` key the VM v0.25 project model requires; projects generated from the templates + failed both `cargo miden build` and macro expansion with "unable to parse project manifest: + missing field `path`" + ### Rust SDK - The FPI macro diagnostic for a dependency package missing from a midenc-driven build now names diff --git a/examples/auth-component-no-auth/build.rs b/examples/auth-component-no-auth/build.rs new file mode 100644 index 000000000..6c3cee049 --- /dev/null +++ b/examples/auth-component-no-auth/build.rs @@ -0,0 +1,121 @@ +//! Populates the Miden package cache for builds that `midenc` does not drive. +//! +//! Plain `cargo check`, `cargo build`, and IDE analysis expand the Miden SDK macros without a +//! surrounding `cargo miden build`. Those macros read compiled dependency packages from the +//! directory named by `MIDENC_PACKAGE_CACHE`. This script asks `cargo-miden` for that +//! directory, populates it with a nested build when the project has source dependencies, and +//! exports the variable to the compilation of this crate. +//! +//! See . + +use std::{ + env, fs, + path::{Path, PathBuf}, + process::{Command, Output}, +}; + +fn main() { + // Re-evaluate this script when the build mode or the tool selection changes. + println!("cargo:rerun-if-env-changed=MIDENC_PACKAGE_CACHE"); + println!("cargo:rerun-if-env-changed=CARGO_MIDEN"); + // These inputs shape the compiler's package-cache fingerprint. + println!("cargo:rerun-if-env-changed=RUSTFLAGS"); + println!("cargo:rerun-if-env-changed=RUSTUP_TOOLCHAIN"); + + // Inside a midenc-driven build the compiler owns the package cache, macro expansion + // already sees the variable, and a nested build would recurse into this script forever. + if env::var_os("MIDENC_PACKAGE_CACHE").is_some() { + return; + } + + let manifest_dir = PathBuf::from(env::var_os("CARGO_MANIFEST_DIR").unwrap()); + + // Ask the compiler where this project's package cache lives and which inputs shape it. + let query = run_cargo_miden(&manifest_dir, "package-cache"); + if !query.status.success() { + panic!( + "`cargo miden package-cache` failed ({}).\nInstall cargo-miden (`cargo install \ + cargo-miden`) or point the CARGO_MIDEN environment variable at a cargo-miden \ + binary.\n--- stderr ---\n{}", + query.status, + String::from_utf8_lossy(&query.stderr), + ); + } + + let stdout = + String::from_utf8(query.stdout).expect("cargo miden package-cache output is UTF-8"); + let mut cache_dir = None; + let mut source_dependencies = 0usize; + for line in stdout.lines() { + if let Some(value) = line.strip_prefix("cache-dir=") { + cache_dir = Some(PathBuf::from(value)); + } else if let Some(value) = line.strip_prefix("source-dependencies=") { + source_dependencies = value.parse().expect("source-dependencies is a number"); + } else if let Some(value) = line.strip_prefix("watch=") { + println!("cargo:rerun-if-changed={value}"); + } + } + let cache_dir = cache_dir.expect("cargo miden package-cache printed no cache-dir"); + + if source_dependencies > 0 { + // Populate the cache. Dependency packages publish before the root target compiles, so + // even a failing build (for example, this crate is mid-edit) usually leaves the + // dependency packages usable; the macros report anything that is genuinely missing. + let build = run_cargo_miden(&manifest_dir, "build"); + if !build.status.success() { + println!( + "cargo:warning=`cargo miden build --release` failed ({}); dependency packages \ + may be stale or missing: {}", + build.status, + last_stderr_line(&build.stderr), + ); + } + } + + // The macros treat a missing directory as an empty cache; create it so the exported + // variable always points at a real location. Watching the directory re-runs this script + // when another build rewrites or prunes the cache (cargo re-runs unconditionally while a + // watched path is missing), which keeps the exported path and its packages live. + fs::create_dir_all(&cache_dir).expect("failed to create the Miden package cache directory"); + println!("cargo:rerun-if-changed={}", cache_dir.display()); + println!("cargo:rustc-env=MIDENC_PACKAGE_CACHE={}", cache_dir.display()); +} + +/// Runs `cargo miden --release` for the project in `manifest_dir`. +/// +/// `CARGO_MIDEN` selects a specific `cargo-miden` binary; otherwise the `cargo miden` plugin +/// is resolved through the `cargo` that drives this build. The nested build gets its own +/// cargo target directory: the outer cargo holds a lock on this build's target directory +/// while build scripts run, and a nested build against the same directory would deadlock. +/// `--release` keeps the cache fingerprint identical to a `cargo miden build --release` run +/// by hand, so both share one cache. +fn run_cargo_miden(manifest_dir: &Path, subcommand: &str) -> Output { + let mut command = match env::var_os("CARGO_MIDEN") { + Some(cargo_miden) => Command::new(cargo_miden), + None => Command::new(env::var_os("CARGO").unwrap_or_else(|| "cargo".into())), + }; + command + .args(["miden", subcommand, "--release"]) + .current_dir(manifest_dir) + .env( + "CARGO_TARGET_DIR", + manifest_dir.join("target").join("miden").join("build-script"), + ); + command.output().unwrap_or_else(|err| { + panic!( + "failed to run `cargo miden {subcommand}`: {err}.\nInstall cargo-miden (`cargo \ + install cargo-miden`) or point the CARGO_MIDEN environment variable at a \ + cargo-miden binary." + ) + }) +} + +/// Returns the last non-empty stderr line for a compact warning. +fn last_stderr_line(stderr: &[u8]) -> String { + String::from_utf8_lossy(stderr) + .lines() + .rev() + .find(|line| !line.trim().is_empty()) + .unwrap_or("no error output") + .to_string() +} diff --git a/examples/auth-component-rpo-falcon512/build.rs b/examples/auth-component-rpo-falcon512/build.rs new file mode 100644 index 000000000..6c3cee049 --- /dev/null +++ b/examples/auth-component-rpo-falcon512/build.rs @@ -0,0 +1,121 @@ +//! Populates the Miden package cache for builds that `midenc` does not drive. +//! +//! Plain `cargo check`, `cargo build`, and IDE analysis expand the Miden SDK macros without a +//! surrounding `cargo miden build`. Those macros read compiled dependency packages from the +//! directory named by `MIDENC_PACKAGE_CACHE`. This script asks `cargo-miden` for that +//! directory, populates it with a nested build when the project has source dependencies, and +//! exports the variable to the compilation of this crate. +//! +//! See . + +use std::{ + env, fs, + path::{Path, PathBuf}, + process::{Command, Output}, +}; + +fn main() { + // Re-evaluate this script when the build mode or the tool selection changes. + println!("cargo:rerun-if-env-changed=MIDENC_PACKAGE_CACHE"); + println!("cargo:rerun-if-env-changed=CARGO_MIDEN"); + // These inputs shape the compiler's package-cache fingerprint. + println!("cargo:rerun-if-env-changed=RUSTFLAGS"); + println!("cargo:rerun-if-env-changed=RUSTUP_TOOLCHAIN"); + + // Inside a midenc-driven build the compiler owns the package cache, macro expansion + // already sees the variable, and a nested build would recurse into this script forever. + if env::var_os("MIDENC_PACKAGE_CACHE").is_some() { + return; + } + + let manifest_dir = PathBuf::from(env::var_os("CARGO_MANIFEST_DIR").unwrap()); + + // Ask the compiler where this project's package cache lives and which inputs shape it. + let query = run_cargo_miden(&manifest_dir, "package-cache"); + if !query.status.success() { + panic!( + "`cargo miden package-cache` failed ({}).\nInstall cargo-miden (`cargo install \ + cargo-miden`) or point the CARGO_MIDEN environment variable at a cargo-miden \ + binary.\n--- stderr ---\n{}", + query.status, + String::from_utf8_lossy(&query.stderr), + ); + } + + let stdout = + String::from_utf8(query.stdout).expect("cargo miden package-cache output is UTF-8"); + let mut cache_dir = None; + let mut source_dependencies = 0usize; + for line in stdout.lines() { + if let Some(value) = line.strip_prefix("cache-dir=") { + cache_dir = Some(PathBuf::from(value)); + } else if let Some(value) = line.strip_prefix("source-dependencies=") { + source_dependencies = value.parse().expect("source-dependencies is a number"); + } else if let Some(value) = line.strip_prefix("watch=") { + println!("cargo:rerun-if-changed={value}"); + } + } + let cache_dir = cache_dir.expect("cargo miden package-cache printed no cache-dir"); + + if source_dependencies > 0 { + // Populate the cache. Dependency packages publish before the root target compiles, so + // even a failing build (for example, this crate is mid-edit) usually leaves the + // dependency packages usable; the macros report anything that is genuinely missing. + let build = run_cargo_miden(&manifest_dir, "build"); + if !build.status.success() { + println!( + "cargo:warning=`cargo miden build --release` failed ({}); dependency packages \ + may be stale or missing: {}", + build.status, + last_stderr_line(&build.stderr), + ); + } + } + + // The macros treat a missing directory as an empty cache; create it so the exported + // variable always points at a real location. Watching the directory re-runs this script + // when another build rewrites or prunes the cache (cargo re-runs unconditionally while a + // watched path is missing), which keeps the exported path and its packages live. + fs::create_dir_all(&cache_dir).expect("failed to create the Miden package cache directory"); + println!("cargo:rerun-if-changed={}", cache_dir.display()); + println!("cargo:rustc-env=MIDENC_PACKAGE_CACHE={}", cache_dir.display()); +} + +/// Runs `cargo miden --release` for the project in `manifest_dir`. +/// +/// `CARGO_MIDEN` selects a specific `cargo-miden` binary; otherwise the `cargo miden` plugin +/// is resolved through the `cargo` that drives this build. The nested build gets its own +/// cargo target directory: the outer cargo holds a lock on this build's target directory +/// while build scripts run, and a nested build against the same directory would deadlock. +/// `--release` keeps the cache fingerprint identical to a `cargo miden build --release` run +/// by hand, so both share one cache. +fn run_cargo_miden(manifest_dir: &Path, subcommand: &str) -> Output { + let mut command = match env::var_os("CARGO_MIDEN") { + Some(cargo_miden) => Command::new(cargo_miden), + None => Command::new(env::var_os("CARGO").unwrap_or_else(|| "cargo".into())), + }; + command + .args(["miden", subcommand, "--release"]) + .current_dir(manifest_dir) + .env( + "CARGO_TARGET_DIR", + manifest_dir.join("target").join("miden").join("build-script"), + ); + command.output().unwrap_or_else(|err| { + panic!( + "failed to run `cargo miden {subcommand}`: {err}.\nInstall cargo-miden (`cargo \ + install cargo-miden`) or point the CARGO_MIDEN environment variable at a \ + cargo-miden binary." + ) + }) +} + +/// Returns the last non-empty stderr line for a compact warning. +fn last_stderr_line(stderr: &[u8]) -> String { + String::from_utf8_lossy(stderr) + .lines() + .rev() + .find(|line| !line.trim().is_empty()) + .unwrap_or("no error output") + .to_string() +} diff --git a/examples/basic-wallet-tx-script/build.rs b/examples/basic-wallet-tx-script/build.rs new file mode 100644 index 000000000..6c3cee049 --- /dev/null +++ b/examples/basic-wallet-tx-script/build.rs @@ -0,0 +1,121 @@ +//! Populates the Miden package cache for builds that `midenc` does not drive. +//! +//! Plain `cargo check`, `cargo build`, and IDE analysis expand the Miden SDK macros without a +//! surrounding `cargo miden build`. Those macros read compiled dependency packages from the +//! directory named by `MIDENC_PACKAGE_CACHE`. This script asks `cargo-miden` for that +//! directory, populates it with a nested build when the project has source dependencies, and +//! exports the variable to the compilation of this crate. +//! +//! See . + +use std::{ + env, fs, + path::{Path, PathBuf}, + process::{Command, Output}, +}; + +fn main() { + // Re-evaluate this script when the build mode or the tool selection changes. + println!("cargo:rerun-if-env-changed=MIDENC_PACKAGE_CACHE"); + println!("cargo:rerun-if-env-changed=CARGO_MIDEN"); + // These inputs shape the compiler's package-cache fingerprint. + println!("cargo:rerun-if-env-changed=RUSTFLAGS"); + println!("cargo:rerun-if-env-changed=RUSTUP_TOOLCHAIN"); + + // Inside a midenc-driven build the compiler owns the package cache, macro expansion + // already sees the variable, and a nested build would recurse into this script forever. + if env::var_os("MIDENC_PACKAGE_CACHE").is_some() { + return; + } + + let manifest_dir = PathBuf::from(env::var_os("CARGO_MANIFEST_DIR").unwrap()); + + // Ask the compiler where this project's package cache lives and which inputs shape it. + let query = run_cargo_miden(&manifest_dir, "package-cache"); + if !query.status.success() { + panic!( + "`cargo miden package-cache` failed ({}).\nInstall cargo-miden (`cargo install \ + cargo-miden`) or point the CARGO_MIDEN environment variable at a cargo-miden \ + binary.\n--- stderr ---\n{}", + query.status, + String::from_utf8_lossy(&query.stderr), + ); + } + + let stdout = + String::from_utf8(query.stdout).expect("cargo miden package-cache output is UTF-8"); + let mut cache_dir = None; + let mut source_dependencies = 0usize; + for line in stdout.lines() { + if let Some(value) = line.strip_prefix("cache-dir=") { + cache_dir = Some(PathBuf::from(value)); + } else if let Some(value) = line.strip_prefix("source-dependencies=") { + source_dependencies = value.parse().expect("source-dependencies is a number"); + } else if let Some(value) = line.strip_prefix("watch=") { + println!("cargo:rerun-if-changed={value}"); + } + } + let cache_dir = cache_dir.expect("cargo miden package-cache printed no cache-dir"); + + if source_dependencies > 0 { + // Populate the cache. Dependency packages publish before the root target compiles, so + // even a failing build (for example, this crate is mid-edit) usually leaves the + // dependency packages usable; the macros report anything that is genuinely missing. + let build = run_cargo_miden(&manifest_dir, "build"); + if !build.status.success() { + println!( + "cargo:warning=`cargo miden build --release` failed ({}); dependency packages \ + may be stale or missing: {}", + build.status, + last_stderr_line(&build.stderr), + ); + } + } + + // The macros treat a missing directory as an empty cache; create it so the exported + // variable always points at a real location. Watching the directory re-runs this script + // when another build rewrites or prunes the cache (cargo re-runs unconditionally while a + // watched path is missing), which keeps the exported path and its packages live. + fs::create_dir_all(&cache_dir).expect("failed to create the Miden package cache directory"); + println!("cargo:rerun-if-changed={}", cache_dir.display()); + println!("cargo:rustc-env=MIDENC_PACKAGE_CACHE={}", cache_dir.display()); +} + +/// Runs `cargo miden --release` for the project in `manifest_dir`. +/// +/// `CARGO_MIDEN` selects a specific `cargo-miden` binary; otherwise the `cargo miden` plugin +/// is resolved through the `cargo` that drives this build. The nested build gets its own +/// cargo target directory: the outer cargo holds a lock on this build's target directory +/// while build scripts run, and a nested build against the same directory would deadlock. +/// `--release` keeps the cache fingerprint identical to a `cargo miden build --release` run +/// by hand, so both share one cache. +fn run_cargo_miden(manifest_dir: &Path, subcommand: &str) -> Output { + let mut command = match env::var_os("CARGO_MIDEN") { + Some(cargo_miden) => Command::new(cargo_miden), + None => Command::new(env::var_os("CARGO").unwrap_or_else(|| "cargo".into())), + }; + command + .args(["miden", subcommand, "--release"]) + .current_dir(manifest_dir) + .env( + "CARGO_TARGET_DIR", + manifest_dir.join("target").join("miden").join("build-script"), + ); + command.output().unwrap_or_else(|err| { + panic!( + "failed to run `cargo miden {subcommand}`: {err}.\nInstall cargo-miden (`cargo \ + install cargo-miden`) or point the CARGO_MIDEN environment variable at a \ + cargo-miden binary." + ) + }) +} + +/// Returns the last non-empty stderr line for a compact warning. +fn last_stderr_line(stderr: &[u8]) -> String { + String::from_utf8_lossy(stderr) + .lines() + .rev() + .find(|line| !line.trim().is_empty()) + .unwrap_or("no error output") + .to_string() +} diff --git a/examples/basic-wallet/build.rs b/examples/basic-wallet/build.rs new file mode 100644 index 000000000..6c3cee049 --- /dev/null +++ b/examples/basic-wallet/build.rs @@ -0,0 +1,121 @@ +//! Populates the Miden package cache for builds that `midenc` does not drive. +//! +//! Plain `cargo check`, `cargo build`, and IDE analysis expand the Miden SDK macros without a +//! surrounding `cargo miden build`. Those macros read compiled dependency packages from the +//! directory named by `MIDENC_PACKAGE_CACHE`. This script asks `cargo-miden` for that +//! directory, populates it with a nested build when the project has source dependencies, and +//! exports the variable to the compilation of this crate. +//! +//! See . + +use std::{ + env, fs, + path::{Path, PathBuf}, + process::{Command, Output}, +}; + +fn main() { + // Re-evaluate this script when the build mode or the tool selection changes. + println!("cargo:rerun-if-env-changed=MIDENC_PACKAGE_CACHE"); + println!("cargo:rerun-if-env-changed=CARGO_MIDEN"); + // These inputs shape the compiler's package-cache fingerprint. + println!("cargo:rerun-if-env-changed=RUSTFLAGS"); + println!("cargo:rerun-if-env-changed=RUSTUP_TOOLCHAIN"); + + // Inside a midenc-driven build the compiler owns the package cache, macro expansion + // already sees the variable, and a nested build would recurse into this script forever. + if env::var_os("MIDENC_PACKAGE_CACHE").is_some() { + return; + } + + let manifest_dir = PathBuf::from(env::var_os("CARGO_MANIFEST_DIR").unwrap()); + + // Ask the compiler where this project's package cache lives and which inputs shape it. + let query = run_cargo_miden(&manifest_dir, "package-cache"); + if !query.status.success() { + panic!( + "`cargo miden package-cache` failed ({}).\nInstall cargo-miden (`cargo install \ + cargo-miden`) or point the CARGO_MIDEN environment variable at a cargo-miden \ + binary.\n--- stderr ---\n{}", + query.status, + String::from_utf8_lossy(&query.stderr), + ); + } + + let stdout = + String::from_utf8(query.stdout).expect("cargo miden package-cache output is UTF-8"); + let mut cache_dir = None; + let mut source_dependencies = 0usize; + for line in stdout.lines() { + if let Some(value) = line.strip_prefix("cache-dir=") { + cache_dir = Some(PathBuf::from(value)); + } else if let Some(value) = line.strip_prefix("source-dependencies=") { + source_dependencies = value.parse().expect("source-dependencies is a number"); + } else if let Some(value) = line.strip_prefix("watch=") { + println!("cargo:rerun-if-changed={value}"); + } + } + let cache_dir = cache_dir.expect("cargo miden package-cache printed no cache-dir"); + + if source_dependencies > 0 { + // Populate the cache. Dependency packages publish before the root target compiles, so + // even a failing build (for example, this crate is mid-edit) usually leaves the + // dependency packages usable; the macros report anything that is genuinely missing. + let build = run_cargo_miden(&manifest_dir, "build"); + if !build.status.success() { + println!( + "cargo:warning=`cargo miden build --release` failed ({}); dependency packages \ + may be stale or missing: {}", + build.status, + last_stderr_line(&build.stderr), + ); + } + } + + // The macros treat a missing directory as an empty cache; create it so the exported + // variable always points at a real location. Watching the directory re-runs this script + // when another build rewrites or prunes the cache (cargo re-runs unconditionally while a + // watched path is missing), which keeps the exported path and its packages live. + fs::create_dir_all(&cache_dir).expect("failed to create the Miden package cache directory"); + println!("cargo:rerun-if-changed={}", cache_dir.display()); + println!("cargo:rustc-env=MIDENC_PACKAGE_CACHE={}", cache_dir.display()); +} + +/// Runs `cargo miden --release` for the project in `manifest_dir`. +/// +/// `CARGO_MIDEN` selects a specific `cargo-miden` binary; otherwise the `cargo miden` plugin +/// is resolved through the `cargo` that drives this build. The nested build gets its own +/// cargo target directory: the outer cargo holds a lock on this build's target directory +/// while build scripts run, and a nested build against the same directory would deadlock. +/// `--release` keeps the cache fingerprint identical to a `cargo miden build --release` run +/// by hand, so both share one cache. +fn run_cargo_miden(manifest_dir: &Path, subcommand: &str) -> Output { + let mut command = match env::var_os("CARGO_MIDEN") { + Some(cargo_miden) => Command::new(cargo_miden), + None => Command::new(env::var_os("CARGO").unwrap_or_else(|| "cargo".into())), + }; + command + .args(["miden", subcommand, "--release"]) + .current_dir(manifest_dir) + .env( + "CARGO_TARGET_DIR", + manifest_dir.join("target").join("miden").join("build-script"), + ); + command.output().unwrap_or_else(|err| { + panic!( + "failed to run `cargo miden {subcommand}`: {err}.\nInstall cargo-miden (`cargo \ + install cargo-miden`) or point the CARGO_MIDEN environment variable at a \ + cargo-miden binary." + ) + }) +} + +/// Returns the last non-empty stderr line for a compact warning. +fn last_stderr_line(stderr: &[u8]) -> String { + String::from_utf8_lossy(stderr) + .lines() + .rev() + .find(|line| !line.trim().is_empty()) + .unwrap_or("no error output") + .to_string() +} diff --git a/examples/collatz/build.rs b/examples/collatz/build.rs new file mode 100644 index 000000000..6c3cee049 --- /dev/null +++ b/examples/collatz/build.rs @@ -0,0 +1,121 @@ +//! Populates the Miden package cache for builds that `midenc` does not drive. +//! +//! Plain `cargo check`, `cargo build`, and IDE analysis expand the Miden SDK macros without a +//! surrounding `cargo miden build`. Those macros read compiled dependency packages from the +//! directory named by `MIDENC_PACKAGE_CACHE`. This script asks `cargo-miden` for that +//! directory, populates it with a nested build when the project has source dependencies, and +//! exports the variable to the compilation of this crate. +//! +//! See . + +use std::{ + env, fs, + path::{Path, PathBuf}, + process::{Command, Output}, +}; + +fn main() { + // Re-evaluate this script when the build mode or the tool selection changes. + println!("cargo:rerun-if-env-changed=MIDENC_PACKAGE_CACHE"); + println!("cargo:rerun-if-env-changed=CARGO_MIDEN"); + // These inputs shape the compiler's package-cache fingerprint. + println!("cargo:rerun-if-env-changed=RUSTFLAGS"); + println!("cargo:rerun-if-env-changed=RUSTUP_TOOLCHAIN"); + + // Inside a midenc-driven build the compiler owns the package cache, macro expansion + // already sees the variable, and a nested build would recurse into this script forever. + if env::var_os("MIDENC_PACKAGE_CACHE").is_some() { + return; + } + + let manifest_dir = PathBuf::from(env::var_os("CARGO_MANIFEST_DIR").unwrap()); + + // Ask the compiler where this project's package cache lives and which inputs shape it. + let query = run_cargo_miden(&manifest_dir, "package-cache"); + if !query.status.success() { + panic!( + "`cargo miden package-cache` failed ({}).\nInstall cargo-miden (`cargo install \ + cargo-miden`) or point the CARGO_MIDEN environment variable at a cargo-miden \ + binary.\n--- stderr ---\n{}", + query.status, + String::from_utf8_lossy(&query.stderr), + ); + } + + let stdout = + String::from_utf8(query.stdout).expect("cargo miden package-cache output is UTF-8"); + let mut cache_dir = None; + let mut source_dependencies = 0usize; + for line in stdout.lines() { + if let Some(value) = line.strip_prefix("cache-dir=") { + cache_dir = Some(PathBuf::from(value)); + } else if let Some(value) = line.strip_prefix("source-dependencies=") { + source_dependencies = value.parse().expect("source-dependencies is a number"); + } else if let Some(value) = line.strip_prefix("watch=") { + println!("cargo:rerun-if-changed={value}"); + } + } + let cache_dir = cache_dir.expect("cargo miden package-cache printed no cache-dir"); + + if source_dependencies > 0 { + // Populate the cache. Dependency packages publish before the root target compiles, so + // even a failing build (for example, this crate is mid-edit) usually leaves the + // dependency packages usable; the macros report anything that is genuinely missing. + let build = run_cargo_miden(&manifest_dir, "build"); + if !build.status.success() { + println!( + "cargo:warning=`cargo miden build --release` failed ({}); dependency packages \ + may be stale or missing: {}", + build.status, + last_stderr_line(&build.stderr), + ); + } + } + + // The macros treat a missing directory as an empty cache; create it so the exported + // variable always points at a real location. Watching the directory re-runs this script + // when another build rewrites or prunes the cache (cargo re-runs unconditionally while a + // watched path is missing), which keeps the exported path and its packages live. + fs::create_dir_all(&cache_dir).expect("failed to create the Miden package cache directory"); + println!("cargo:rerun-if-changed={}", cache_dir.display()); + println!("cargo:rustc-env=MIDENC_PACKAGE_CACHE={}", cache_dir.display()); +} + +/// Runs `cargo miden --release` for the project in `manifest_dir`. +/// +/// `CARGO_MIDEN` selects a specific `cargo-miden` binary; otherwise the `cargo miden` plugin +/// is resolved through the `cargo` that drives this build. The nested build gets its own +/// cargo target directory: the outer cargo holds a lock on this build's target directory +/// while build scripts run, and a nested build against the same directory would deadlock. +/// `--release` keeps the cache fingerprint identical to a `cargo miden build --release` run +/// by hand, so both share one cache. +fn run_cargo_miden(manifest_dir: &Path, subcommand: &str) -> Output { + let mut command = match env::var_os("CARGO_MIDEN") { + Some(cargo_miden) => Command::new(cargo_miden), + None => Command::new(env::var_os("CARGO").unwrap_or_else(|| "cargo".into())), + }; + command + .args(["miden", subcommand, "--release"]) + .current_dir(manifest_dir) + .env( + "CARGO_TARGET_DIR", + manifest_dir.join("target").join("miden").join("build-script"), + ); + command.output().unwrap_or_else(|err| { + panic!( + "failed to run `cargo miden {subcommand}`: {err}.\nInstall cargo-miden (`cargo \ + install cargo-miden`) or point the CARGO_MIDEN environment variable at a \ + cargo-miden binary." + ) + }) +} + +/// Returns the last non-empty stderr line for a compact warning. +fn last_stderr_line(stderr: &[u8]) -> String { + String::from_utf8_lossy(stderr) + .lines() + .rev() + .find(|line| !line.trim().is_empty()) + .unwrap_or("no error output") + .to_string() +} diff --git a/examples/counter-contract/build.rs b/examples/counter-contract/build.rs new file mode 100644 index 000000000..6c3cee049 --- /dev/null +++ b/examples/counter-contract/build.rs @@ -0,0 +1,121 @@ +//! Populates the Miden package cache for builds that `midenc` does not drive. +//! +//! Plain `cargo check`, `cargo build`, and IDE analysis expand the Miden SDK macros without a +//! surrounding `cargo miden build`. Those macros read compiled dependency packages from the +//! directory named by `MIDENC_PACKAGE_CACHE`. This script asks `cargo-miden` for that +//! directory, populates it with a nested build when the project has source dependencies, and +//! exports the variable to the compilation of this crate. +//! +//! See . + +use std::{ + env, fs, + path::{Path, PathBuf}, + process::{Command, Output}, +}; + +fn main() { + // Re-evaluate this script when the build mode or the tool selection changes. + println!("cargo:rerun-if-env-changed=MIDENC_PACKAGE_CACHE"); + println!("cargo:rerun-if-env-changed=CARGO_MIDEN"); + // These inputs shape the compiler's package-cache fingerprint. + println!("cargo:rerun-if-env-changed=RUSTFLAGS"); + println!("cargo:rerun-if-env-changed=RUSTUP_TOOLCHAIN"); + + // Inside a midenc-driven build the compiler owns the package cache, macro expansion + // already sees the variable, and a nested build would recurse into this script forever. + if env::var_os("MIDENC_PACKAGE_CACHE").is_some() { + return; + } + + let manifest_dir = PathBuf::from(env::var_os("CARGO_MANIFEST_DIR").unwrap()); + + // Ask the compiler where this project's package cache lives and which inputs shape it. + let query = run_cargo_miden(&manifest_dir, "package-cache"); + if !query.status.success() { + panic!( + "`cargo miden package-cache` failed ({}).\nInstall cargo-miden (`cargo install \ + cargo-miden`) or point the CARGO_MIDEN environment variable at a cargo-miden \ + binary.\n--- stderr ---\n{}", + query.status, + String::from_utf8_lossy(&query.stderr), + ); + } + + let stdout = + String::from_utf8(query.stdout).expect("cargo miden package-cache output is UTF-8"); + let mut cache_dir = None; + let mut source_dependencies = 0usize; + for line in stdout.lines() { + if let Some(value) = line.strip_prefix("cache-dir=") { + cache_dir = Some(PathBuf::from(value)); + } else if let Some(value) = line.strip_prefix("source-dependencies=") { + source_dependencies = value.parse().expect("source-dependencies is a number"); + } else if let Some(value) = line.strip_prefix("watch=") { + println!("cargo:rerun-if-changed={value}"); + } + } + let cache_dir = cache_dir.expect("cargo miden package-cache printed no cache-dir"); + + if source_dependencies > 0 { + // Populate the cache. Dependency packages publish before the root target compiles, so + // even a failing build (for example, this crate is mid-edit) usually leaves the + // dependency packages usable; the macros report anything that is genuinely missing. + let build = run_cargo_miden(&manifest_dir, "build"); + if !build.status.success() { + println!( + "cargo:warning=`cargo miden build --release` failed ({}); dependency packages \ + may be stale or missing: {}", + build.status, + last_stderr_line(&build.stderr), + ); + } + } + + // The macros treat a missing directory as an empty cache; create it so the exported + // variable always points at a real location. Watching the directory re-runs this script + // when another build rewrites or prunes the cache (cargo re-runs unconditionally while a + // watched path is missing), which keeps the exported path and its packages live. + fs::create_dir_all(&cache_dir).expect("failed to create the Miden package cache directory"); + println!("cargo:rerun-if-changed={}", cache_dir.display()); + println!("cargo:rustc-env=MIDENC_PACKAGE_CACHE={}", cache_dir.display()); +} + +/// Runs `cargo miden --release` for the project in `manifest_dir`. +/// +/// `CARGO_MIDEN` selects a specific `cargo-miden` binary; otherwise the `cargo miden` plugin +/// is resolved through the `cargo` that drives this build. The nested build gets its own +/// cargo target directory: the outer cargo holds a lock on this build's target directory +/// while build scripts run, and a nested build against the same directory would deadlock. +/// `--release` keeps the cache fingerprint identical to a `cargo miden build --release` run +/// by hand, so both share one cache. +fn run_cargo_miden(manifest_dir: &Path, subcommand: &str) -> Output { + let mut command = match env::var_os("CARGO_MIDEN") { + Some(cargo_miden) => Command::new(cargo_miden), + None => Command::new(env::var_os("CARGO").unwrap_or_else(|| "cargo".into())), + }; + command + .args(["miden", subcommand, "--release"]) + .current_dir(manifest_dir) + .env( + "CARGO_TARGET_DIR", + manifest_dir.join("target").join("miden").join("build-script"), + ); + command.output().unwrap_or_else(|err| { + panic!( + "failed to run `cargo miden {subcommand}`: {err}.\nInstall cargo-miden (`cargo \ + install cargo-miden`) or point the CARGO_MIDEN environment variable at a \ + cargo-miden binary." + ) + }) +} + +/// Returns the last non-empty stderr line for a compact warning. +fn last_stderr_line(stderr: &[u8]) -> String { + String::from_utf8_lossy(stderr) + .lines() + .rev() + .find(|line| !line.trim().is_empty()) + .unwrap_or("no error output") + .to_string() +} diff --git a/examples/counter-note/build.rs b/examples/counter-note/build.rs new file mode 100644 index 000000000..6c3cee049 --- /dev/null +++ b/examples/counter-note/build.rs @@ -0,0 +1,121 @@ +//! Populates the Miden package cache for builds that `midenc` does not drive. +//! +//! Plain `cargo check`, `cargo build`, and IDE analysis expand the Miden SDK macros without a +//! surrounding `cargo miden build`. Those macros read compiled dependency packages from the +//! directory named by `MIDENC_PACKAGE_CACHE`. This script asks `cargo-miden` for that +//! directory, populates it with a nested build when the project has source dependencies, and +//! exports the variable to the compilation of this crate. +//! +//! See . + +use std::{ + env, fs, + path::{Path, PathBuf}, + process::{Command, Output}, +}; + +fn main() { + // Re-evaluate this script when the build mode or the tool selection changes. + println!("cargo:rerun-if-env-changed=MIDENC_PACKAGE_CACHE"); + println!("cargo:rerun-if-env-changed=CARGO_MIDEN"); + // These inputs shape the compiler's package-cache fingerprint. + println!("cargo:rerun-if-env-changed=RUSTFLAGS"); + println!("cargo:rerun-if-env-changed=RUSTUP_TOOLCHAIN"); + + // Inside a midenc-driven build the compiler owns the package cache, macro expansion + // already sees the variable, and a nested build would recurse into this script forever. + if env::var_os("MIDENC_PACKAGE_CACHE").is_some() { + return; + } + + let manifest_dir = PathBuf::from(env::var_os("CARGO_MANIFEST_DIR").unwrap()); + + // Ask the compiler where this project's package cache lives and which inputs shape it. + let query = run_cargo_miden(&manifest_dir, "package-cache"); + if !query.status.success() { + panic!( + "`cargo miden package-cache` failed ({}).\nInstall cargo-miden (`cargo install \ + cargo-miden`) or point the CARGO_MIDEN environment variable at a cargo-miden \ + binary.\n--- stderr ---\n{}", + query.status, + String::from_utf8_lossy(&query.stderr), + ); + } + + let stdout = + String::from_utf8(query.stdout).expect("cargo miden package-cache output is UTF-8"); + let mut cache_dir = None; + let mut source_dependencies = 0usize; + for line in stdout.lines() { + if let Some(value) = line.strip_prefix("cache-dir=") { + cache_dir = Some(PathBuf::from(value)); + } else if let Some(value) = line.strip_prefix("source-dependencies=") { + source_dependencies = value.parse().expect("source-dependencies is a number"); + } else if let Some(value) = line.strip_prefix("watch=") { + println!("cargo:rerun-if-changed={value}"); + } + } + let cache_dir = cache_dir.expect("cargo miden package-cache printed no cache-dir"); + + if source_dependencies > 0 { + // Populate the cache. Dependency packages publish before the root target compiles, so + // even a failing build (for example, this crate is mid-edit) usually leaves the + // dependency packages usable; the macros report anything that is genuinely missing. + let build = run_cargo_miden(&manifest_dir, "build"); + if !build.status.success() { + println!( + "cargo:warning=`cargo miden build --release` failed ({}); dependency packages \ + may be stale or missing: {}", + build.status, + last_stderr_line(&build.stderr), + ); + } + } + + // The macros treat a missing directory as an empty cache; create it so the exported + // variable always points at a real location. Watching the directory re-runs this script + // when another build rewrites or prunes the cache (cargo re-runs unconditionally while a + // watched path is missing), which keeps the exported path and its packages live. + fs::create_dir_all(&cache_dir).expect("failed to create the Miden package cache directory"); + println!("cargo:rerun-if-changed={}", cache_dir.display()); + println!("cargo:rustc-env=MIDENC_PACKAGE_CACHE={}", cache_dir.display()); +} + +/// Runs `cargo miden --release` for the project in `manifest_dir`. +/// +/// `CARGO_MIDEN` selects a specific `cargo-miden` binary; otherwise the `cargo miden` plugin +/// is resolved through the `cargo` that drives this build. The nested build gets its own +/// cargo target directory: the outer cargo holds a lock on this build's target directory +/// while build scripts run, and a nested build against the same directory would deadlock. +/// `--release` keeps the cache fingerprint identical to a `cargo miden build --release` run +/// by hand, so both share one cache. +fn run_cargo_miden(manifest_dir: &Path, subcommand: &str) -> Output { + let mut command = match env::var_os("CARGO_MIDEN") { + Some(cargo_miden) => Command::new(cargo_miden), + None => Command::new(env::var_os("CARGO").unwrap_or_else(|| "cargo".into())), + }; + command + .args(["miden", subcommand, "--release"]) + .current_dir(manifest_dir) + .env( + "CARGO_TARGET_DIR", + manifest_dir.join("target").join("miden").join("build-script"), + ); + command.output().unwrap_or_else(|err| { + panic!( + "failed to run `cargo miden {subcommand}`: {err}.\nInstall cargo-miden (`cargo \ + install cargo-miden`) or point the CARGO_MIDEN environment variable at a \ + cargo-miden binary." + ) + }) +} + +/// Returns the last non-empty stderr line for a compact warning. +fn last_stderr_line(stderr: &[u8]) -> String { + String::from_utf8_lossy(stderr) + .lines() + .rev() + .find(|line| !line.trim().is_empty()) + .unwrap_or("no error output") + .to_string() +} diff --git a/examples/fibonacci/build.rs b/examples/fibonacci/build.rs new file mode 100644 index 000000000..6c3cee049 --- /dev/null +++ b/examples/fibonacci/build.rs @@ -0,0 +1,121 @@ +//! Populates the Miden package cache for builds that `midenc` does not drive. +//! +//! Plain `cargo check`, `cargo build`, and IDE analysis expand the Miden SDK macros without a +//! surrounding `cargo miden build`. Those macros read compiled dependency packages from the +//! directory named by `MIDENC_PACKAGE_CACHE`. This script asks `cargo-miden` for that +//! directory, populates it with a nested build when the project has source dependencies, and +//! exports the variable to the compilation of this crate. +//! +//! See . + +use std::{ + env, fs, + path::{Path, PathBuf}, + process::{Command, Output}, +}; + +fn main() { + // Re-evaluate this script when the build mode or the tool selection changes. + println!("cargo:rerun-if-env-changed=MIDENC_PACKAGE_CACHE"); + println!("cargo:rerun-if-env-changed=CARGO_MIDEN"); + // These inputs shape the compiler's package-cache fingerprint. + println!("cargo:rerun-if-env-changed=RUSTFLAGS"); + println!("cargo:rerun-if-env-changed=RUSTUP_TOOLCHAIN"); + + // Inside a midenc-driven build the compiler owns the package cache, macro expansion + // already sees the variable, and a nested build would recurse into this script forever. + if env::var_os("MIDENC_PACKAGE_CACHE").is_some() { + return; + } + + let manifest_dir = PathBuf::from(env::var_os("CARGO_MANIFEST_DIR").unwrap()); + + // Ask the compiler where this project's package cache lives and which inputs shape it. + let query = run_cargo_miden(&manifest_dir, "package-cache"); + if !query.status.success() { + panic!( + "`cargo miden package-cache` failed ({}).\nInstall cargo-miden (`cargo install \ + cargo-miden`) or point the CARGO_MIDEN environment variable at a cargo-miden \ + binary.\n--- stderr ---\n{}", + query.status, + String::from_utf8_lossy(&query.stderr), + ); + } + + let stdout = + String::from_utf8(query.stdout).expect("cargo miden package-cache output is UTF-8"); + let mut cache_dir = None; + let mut source_dependencies = 0usize; + for line in stdout.lines() { + if let Some(value) = line.strip_prefix("cache-dir=") { + cache_dir = Some(PathBuf::from(value)); + } else if let Some(value) = line.strip_prefix("source-dependencies=") { + source_dependencies = value.parse().expect("source-dependencies is a number"); + } else if let Some(value) = line.strip_prefix("watch=") { + println!("cargo:rerun-if-changed={value}"); + } + } + let cache_dir = cache_dir.expect("cargo miden package-cache printed no cache-dir"); + + if source_dependencies > 0 { + // Populate the cache. Dependency packages publish before the root target compiles, so + // even a failing build (for example, this crate is mid-edit) usually leaves the + // dependency packages usable; the macros report anything that is genuinely missing. + let build = run_cargo_miden(&manifest_dir, "build"); + if !build.status.success() { + println!( + "cargo:warning=`cargo miden build --release` failed ({}); dependency packages \ + may be stale or missing: {}", + build.status, + last_stderr_line(&build.stderr), + ); + } + } + + // The macros treat a missing directory as an empty cache; create it so the exported + // variable always points at a real location. Watching the directory re-runs this script + // when another build rewrites or prunes the cache (cargo re-runs unconditionally while a + // watched path is missing), which keeps the exported path and its packages live. + fs::create_dir_all(&cache_dir).expect("failed to create the Miden package cache directory"); + println!("cargo:rerun-if-changed={}", cache_dir.display()); + println!("cargo:rustc-env=MIDENC_PACKAGE_CACHE={}", cache_dir.display()); +} + +/// Runs `cargo miden --release` for the project in `manifest_dir`. +/// +/// `CARGO_MIDEN` selects a specific `cargo-miden` binary; otherwise the `cargo miden` plugin +/// is resolved through the `cargo` that drives this build. The nested build gets its own +/// cargo target directory: the outer cargo holds a lock on this build's target directory +/// while build scripts run, and a nested build against the same directory would deadlock. +/// `--release` keeps the cache fingerprint identical to a `cargo miden build --release` run +/// by hand, so both share one cache. +fn run_cargo_miden(manifest_dir: &Path, subcommand: &str) -> Output { + let mut command = match env::var_os("CARGO_MIDEN") { + Some(cargo_miden) => Command::new(cargo_miden), + None => Command::new(env::var_os("CARGO").unwrap_or_else(|| "cargo".into())), + }; + command + .args(["miden", subcommand, "--release"]) + .current_dir(manifest_dir) + .env( + "CARGO_TARGET_DIR", + manifest_dir.join("target").join("miden").join("build-script"), + ); + command.output().unwrap_or_else(|err| { + panic!( + "failed to run `cargo miden {subcommand}`: {err}.\nInstall cargo-miden (`cargo \ + install cargo-miden`) or point the CARGO_MIDEN environment variable at a \ + cargo-miden binary." + ) + }) +} + +/// Returns the last non-empty stderr line for a compact warning. +fn last_stderr_line(stderr: &[u8]) -> String { + String::from_utf8_lossy(stderr) + .lines() + .rev() + .find(|line| !line.trim().is_empty()) + .unwrap_or("no error output") + .to_string() +} diff --git a/examples/is-prime/build.rs b/examples/is-prime/build.rs new file mode 100644 index 000000000..6c3cee049 --- /dev/null +++ b/examples/is-prime/build.rs @@ -0,0 +1,121 @@ +//! Populates the Miden package cache for builds that `midenc` does not drive. +//! +//! Plain `cargo check`, `cargo build`, and IDE analysis expand the Miden SDK macros without a +//! surrounding `cargo miden build`. Those macros read compiled dependency packages from the +//! directory named by `MIDENC_PACKAGE_CACHE`. This script asks `cargo-miden` for that +//! directory, populates it with a nested build when the project has source dependencies, and +//! exports the variable to the compilation of this crate. +//! +//! See . + +use std::{ + env, fs, + path::{Path, PathBuf}, + process::{Command, Output}, +}; + +fn main() { + // Re-evaluate this script when the build mode or the tool selection changes. + println!("cargo:rerun-if-env-changed=MIDENC_PACKAGE_CACHE"); + println!("cargo:rerun-if-env-changed=CARGO_MIDEN"); + // These inputs shape the compiler's package-cache fingerprint. + println!("cargo:rerun-if-env-changed=RUSTFLAGS"); + println!("cargo:rerun-if-env-changed=RUSTUP_TOOLCHAIN"); + + // Inside a midenc-driven build the compiler owns the package cache, macro expansion + // already sees the variable, and a nested build would recurse into this script forever. + if env::var_os("MIDENC_PACKAGE_CACHE").is_some() { + return; + } + + let manifest_dir = PathBuf::from(env::var_os("CARGO_MANIFEST_DIR").unwrap()); + + // Ask the compiler where this project's package cache lives and which inputs shape it. + let query = run_cargo_miden(&manifest_dir, "package-cache"); + if !query.status.success() { + panic!( + "`cargo miden package-cache` failed ({}).\nInstall cargo-miden (`cargo install \ + cargo-miden`) or point the CARGO_MIDEN environment variable at a cargo-miden \ + binary.\n--- stderr ---\n{}", + query.status, + String::from_utf8_lossy(&query.stderr), + ); + } + + let stdout = + String::from_utf8(query.stdout).expect("cargo miden package-cache output is UTF-8"); + let mut cache_dir = None; + let mut source_dependencies = 0usize; + for line in stdout.lines() { + if let Some(value) = line.strip_prefix("cache-dir=") { + cache_dir = Some(PathBuf::from(value)); + } else if let Some(value) = line.strip_prefix("source-dependencies=") { + source_dependencies = value.parse().expect("source-dependencies is a number"); + } else if let Some(value) = line.strip_prefix("watch=") { + println!("cargo:rerun-if-changed={value}"); + } + } + let cache_dir = cache_dir.expect("cargo miden package-cache printed no cache-dir"); + + if source_dependencies > 0 { + // Populate the cache. Dependency packages publish before the root target compiles, so + // even a failing build (for example, this crate is mid-edit) usually leaves the + // dependency packages usable; the macros report anything that is genuinely missing. + let build = run_cargo_miden(&manifest_dir, "build"); + if !build.status.success() { + println!( + "cargo:warning=`cargo miden build --release` failed ({}); dependency packages \ + may be stale or missing: {}", + build.status, + last_stderr_line(&build.stderr), + ); + } + } + + // The macros treat a missing directory as an empty cache; create it so the exported + // variable always points at a real location. Watching the directory re-runs this script + // when another build rewrites or prunes the cache (cargo re-runs unconditionally while a + // watched path is missing), which keeps the exported path and its packages live. + fs::create_dir_all(&cache_dir).expect("failed to create the Miden package cache directory"); + println!("cargo:rerun-if-changed={}", cache_dir.display()); + println!("cargo:rustc-env=MIDENC_PACKAGE_CACHE={}", cache_dir.display()); +} + +/// Runs `cargo miden --release` for the project in `manifest_dir`. +/// +/// `CARGO_MIDEN` selects a specific `cargo-miden` binary; otherwise the `cargo miden` plugin +/// is resolved through the `cargo` that drives this build. The nested build gets its own +/// cargo target directory: the outer cargo holds a lock on this build's target directory +/// while build scripts run, and a nested build against the same directory would deadlock. +/// `--release` keeps the cache fingerprint identical to a `cargo miden build --release` run +/// by hand, so both share one cache. +fn run_cargo_miden(manifest_dir: &Path, subcommand: &str) -> Output { + let mut command = match env::var_os("CARGO_MIDEN") { + Some(cargo_miden) => Command::new(cargo_miden), + None => Command::new(env::var_os("CARGO").unwrap_or_else(|| "cargo".into())), + }; + command + .args(["miden", subcommand, "--release"]) + .current_dir(manifest_dir) + .env( + "CARGO_TARGET_DIR", + manifest_dir.join("target").join("miden").join("build-script"), + ); + command.output().unwrap_or_else(|err| { + panic!( + "failed to run `cargo miden {subcommand}`: {err}.\nInstall cargo-miden (`cargo \ + install cargo-miden`) or point the CARGO_MIDEN environment variable at a \ + cargo-miden binary." + ) + }) +} + +/// Returns the last non-empty stderr line for a compact warning. +fn last_stderr_line(stderr: &[u8]) -> String { + String::from_utf8_lossy(stderr) + .lines() + .rev() + .find(|line| !line.trim().is_empty()) + .unwrap_or("no error output") + .to_string() +} diff --git a/examples/p2id-note/build.rs b/examples/p2id-note/build.rs new file mode 100644 index 000000000..6c3cee049 --- /dev/null +++ b/examples/p2id-note/build.rs @@ -0,0 +1,121 @@ +//! Populates the Miden package cache for builds that `midenc` does not drive. +//! +//! Plain `cargo check`, `cargo build`, and IDE analysis expand the Miden SDK macros without a +//! surrounding `cargo miden build`. Those macros read compiled dependency packages from the +//! directory named by `MIDENC_PACKAGE_CACHE`. This script asks `cargo-miden` for that +//! directory, populates it with a nested build when the project has source dependencies, and +//! exports the variable to the compilation of this crate. +//! +//! See . + +use std::{ + env, fs, + path::{Path, PathBuf}, + process::{Command, Output}, +}; + +fn main() { + // Re-evaluate this script when the build mode or the tool selection changes. + println!("cargo:rerun-if-env-changed=MIDENC_PACKAGE_CACHE"); + println!("cargo:rerun-if-env-changed=CARGO_MIDEN"); + // These inputs shape the compiler's package-cache fingerprint. + println!("cargo:rerun-if-env-changed=RUSTFLAGS"); + println!("cargo:rerun-if-env-changed=RUSTUP_TOOLCHAIN"); + + // Inside a midenc-driven build the compiler owns the package cache, macro expansion + // already sees the variable, and a nested build would recurse into this script forever. + if env::var_os("MIDENC_PACKAGE_CACHE").is_some() { + return; + } + + let manifest_dir = PathBuf::from(env::var_os("CARGO_MANIFEST_DIR").unwrap()); + + // Ask the compiler where this project's package cache lives and which inputs shape it. + let query = run_cargo_miden(&manifest_dir, "package-cache"); + if !query.status.success() { + panic!( + "`cargo miden package-cache` failed ({}).\nInstall cargo-miden (`cargo install \ + cargo-miden`) or point the CARGO_MIDEN environment variable at a cargo-miden \ + binary.\n--- stderr ---\n{}", + query.status, + String::from_utf8_lossy(&query.stderr), + ); + } + + let stdout = + String::from_utf8(query.stdout).expect("cargo miden package-cache output is UTF-8"); + let mut cache_dir = None; + let mut source_dependencies = 0usize; + for line in stdout.lines() { + if let Some(value) = line.strip_prefix("cache-dir=") { + cache_dir = Some(PathBuf::from(value)); + } else if let Some(value) = line.strip_prefix("source-dependencies=") { + source_dependencies = value.parse().expect("source-dependencies is a number"); + } else if let Some(value) = line.strip_prefix("watch=") { + println!("cargo:rerun-if-changed={value}"); + } + } + let cache_dir = cache_dir.expect("cargo miden package-cache printed no cache-dir"); + + if source_dependencies > 0 { + // Populate the cache. Dependency packages publish before the root target compiles, so + // even a failing build (for example, this crate is mid-edit) usually leaves the + // dependency packages usable; the macros report anything that is genuinely missing. + let build = run_cargo_miden(&manifest_dir, "build"); + if !build.status.success() { + println!( + "cargo:warning=`cargo miden build --release` failed ({}); dependency packages \ + may be stale or missing: {}", + build.status, + last_stderr_line(&build.stderr), + ); + } + } + + // The macros treat a missing directory as an empty cache; create it so the exported + // variable always points at a real location. Watching the directory re-runs this script + // when another build rewrites or prunes the cache (cargo re-runs unconditionally while a + // watched path is missing), which keeps the exported path and its packages live. + fs::create_dir_all(&cache_dir).expect("failed to create the Miden package cache directory"); + println!("cargo:rerun-if-changed={}", cache_dir.display()); + println!("cargo:rustc-env=MIDENC_PACKAGE_CACHE={}", cache_dir.display()); +} + +/// Runs `cargo miden --release` for the project in `manifest_dir`. +/// +/// `CARGO_MIDEN` selects a specific `cargo-miden` binary; otherwise the `cargo miden` plugin +/// is resolved through the `cargo` that drives this build. The nested build gets its own +/// cargo target directory: the outer cargo holds a lock on this build's target directory +/// while build scripts run, and a nested build against the same directory would deadlock. +/// `--release` keeps the cache fingerprint identical to a `cargo miden build --release` run +/// by hand, so both share one cache. +fn run_cargo_miden(manifest_dir: &Path, subcommand: &str) -> Output { + let mut command = match env::var_os("CARGO_MIDEN") { + Some(cargo_miden) => Command::new(cargo_miden), + None => Command::new(env::var_os("CARGO").unwrap_or_else(|| "cargo".into())), + }; + command + .args(["miden", subcommand, "--release"]) + .current_dir(manifest_dir) + .env( + "CARGO_TARGET_DIR", + manifest_dir.join("target").join("miden").join("build-script"), + ); + command.output().unwrap_or_else(|err| { + panic!( + "failed to run `cargo miden {subcommand}`: {err}.\nInstall cargo-miden (`cargo \ + install cargo-miden`) or point the CARGO_MIDEN environment variable at a \ + cargo-miden binary." + ) + }) +} + +/// Returns the last non-empty stderr line for a compact warning. +fn last_stderr_line(stderr: &[u8]) -> String { + String::from_utf8_lossy(stderr) + .lines() + .rev() + .find(|line| !line.trim().is_empty()) + .unwrap_or("no error output") + .to_string() +} diff --git a/examples/p2ide-note/build.rs b/examples/p2ide-note/build.rs new file mode 100644 index 000000000..6c3cee049 --- /dev/null +++ b/examples/p2ide-note/build.rs @@ -0,0 +1,121 @@ +//! Populates the Miden package cache for builds that `midenc` does not drive. +//! +//! Plain `cargo check`, `cargo build`, and IDE analysis expand the Miden SDK macros without a +//! surrounding `cargo miden build`. Those macros read compiled dependency packages from the +//! directory named by `MIDENC_PACKAGE_CACHE`. This script asks `cargo-miden` for that +//! directory, populates it with a nested build when the project has source dependencies, and +//! exports the variable to the compilation of this crate. +//! +//! See . + +use std::{ + env, fs, + path::{Path, PathBuf}, + process::{Command, Output}, +}; + +fn main() { + // Re-evaluate this script when the build mode or the tool selection changes. + println!("cargo:rerun-if-env-changed=MIDENC_PACKAGE_CACHE"); + println!("cargo:rerun-if-env-changed=CARGO_MIDEN"); + // These inputs shape the compiler's package-cache fingerprint. + println!("cargo:rerun-if-env-changed=RUSTFLAGS"); + println!("cargo:rerun-if-env-changed=RUSTUP_TOOLCHAIN"); + + // Inside a midenc-driven build the compiler owns the package cache, macro expansion + // already sees the variable, and a nested build would recurse into this script forever. + if env::var_os("MIDENC_PACKAGE_CACHE").is_some() { + return; + } + + let manifest_dir = PathBuf::from(env::var_os("CARGO_MANIFEST_DIR").unwrap()); + + // Ask the compiler where this project's package cache lives and which inputs shape it. + let query = run_cargo_miden(&manifest_dir, "package-cache"); + if !query.status.success() { + panic!( + "`cargo miden package-cache` failed ({}).\nInstall cargo-miden (`cargo install \ + cargo-miden`) or point the CARGO_MIDEN environment variable at a cargo-miden \ + binary.\n--- stderr ---\n{}", + query.status, + String::from_utf8_lossy(&query.stderr), + ); + } + + let stdout = + String::from_utf8(query.stdout).expect("cargo miden package-cache output is UTF-8"); + let mut cache_dir = None; + let mut source_dependencies = 0usize; + for line in stdout.lines() { + if let Some(value) = line.strip_prefix("cache-dir=") { + cache_dir = Some(PathBuf::from(value)); + } else if let Some(value) = line.strip_prefix("source-dependencies=") { + source_dependencies = value.parse().expect("source-dependencies is a number"); + } else if let Some(value) = line.strip_prefix("watch=") { + println!("cargo:rerun-if-changed={value}"); + } + } + let cache_dir = cache_dir.expect("cargo miden package-cache printed no cache-dir"); + + if source_dependencies > 0 { + // Populate the cache. Dependency packages publish before the root target compiles, so + // even a failing build (for example, this crate is mid-edit) usually leaves the + // dependency packages usable; the macros report anything that is genuinely missing. + let build = run_cargo_miden(&manifest_dir, "build"); + if !build.status.success() { + println!( + "cargo:warning=`cargo miden build --release` failed ({}); dependency packages \ + may be stale or missing: {}", + build.status, + last_stderr_line(&build.stderr), + ); + } + } + + // The macros treat a missing directory as an empty cache; create it so the exported + // variable always points at a real location. Watching the directory re-runs this script + // when another build rewrites or prunes the cache (cargo re-runs unconditionally while a + // watched path is missing), which keeps the exported path and its packages live. + fs::create_dir_all(&cache_dir).expect("failed to create the Miden package cache directory"); + println!("cargo:rerun-if-changed={}", cache_dir.display()); + println!("cargo:rustc-env=MIDENC_PACKAGE_CACHE={}", cache_dir.display()); +} + +/// Runs `cargo miden --release` for the project in `manifest_dir`. +/// +/// `CARGO_MIDEN` selects a specific `cargo-miden` binary; otherwise the `cargo miden` plugin +/// is resolved through the `cargo` that drives this build. The nested build gets its own +/// cargo target directory: the outer cargo holds a lock on this build's target directory +/// while build scripts run, and a nested build against the same directory would deadlock. +/// `--release` keeps the cache fingerprint identical to a `cargo miden build --release` run +/// by hand, so both share one cache. +fn run_cargo_miden(manifest_dir: &Path, subcommand: &str) -> Output { + let mut command = match env::var_os("CARGO_MIDEN") { + Some(cargo_miden) => Command::new(cargo_miden), + None => Command::new(env::var_os("CARGO").unwrap_or_else(|| "cargo".into())), + }; + command + .args(["miden", subcommand, "--release"]) + .current_dir(manifest_dir) + .env( + "CARGO_TARGET_DIR", + manifest_dir.join("target").join("miden").join("build-script"), + ); + command.output().unwrap_or_else(|err| { + panic!( + "failed to run `cargo miden {subcommand}`: {err}.\nInstall cargo-miden (`cargo \ + install cargo-miden`) or point the CARGO_MIDEN environment variable at a \ + cargo-miden binary." + ) + }) +} + +/// Returns the last non-empty stderr line for a compact warning. +fn last_stderr_line(stderr: &[u8]) -> String { + String::from_utf8_lossy(stderr) + .lines() + .rev() + .find(|line| !line.trim().is_empty()) + .unwrap_or("no error output") + .to_string() +} diff --git a/examples/storage-example/build.rs b/examples/storage-example/build.rs new file mode 100644 index 000000000..6c3cee049 --- /dev/null +++ b/examples/storage-example/build.rs @@ -0,0 +1,121 @@ +//! Populates the Miden package cache for builds that `midenc` does not drive. +//! +//! Plain `cargo check`, `cargo build`, and IDE analysis expand the Miden SDK macros without a +//! surrounding `cargo miden build`. Those macros read compiled dependency packages from the +//! directory named by `MIDENC_PACKAGE_CACHE`. This script asks `cargo-miden` for that +//! directory, populates it with a nested build when the project has source dependencies, and +//! exports the variable to the compilation of this crate. +//! +//! See . + +use std::{ + env, fs, + path::{Path, PathBuf}, + process::{Command, Output}, +}; + +fn main() { + // Re-evaluate this script when the build mode or the tool selection changes. + println!("cargo:rerun-if-env-changed=MIDENC_PACKAGE_CACHE"); + println!("cargo:rerun-if-env-changed=CARGO_MIDEN"); + // These inputs shape the compiler's package-cache fingerprint. + println!("cargo:rerun-if-env-changed=RUSTFLAGS"); + println!("cargo:rerun-if-env-changed=RUSTUP_TOOLCHAIN"); + + // Inside a midenc-driven build the compiler owns the package cache, macro expansion + // already sees the variable, and a nested build would recurse into this script forever. + if env::var_os("MIDENC_PACKAGE_CACHE").is_some() { + return; + } + + let manifest_dir = PathBuf::from(env::var_os("CARGO_MANIFEST_DIR").unwrap()); + + // Ask the compiler where this project's package cache lives and which inputs shape it. + let query = run_cargo_miden(&manifest_dir, "package-cache"); + if !query.status.success() { + panic!( + "`cargo miden package-cache` failed ({}).\nInstall cargo-miden (`cargo install \ + cargo-miden`) or point the CARGO_MIDEN environment variable at a cargo-miden \ + binary.\n--- stderr ---\n{}", + query.status, + String::from_utf8_lossy(&query.stderr), + ); + } + + let stdout = + String::from_utf8(query.stdout).expect("cargo miden package-cache output is UTF-8"); + let mut cache_dir = None; + let mut source_dependencies = 0usize; + for line in stdout.lines() { + if let Some(value) = line.strip_prefix("cache-dir=") { + cache_dir = Some(PathBuf::from(value)); + } else if let Some(value) = line.strip_prefix("source-dependencies=") { + source_dependencies = value.parse().expect("source-dependencies is a number"); + } else if let Some(value) = line.strip_prefix("watch=") { + println!("cargo:rerun-if-changed={value}"); + } + } + let cache_dir = cache_dir.expect("cargo miden package-cache printed no cache-dir"); + + if source_dependencies > 0 { + // Populate the cache. Dependency packages publish before the root target compiles, so + // even a failing build (for example, this crate is mid-edit) usually leaves the + // dependency packages usable; the macros report anything that is genuinely missing. + let build = run_cargo_miden(&manifest_dir, "build"); + if !build.status.success() { + println!( + "cargo:warning=`cargo miden build --release` failed ({}); dependency packages \ + may be stale or missing: {}", + build.status, + last_stderr_line(&build.stderr), + ); + } + } + + // The macros treat a missing directory as an empty cache; create it so the exported + // variable always points at a real location. Watching the directory re-runs this script + // when another build rewrites or prunes the cache (cargo re-runs unconditionally while a + // watched path is missing), which keeps the exported path and its packages live. + fs::create_dir_all(&cache_dir).expect("failed to create the Miden package cache directory"); + println!("cargo:rerun-if-changed={}", cache_dir.display()); + println!("cargo:rustc-env=MIDENC_PACKAGE_CACHE={}", cache_dir.display()); +} + +/// Runs `cargo miden --release` for the project in `manifest_dir`. +/// +/// `CARGO_MIDEN` selects a specific `cargo-miden` binary; otherwise the `cargo miden` plugin +/// is resolved through the `cargo` that drives this build. The nested build gets its own +/// cargo target directory: the outer cargo holds a lock on this build's target directory +/// while build scripts run, and a nested build against the same directory would deadlock. +/// `--release` keeps the cache fingerprint identical to a `cargo miden build --release` run +/// by hand, so both share one cache. +fn run_cargo_miden(manifest_dir: &Path, subcommand: &str) -> Output { + let mut command = match env::var_os("CARGO_MIDEN") { + Some(cargo_miden) => Command::new(cargo_miden), + None => Command::new(env::var_os("CARGO").unwrap_or_else(|| "cargo".into())), + }; + command + .args(["miden", subcommand, "--release"]) + .current_dir(manifest_dir) + .env( + "CARGO_TARGET_DIR", + manifest_dir.join("target").join("miden").join("build-script"), + ); + command.output().unwrap_or_else(|err| { + panic!( + "failed to run `cargo miden {subcommand}`: {err}.\nInstall cargo-miden (`cargo \ + install cargo-miden`) or point the CARGO_MIDEN environment variable at a \ + cargo-miden binary." + ) + }) +} + +/// Returns the last non-empty stderr line for a compact warning. +fn last_stderr_line(stderr: &[u8]) -> String { + String::from_utf8_lossy(stderr) + .lines() + .rev() + .find(|line| !line.trim().is_empty()) + .unwrap_or("no error output") + .to_string() +} diff --git a/extra/templates/project/CLAUDE.md b/extra/templates/project/CLAUDE.md index e20175059..b92fbcbd1 100644 --- a/extra/templates/project/CLAUDE.md +++ b/extra/templates/project/CLAUDE.md @@ -17,6 +17,11 @@ Contracts are built individually with cargo-miden (not `cargo build`): cargo miden build --manifest-path contracts//Cargo.toml --release ``` +Each contract has a `build.rs` that populates the Miden package cache, so plain `cargo check` +and IDE analysis resolve dependency packages without a manual `cargo miden build` first. The +script needs `cargo miden` on `PATH` (or a binary named by the `CARGO_MIDEN` environment +variable). + Tests run via the workspace: ``` cargo test -p integration --release diff --git a/extra/templates/project/README.md b/extra/templates/project/README.md index 7d1cedde2..b11ff3db5 100644 --- a/extra/templates/project/README.md +++ b/extra/templates/project/README.md @@ -92,6 +92,11 @@ cd contracts/counter-account miden build ``` +Each contract also has a `build.rs` that keeps plain `cargo check` and IDE analysis working: +it populates the Miden package cache with the contract's compiled dependencies, so the SDK +macros resolve them without a manual build. The script needs `cargo miden` on `PATH` (or a +binary named by the `CARGO_MIDEN` environment variable). + ### Run a Binary ```bash diff --git a/extra/templates/project/contracts/counter-account/build.rs b/extra/templates/project/contracts/counter-account/build.rs new file mode 100644 index 000000000..6c3cee049 --- /dev/null +++ b/extra/templates/project/contracts/counter-account/build.rs @@ -0,0 +1,121 @@ +//! Populates the Miden package cache for builds that `midenc` does not drive. +//! +//! Plain `cargo check`, `cargo build`, and IDE analysis expand the Miden SDK macros without a +//! surrounding `cargo miden build`. Those macros read compiled dependency packages from the +//! directory named by `MIDENC_PACKAGE_CACHE`. This script asks `cargo-miden` for that +//! directory, populates it with a nested build when the project has source dependencies, and +//! exports the variable to the compilation of this crate. +//! +//! See . + +use std::{ + env, fs, + path::{Path, PathBuf}, + process::{Command, Output}, +}; + +fn main() { + // Re-evaluate this script when the build mode or the tool selection changes. + println!("cargo:rerun-if-env-changed=MIDENC_PACKAGE_CACHE"); + println!("cargo:rerun-if-env-changed=CARGO_MIDEN"); + // These inputs shape the compiler's package-cache fingerprint. + println!("cargo:rerun-if-env-changed=RUSTFLAGS"); + println!("cargo:rerun-if-env-changed=RUSTUP_TOOLCHAIN"); + + // Inside a midenc-driven build the compiler owns the package cache, macro expansion + // already sees the variable, and a nested build would recurse into this script forever. + if env::var_os("MIDENC_PACKAGE_CACHE").is_some() { + return; + } + + let manifest_dir = PathBuf::from(env::var_os("CARGO_MANIFEST_DIR").unwrap()); + + // Ask the compiler where this project's package cache lives and which inputs shape it. + let query = run_cargo_miden(&manifest_dir, "package-cache"); + if !query.status.success() { + panic!( + "`cargo miden package-cache` failed ({}).\nInstall cargo-miden (`cargo install \ + cargo-miden`) or point the CARGO_MIDEN environment variable at a cargo-miden \ + binary.\n--- stderr ---\n{}", + query.status, + String::from_utf8_lossy(&query.stderr), + ); + } + + let stdout = + String::from_utf8(query.stdout).expect("cargo miden package-cache output is UTF-8"); + let mut cache_dir = None; + let mut source_dependencies = 0usize; + for line in stdout.lines() { + if let Some(value) = line.strip_prefix("cache-dir=") { + cache_dir = Some(PathBuf::from(value)); + } else if let Some(value) = line.strip_prefix("source-dependencies=") { + source_dependencies = value.parse().expect("source-dependencies is a number"); + } else if let Some(value) = line.strip_prefix("watch=") { + println!("cargo:rerun-if-changed={value}"); + } + } + let cache_dir = cache_dir.expect("cargo miden package-cache printed no cache-dir"); + + if source_dependencies > 0 { + // Populate the cache. Dependency packages publish before the root target compiles, so + // even a failing build (for example, this crate is mid-edit) usually leaves the + // dependency packages usable; the macros report anything that is genuinely missing. + let build = run_cargo_miden(&manifest_dir, "build"); + if !build.status.success() { + println!( + "cargo:warning=`cargo miden build --release` failed ({}); dependency packages \ + may be stale or missing: {}", + build.status, + last_stderr_line(&build.stderr), + ); + } + } + + // The macros treat a missing directory as an empty cache; create it so the exported + // variable always points at a real location. Watching the directory re-runs this script + // when another build rewrites or prunes the cache (cargo re-runs unconditionally while a + // watched path is missing), which keeps the exported path and its packages live. + fs::create_dir_all(&cache_dir).expect("failed to create the Miden package cache directory"); + println!("cargo:rerun-if-changed={}", cache_dir.display()); + println!("cargo:rustc-env=MIDENC_PACKAGE_CACHE={}", cache_dir.display()); +} + +/// Runs `cargo miden --release` for the project in `manifest_dir`. +/// +/// `CARGO_MIDEN` selects a specific `cargo-miden` binary; otherwise the `cargo miden` plugin +/// is resolved through the `cargo` that drives this build. The nested build gets its own +/// cargo target directory: the outer cargo holds a lock on this build's target directory +/// while build scripts run, and a nested build against the same directory would deadlock. +/// `--release` keeps the cache fingerprint identical to a `cargo miden build --release` run +/// by hand, so both share one cache. +fn run_cargo_miden(manifest_dir: &Path, subcommand: &str) -> Output { + let mut command = match env::var_os("CARGO_MIDEN") { + Some(cargo_miden) => Command::new(cargo_miden), + None => Command::new(env::var_os("CARGO").unwrap_or_else(|| "cargo".into())), + }; + command + .args(["miden", subcommand, "--release"]) + .current_dir(manifest_dir) + .env( + "CARGO_TARGET_DIR", + manifest_dir.join("target").join("miden").join("build-script"), + ); + command.output().unwrap_or_else(|err| { + panic!( + "failed to run `cargo miden {subcommand}`: {err}.\nInstall cargo-miden (`cargo \ + install cargo-miden`) or point the CARGO_MIDEN environment variable at a \ + cargo-miden binary." + ) + }) +} + +/// Returns the last non-empty stderr line for a compact warning. +fn last_stderr_line(stderr: &[u8]) -> String { + String::from_utf8_lossy(stderr) + .lines() + .rev() + .find(|line| !line.trim().is_empty()) + .unwrap_or("no error output") + .to_string() +} diff --git a/extra/templates/project/contracts/counter-account/miden-project.toml b/extra/templates/project/contracts/counter-account/miden-project.toml index c36d01aa6..42f65950a 100644 --- a/extra/templates/project/contracts/counter-account/miden-project.toml +++ b/extra/templates/project/contracts/counter-account/miden-project.toml @@ -7,6 +7,7 @@ kind = "account-component" # Full `miden:/@` id. The interface segment is the # kebab-cased component trait name (`CounterContract` -> `counter-contract`). namespace = "miden:counter-account/counter-contract@0.1.0" +path = "src/lib.rs" [dependencies] miden-core = "*" diff --git a/extra/templates/project/contracts/increment-note/build.rs b/extra/templates/project/contracts/increment-note/build.rs new file mode 100644 index 000000000..6c3cee049 --- /dev/null +++ b/extra/templates/project/contracts/increment-note/build.rs @@ -0,0 +1,121 @@ +//! Populates the Miden package cache for builds that `midenc` does not drive. +//! +//! Plain `cargo check`, `cargo build`, and IDE analysis expand the Miden SDK macros without a +//! surrounding `cargo miden build`. Those macros read compiled dependency packages from the +//! directory named by `MIDENC_PACKAGE_CACHE`. This script asks `cargo-miden` for that +//! directory, populates it with a nested build when the project has source dependencies, and +//! exports the variable to the compilation of this crate. +//! +//! See . + +use std::{ + env, fs, + path::{Path, PathBuf}, + process::{Command, Output}, +}; + +fn main() { + // Re-evaluate this script when the build mode or the tool selection changes. + println!("cargo:rerun-if-env-changed=MIDENC_PACKAGE_CACHE"); + println!("cargo:rerun-if-env-changed=CARGO_MIDEN"); + // These inputs shape the compiler's package-cache fingerprint. + println!("cargo:rerun-if-env-changed=RUSTFLAGS"); + println!("cargo:rerun-if-env-changed=RUSTUP_TOOLCHAIN"); + + // Inside a midenc-driven build the compiler owns the package cache, macro expansion + // already sees the variable, and a nested build would recurse into this script forever. + if env::var_os("MIDENC_PACKAGE_CACHE").is_some() { + return; + } + + let manifest_dir = PathBuf::from(env::var_os("CARGO_MANIFEST_DIR").unwrap()); + + // Ask the compiler where this project's package cache lives and which inputs shape it. + let query = run_cargo_miden(&manifest_dir, "package-cache"); + if !query.status.success() { + panic!( + "`cargo miden package-cache` failed ({}).\nInstall cargo-miden (`cargo install \ + cargo-miden`) or point the CARGO_MIDEN environment variable at a cargo-miden \ + binary.\n--- stderr ---\n{}", + query.status, + String::from_utf8_lossy(&query.stderr), + ); + } + + let stdout = + String::from_utf8(query.stdout).expect("cargo miden package-cache output is UTF-8"); + let mut cache_dir = None; + let mut source_dependencies = 0usize; + for line in stdout.lines() { + if let Some(value) = line.strip_prefix("cache-dir=") { + cache_dir = Some(PathBuf::from(value)); + } else if let Some(value) = line.strip_prefix("source-dependencies=") { + source_dependencies = value.parse().expect("source-dependencies is a number"); + } else if let Some(value) = line.strip_prefix("watch=") { + println!("cargo:rerun-if-changed={value}"); + } + } + let cache_dir = cache_dir.expect("cargo miden package-cache printed no cache-dir"); + + if source_dependencies > 0 { + // Populate the cache. Dependency packages publish before the root target compiles, so + // even a failing build (for example, this crate is mid-edit) usually leaves the + // dependency packages usable; the macros report anything that is genuinely missing. + let build = run_cargo_miden(&manifest_dir, "build"); + if !build.status.success() { + println!( + "cargo:warning=`cargo miden build --release` failed ({}); dependency packages \ + may be stale or missing: {}", + build.status, + last_stderr_line(&build.stderr), + ); + } + } + + // The macros treat a missing directory as an empty cache; create it so the exported + // variable always points at a real location. Watching the directory re-runs this script + // when another build rewrites or prunes the cache (cargo re-runs unconditionally while a + // watched path is missing), which keeps the exported path and its packages live. + fs::create_dir_all(&cache_dir).expect("failed to create the Miden package cache directory"); + println!("cargo:rerun-if-changed={}", cache_dir.display()); + println!("cargo:rustc-env=MIDENC_PACKAGE_CACHE={}", cache_dir.display()); +} + +/// Runs `cargo miden --release` for the project in `manifest_dir`. +/// +/// `CARGO_MIDEN` selects a specific `cargo-miden` binary; otherwise the `cargo miden` plugin +/// is resolved through the `cargo` that drives this build. The nested build gets its own +/// cargo target directory: the outer cargo holds a lock on this build's target directory +/// while build scripts run, and a nested build against the same directory would deadlock. +/// `--release` keeps the cache fingerprint identical to a `cargo miden build --release` run +/// by hand, so both share one cache. +fn run_cargo_miden(manifest_dir: &Path, subcommand: &str) -> Output { + let mut command = match env::var_os("CARGO_MIDEN") { + Some(cargo_miden) => Command::new(cargo_miden), + None => Command::new(env::var_os("CARGO").unwrap_or_else(|| "cargo".into())), + }; + command + .args(["miden", subcommand, "--release"]) + .current_dir(manifest_dir) + .env( + "CARGO_TARGET_DIR", + manifest_dir.join("target").join("miden").join("build-script"), + ); + command.output().unwrap_or_else(|err| { + panic!( + "failed to run `cargo miden {subcommand}`: {err}.\nInstall cargo-miden (`cargo \ + install cargo-miden`) or point the CARGO_MIDEN environment variable at a \ + cargo-miden binary." + ) + }) +} + +/// Returns the last non-empty stderr line for a compact warning. +fn last_stderr_line(stderr: &[u8]) -> String { + String::from_utf8_lossy(stderr) + .lines() + .rev() + .find(|line| !line.trim().is_empty()) + .unwrap_or("no error output") + .to_string() +} diff --git a/extra/templates/project/contracts/increment-note/miden-project.toml b/extra/templates/project/contracts/increment-note/miden-project.toml index 0b811fd4b..28277c928 100644 --- a/extra/templates/project/contracts/increment-note/miden-project.toml +++ b/extra/templates/project/contracts/increment-note/miden-project.toml @@ -6,6 +6,7 @@ version = "0.1.0" kind = "note" # Notes export a package-derived interface (`miden-`), matching the `#[note]` macro. namespace = "miden:increment-note/miden-increment-note@0.1.0" +path = "src/lib.rs" [dependencies] miden-core = "*" diff --git a/extra/templates/rust/account/template/build.rs b/extra/templates/rust/account/template/build.rs new file mode 100644 index 000000000..6c3cee049 --- /dev/null +++ b/extra/templates/rust/account/template/build.rs @@ -0,0 +1,121 @@ +//! Populates the Miden package cache for builds that `midenc` does not drive. +//! +//! Plain `cargo check`, `cargo build`, and IDE analysis expand the Miden SDK macros without a +//! surrounding `cargo miden build`. Those macros read compiled dependency packages from the +//! directory named by `MIDENC_PACKAGE_CACHE`. This script asks `cargo-miden` for that +//! directory, populates it with a nested build when the project has source dependencies, and +//! exports the variable to the compilation of this crate. +//! +//! See . + +use std::{ + env, fs, + path::{Path, PathBuf}, + process::{Command, Output}, +}; + +fn main() { + // Re-evaluate this script when the build mode or the tool selection changes. + println!("cargo:rerun-if-env-changed=MIDENC_PACKAGE_CACHE"); + println!("cargo:rerun-if-env-changed=CARGO_MIDEN"); + // These inputs shape the compiler's package-cache fingerprint. + println!("cargo:rerun-if-env-changed=RUSTFLAGS"); + println!("cargo:rerun-if-env-changed=RUSTUP_TOOLCHAIN"); + + // Inside a midenc-driven build the compiler owns the package cache, macro expansion + // already sees the variable, and a nested build would recurse into this script forever. + if env::var_os("MIDENC_PACKAGE_CACHE").is_some() { + return; + } + + let manifest_dir = PathBuf::from(env::var_os("CARGO_MANIFEST_DIR").unwrap()); + + // Ask the compiler where this project's package cache lives and which inputs shape it. + let query = run_cargo_miden(&manifest_dir, "package-cache"); + if !query.status.success() { + panic!( + "`cargo miden package-cache` failed ({}).\nInstall cargo-miden (`cargo install \ + cargo-miden`) or point the CARGO_MIDEN environment variable at a cargo-miden \ + binary.\n--- stderr ---\n{}", + query.status, + String::from_utf8_lossy(&query.stderr), + ); + } + + let stdout = + String::from_utf8(query.stdout).expect("cargo miden package-cache output is UTF-8"); + let mut cache_dir = None; + let mut source_dependencies = 0usize; + for line in stdout.lines() { + if let Some(value) = line.strip_prefix("cache-dir=") { + cache_dir = Some(PathBuf::from(value)); + } else if let Some(value) = line.strip_prefix("source-dependencies=") { + source_dependencies = value.parse().expect("source-dependencies is a number"); + } else if let Some(value) = line.strip_prefix("watch=") { + println!("cargo:rerun-if-changed={value}"); + } + } + let cache_dir = cache_dir.expect("cargo miden package-cache printed no cache-dir"); + + if source_dependencies > 0 { + // Populate the cache. Dependency packages publish before the root target compiles, so + // even a failing build (for example, this crate is mid-edit) usually leaves the + // dependency packages usable; the macros report anything that is genuinely missing. + let build = run_cargo_miden(&manifest_dir, "build"); + if !build.status.success() { + println!( + "cargo:warning=`cargo miden build --release` failed ({}); dependency packages \ + may be stale or missing: {}", + build.status, + last_stderr_line(&build.stderr), + ); + } + } + + // The macros treat a missing directory as an empty cache; create it so the exported + // variable always points at a real location. Watching the directory re-runs this script + // when another build rewrites or prunes the cache (cargo re-runs unconditionally while a + // watched path is missing), which keeps the exported path and its packages live. + fs::create_dir_all(&cache_dir).expect("failed to create the Miden package cache directory"); + println!("cargo:rerun-if-changed={}", cache_dir.display()); + println!("cargo:rustc-env=MIDENC_PACKAGE_CACHE={}", cache_dir.display()); +} + +/// Runs `cargo miden --release` for the project in `manifest_dir`. +/// +/// `CARGO_MIDEN` selects a specific `cargo-miden` binary; otherwise the `cargo miden` plugin +/// is resolved through the `cargo` that drives this build. The nested build gets its own +/// cargo target directory: the outer cargo holds a lock on this build's target directory +/// while build scripts run, and a nested build against the same directory would deadlock. +/// `--release` keeps the cache fingerprint identical to a `cargo miden build --release` run +/// by hand, so both share one cache. +fn run_cargo_miden(manifest_dir: &Path, subcommand: &str) -> Output { + let mut command = match env::var_os("CARGO_MIDEN") { + Some(cargo_miden) => Command::new(cargo_miden), + None => Command::new(env::var_os("CARGO").unwrap_or_else(|| "cargo".into())), + }; + command + .args(["miden", subcommand, "--release"]) + .current_dir(manifest_dir) + .env( + "CARGO_TARGET_DIR", + manifest_dir.join("target").join("miden").join("build-script"), + ); + command.output().unwrap_or_else(|err| { + panic!( + "failed to run `cargo miden {subcommand}`: {err}.\nInstall cargo-miden (`cargo \ + install cargo-miden`) or point the CARGO_MIDEN environment variable at a \ + cargo-miden binary." + ) + }) +} + +/// Returns the last non-empty stderr line for a compact warning. +fn last_stderr_line(stderr: &[u8]) -> String { + String::from_utf8_lossy(stderr) + .lines() + .rev() + .find(|line| !line.trim().is_empty()) + .unwrap_or("no error output") + .to_string() +} diff --git a/extra/templates/rust/account/template/miden-project.toml b/extra/templates/rust/account/template/miden-project.toml index ceb412184..94017d1bb 100644 --- a/extra/templates/rust/account/template/miden-project.toml +++ b/extra/templates/rust/account/template/miden-project.toml @@ -5,6 +5,7 @@ version = "0.1.0" [lib] kind = "account-component" namespace = "miden:{{crate_name | replace: "_", "-" }}/{{crate_name | replace: "_", "-" }}@0.1.0" +path = "src/lib.rs" [dependencies] miden-core = "*" diff --git a/extra/templates/rust/auth-component/template/build.rs b/extra/templates/rust/auth-component/template/build.rs new file mode 100644 index 000000000..6c3cee049 --- /dev/null +++ b/extra/templates/rust/auth-component/template/build.rs @@ -0,0 +1,121 @@ +//! Populates the Miden package cache for builds that `midenc` does not drive. +//! +//! Plain `cargo check`, `cargo build`, and IDE analysis expand the Miden SDK macros without a +//! surrounding `cargo miden build`. Those macros read compiled dependency packages from the +//! directory named by `MIDENC_PACKAGE_CACHE`. This script asks `cargo-miden` for that +//! directory, populates it with a nested build when the project has source dependencies, and +//! exports the variable to the compilation of this crate. +//! +//! See . + +use std::{ + env, fs, + path::{Path, PathBuf}, + process::{Command, Output}, +}; + +fn main() { + // Re-evaluate this script when the build mode or the tool selection changes. + println!("cargo:rerun-if-env-changed=MIDENC_PACKAGE_CACHE"); + println!("cargo:rerun-if-env-changed=CARGO_MIDEN"); + // These inputs shape the compiler's package-cache fingerprint. + println!("cargo:rerun-if-env-changed=RUSTFLAGS"); + println!("cargo:rerun-if-env-changed=RUSTUP_TOOLCHAIN"); + + // Inside a midenc-driven build the compiler owns the package cache, macro expansion + // already sees the variable, and a nested build would recurse into this script forever. + if env::var_os("MIDENC_PACKAGE_CACHE").is_some() { + return; + } + + let manifest_dir = PathBuf::from(env::var_os("CARGO_MANIFEST_DIR").unwrap()); + + // Ask the compiler where this project's package cache lives and which inputs shape it. + let query = run_cargo_miden(&manifest_dir, "package-cache"); + if !query.status.success() { + panic!( + "`cargo miden package-cache` failed ({}).\nInstall cargo-miden (`cargo install \ + cargo-miden`) or point the CARGO_MIDEN environment variable at a cargo-miden \ + binary.\n--- stderr ---\n{}", + query.status, + String::from_utf8_lossy(&query.stderr), + ); + } + + let stdout = + String::from_utf8(query.stdout).expect("cargo miden package-cache output is UTF-8"); + let mut cache_dir = None; + let mut source_dependencies = 0usize; + for line in stdout.lines() { + if let Some(value) = line.strip_prefix("cache-dir=") { + cache_dir = Some(PathBuf::from(value)); + } else if let Some(value) = line.strip_prefix("source-dependencies=") { + source_dependencies = value.parse().expect("source-dependencies is a number"); + } else if let Some(value) = line.strip_prefix("watch=") { + println!("cargo:rerun-if-changed={value}"); + } + } + let cache_dir = cache_dir.expect("cargo miden package-cache printed no cache-dir"); + + if source_dependencies > 0 { + // Populate the cache. Dependency packages publish before the root target compiles, so + // even a failing build (for example, this crate is mid-edit) usually leaves the + // dependency packages usable; the macros report anything that is genuinely missing. + let build = run_cargo_miden(&manifest_dir, "build"); + if !build.status.success() { + println!( + "cargo:warning=`cargo miden build --release` failed ({}); dependency packages \ + may be stale or missing: {}", + build.status, + last_stderr_line(&build.stderr), + ); + } + } + + // The macros treat a missing directory as an empty cache; create it so the exported + // variable always points at a real location. Watching the directory re-runs this script + // when another build rewrites or prunes the cache (cargo re-runs unconditionally while a + // watched path is missing), which keeps the exported path and its packages live. + fs::create_dir_all(&cache_dir).expect("failed to create the Miden package cache directory"); + println!("cargo:rerun-if-changed={}", cache_dir.display()); + println!("cargo:rustc-env=MIDENC_PACKAGE_CACHE={}", cache_dir.display()); +} + +/// Runs `cargo miden --release` for the project in `manifest_dir`. +/// +/// `CARGO_MIDEN` selects a specific `cargo-miden` binary; otherwise the `cargo miden` plugin +/// is resolved through the `cargo` that drives this build. The nested build gets its own +/// cargo target directory: the outer cargo holds a lock on this build's target directory +/// while build scripts run, and a nested build against the same directory would deadlock. +/// `--release` keeps the cache fingerprint identical to a `cargo miden build --release` run +/// by hand, so both share one cache. +fn run_cargo_miden(manifest_dir: &Path, subcommand: &str) -> Output { + let mut command = match env::var_os("CARGO_MIDEN") { + Some(cargo_miden) => Command::new(cargo_miden), + None => Command::new(env::var_os("CARGO").unwrap_or_else(|| "cargo".into())), + }; + command + .args(["miden", subcommand, "--release"]) + .current_dir(manifest_dir) + .env( + "CARGO_TARGET_DIR", + manifest_dir.join("target").join("miden").join("build-script"), + ); + command.output().unwrap_or_else(|err| { + panic!( + "failed to run `cargo miden {subcommand}`: {err}.\nInstall cargo-miden (`cargo \ + install cargo-miden`) or point the CARGO_MIDEN environment variable at a \ + cargo-miden binary." + ) + }) +} + +/// Returns the last non-empty stderr line for a compact warning. +fn last_stderr_line(stderr: &[u8]) -> String { + String::from_utf8_lossy(stderr) + .lines() + .rev() + .find(|line| !line.trim().is_empty()) + .unwrap_or("no error output") + .to_string() +} diff --git a/extra/templates/rust/auth-component/template/miden-project.toml b/extra/templates/rust/auth-component/template/miden-project.toml index 5660b6469..56de7b10d 100644 --- a/extra/templates/rust/auth-component/template/miden-project.toml +++ b/extra/templates/rust/auth-component/template/miden-project.toml @@ -5,6 +5,7 @@ version = "0.1.0" [lib] kind = "account-component" namespace = "miden:{{crate_name | replace: "_", "-" }}/auth-component@0.1.0" +path = "src/lib.rs" [dependencies] miden-core = "*" diff --git a/extra/templates/rust/note/template/build.rs b/extra/templates/rust/note/template/build.rs new file mode 100644 index 000000000..6c3cee049 --- /dev/null +++ b/extra/templates/rust/note/template/build.rs @@ -0,0 +1,121 @@ +//! Populates the Miden package cache for builds that `midenc` does not drive. +//! +//! Plain `cargo check`, `cargo build`, and IDE analysis expand the Miden SDK macros without a +//! surrounding `cargo miden build`. Those macros read compiled dependency packages from the +//! directory named by `MIDENC_PACKAGE_CACHE`. This script asks `cargo-miden` for that +//! directory, populates it with a nested build when the project has source dependencies, and +//! exports the variable to the compilation of this crate. +//! +//! See . + +use std::{ + env, fs, + path::{Path, PathBuf}, + process::{Command, Output}, +}; + +fn main() { + // Re-evaluate this script when the build mode or the tool selection changes. + println!("cargo:rerun-if-env-changed=MIDENC_PACKAGE_CACHE"); + println!("cargo:rerun-if-env-changed=CARGO_MIDEN"); + // These inputs shape the compiler's package-cache fingerprint. + println!("cargo:rerun-if-env-changed=RUSTFLAGS"); + println!("cargo:rerun-if-env-changed=RUSTUP_TOOLCHAIN"); + + // Inside a midenc-driven build the compiler owns the package cache, macro expansion + // already sees the variable, and a nested build would recurse into this script forever. + if env::var_os("MIDENC_PACKAGE_CACHE").is_some() { + return; + } + + let manifest_dir = PathBuf::from(env::var_os("CARGO_MANIFEST_DIR").unwrap()); + + // Ask the compiler where this project's package cache lives and which inputs shape it. + let query = run_cargo_miden(&manifest_dir, "package-cache"); + if !query.status.success() { + panic!( + "`cargo miden package-cache` failed ({}).\nInstall cargo-miden (`cargo install \ + cargo-miden`) or point the CARGO_MIDEN environment variable at a cargo-miden \ + binary.\n--- stderr ---\n{}", + query.status, + String::from_utf8_lossy(&query.stderr), + ); + } + + let stdout = + String::from_utf8(query.stdout).expect("cargo miden package-cache output is UTF-8"); + let mut cache_dir = None; + let mut source_dependencies = 0usize; + for line in stdout.lines() { + if let Some(value) = line.strip_prefix("cache-dir=") { + cache_dir = Some(PathBuf::from(value)); + } else if let Some(value) = line.strip_prefix("source-dependencies=") { + source_dependencies = value.parse().expect("source-dependencies is a number"); + } else if let Some(value) = line.strip_prefix("watch=") { + println!("cargo:rerun-if-changed={value}"); + } + } + let cache_dir = cache_dir.expect("cargo miden package-cache printed no cache-dir"); + + if source_dependencies > 0 { + // Populate the cache. Dependency packages publish before the root target compiles, so + // even a failing build (for example, this crate is mid-edit) usually leaves the + // dependency packages usable; the macros report anything that is genuinely missing. + let build = run_cargo_miden(&manifest_dir, "build"); + if !build.status.success() { + println!( + "cargo:warning=`cargo miden build --release` failed ({}); dependency packages \ + may be stale or missing: {}", + build.status, + last_stderr_line(&build.stderr), + ); + } + } + + // The macros treat a missing directory as an empty cache; create it so the exported + // variable always points at a real location. Watching the directory re-runs this script + // when another build rewrites or prunes the cache (cargo re-runs unconditionally while a + // watched path is missing), which keeps the exported path and its packages live. + fs::create_dir_all(&cache_dir).expect("failed to create the Miden package cache directory"); + println!("cargo:rerun-if-changed={}", cache_dir.display()); + println!("cargo:rustc-env=MIDENC_PACKAGE_CACHE={}", cache_dir.display()); +} + +/// Runs `cargo miden --release` for the project in `manifest_dir`. +/// +/// `CARGO_MIDEN` selects a specific `cargo-miden` binary; otherwise the `cargo miden` plugin +/// is resolved through the `cargo` that drives this build. The nested build gets its own +/// cargo target directory: the outer cargo holds a lock on this build's target directory +/// while build scripts run, and a nested build against the same directory would deadlock. +/// `--release` keeps the cache fingerprint identical to a `cargo miden build --release` run +/// by hand, so both share one cache. +fn run_cargo_miden(manifest_dir: &Path, subcommand: &str) -> Output { + let mut command = match env::var_os("CARGO_MIDEN") { + Some(cargo_miden) => Command::new(cargo_miden), + None => Command::new(env::var_os("CARGO").unwrap_or_else(|| "cargo".into())), + }; + command + .args(["miden", subcommand, "--release"]) + .current_dir(manifest_dir) + .env( + "CARGO_TARGET_DIR", + manifest_dir.join("target").join("miden").join("build-script"), + ); + command.output().unwrap_or_else(|err| { + panic!( + "failed to run `cargo miden {subcommand}`: {err}.\nInstall cargo-miden (`cargo \ + install cargo-miden`) or point the CARGO_MIDEN environment variable at a \ + cargo-miden binary." + ) + }) +} + +/// Returns the last non-empty stderr line for a compact warning. +fn last_stderr_line(stderr: &[u8]) -> String { + String::from_utf8_lossy(stderr) + .lines() + .rev() + .find(|line| !line.trim().is_empty()) + .unwrap_or("no error output") + .to_string() +} diff --git a/extra/templates/rust/note/template/miden-project.toml b/extra/templates/rust/note/template/miden-project.toml index c9195115f..991a78d63 100644 --- a/extra/templates/rust/note/template/miden-project.toml +++ b/extra/templates/rust/note/template/miden-project.toml @@ -5,6 +5,7 @@ version = "0.1.0" [lib] kind = "note" namespace = "miden:{{crate_name | replace: "_", "-" }}/miden-{{crate_name | replace: "_", "-" }}@0.1.0" +path = "src/lib.rs" [dependencies] miden-core = "*" diff --git a/extra/templates/rust/program/template/build.rs b/extra/templates/rust/program/template/build.rs new file mode 100644 index 000000000..6c3cee049 --- /dev/null +++ b/extra/templates/rust/program/template/build.rs @@ -0,0 +1,121 @@ +//! Populates the Miden package cache for builds that `midenc` does not drive. +//! +//! Plain `cargo check`, `cargo build`, and IDE analysis expand the Miden SDK macros without a +//! surrounding `cargo miden build`. Those macros read compiled dependency packages from the +//! directory named by `MIDENC_PACKAGE_CACHE`. This script asks `cargo-miden` for that +//! directory, populates it with a nested build when the project has source dependencies, and +//! exports the variable to the compilation of this crate. +//! +//! See . + +use std::{ + env, fs, + path::{Path, PathBuf}, + process::{Command, Output}, +}; + +fn main() { + // Re-evaluate this script when the build mode or the tool selection changes. + println!("cargo:rerun-if-env-changed=MIDENC_PACKAGE_CACHE"); + println!("cargo:rerun-if-env-changed=CARGO_MIDEN"); + // These inputs shape the compiler's package-cache fingerprint. + println!("cargo:rerun-if-env-changed=RUSTFLAGS"); + println!("cargo:rerun-if-env-changed=RUSTUP_TOOLCHAIN"); + + // Inside a midenc-driven build the compiler owns the package cache, macro expansion + // already sees the variable, and a nested build would recurse into this script forever. + if env::var_os("MIDENC_PACKAGE_CACHE").is_some() { + return; + } + + let manifest_dir = PathBuf::from(env::var_os("CARGO_MANIFEST_DIR").unwrap()); + + // Ask the compiler where this project's package cache lives and which inputs shape it. + let query = run_cargo_miden(&manifest_dir, "package-cache"); + if !query.status.success() { + panic!( + "`cargo miden package-cache` failed ({}).\nInstall cargo-miden (`cargo install \ + cargo-miden`) or point the CARGO_MIDEN environment variable at a cargo-miden \ + binary.\n--- stderr ---\n{}", + query.status, + String::from_utf8_lossy(&query.stderr), + ); + } + + let stdout = + String::from_utf8(query.stdout).expect("cargo miden package-cache output is UTF-8"); + let mut cache_dir = None; + let mut source_dependencies = 0usize; + for line in stdout.lines() { + if let Some(value) = line.strip_prefix("cache-dir=") { + cache_dir = Some(PathBuf::from(value)); + } else if let Some(value) = line.strip_prefix("source-dependencies=") { + source_dependencies = value.parse().expect("source-dependencies is a number"); + } else if let Some(value) = line.strip_prefix("watch=") { + println!("cargo:rerun-if-changed={value}"); + } + } + let cache_dir = cache_dir.expect("cargo miden package-cache printed no cache-dir"); + + if source_dependencies > 0 { + // Populate the cache. Dependency packages publish before the root target compiles, so + // even a failing build (for example, this crate is mid-edit) usually leaves the + // dependency packages usable; the macros report anything that is genuinely missing. + let build = run_cargo_miden(&manifest_dir, "build"); + if !build.status.success() { + println!( + "cargo:warning=`cargo miden build --release` failed ({}); dependency packages \ + may be stale or missing: {}", + build.status, + last_stderr_line(&build.stderr), + ); + } + } + + // The macros treat a missing directory as an empty cache; create it so the exported + // variable always points at a real location. Watching the directory re-runs this script + // when another build rewrites or prunes the cache (cargo re-runs unconditionally while a + // watched path is missing), which keeps the exported path and its packages live. + fs::create_dir_all(&cache_dir).expect("failed to create the Miden package cache directory"); + println!("cargo:rerun-if-changed={}", cache_dir.display()); + println!("cargo:rustc-env=MIDENC_PACKAGE_CACHE={}", cache_dir.display()); +} + +/// Runs `cargo miden --release` for the project in `manifest_dir`. +/// +/// `CARGO_MIDEN` selects a specific `cargo-miden` binary; otherwise the `cargo miden` plugin +/// is resolved through the `cargo` that drives this build. The nested build gets its own +/// cargo target directory: the outer cargo holds a lock on this build's target directory +/// while build scripts run, and a nested build against the same directory would deadlock. +/// `--release` keeps the cache fingerprint identical to a `cargo miden build --release` run +/// by hand, so both share one cache. +fn run_cargo_miden(manifest_dir: &Path, subcommand: &str) -> Output { + let mut command = match env::var_os("CARGO_MIDEN") { + Some(cargo_miden) => Command::new(cargo_miden), + None => Command::new(env::var_os("CARGO").unwrap_or_else(|| "cargo".into())), + }; + command + .args(["miden", subcommand, "--release"]) + .current_dir(manifest_dir) + .env( + "CARGO_TARGET_DIR", + manifest_dir.join("target").join("miden").join("build-script"), + ); + command.output().unwrap_or_else(|err| { + panic!( + "failed to run `cargo miden {subcommand}`: {err}.\nInstall cargo-miden (`cargo \ + install cargo-miden`) or point the CARGO_MIDEN environment variable at a \ + cargo-miden binary." + ) + }) +} + +/// Returns the last non-empty stderr line for a compact warning. +fn last_stderr_line(stderr: &[u8]) -> String { + String::from_utf8_lossy(stderr) + .lines() + .rev() + .find(|line| !line.trim().is_empty()) + .unwrap_or("no error output") + .to_string() +} diff --git a/extra/templates/rust/tx-script/template/build.rs b/extra/templates/rust/tx-script/template/build.rs new file mode 100644 index 000000000..6c3cee049 --- /dev/null +++ b/extra/templates/rust/tx-script/template/build.rs @@ -0,0 +1,121 @@ +//! Populates the Miden package cache for builds that `midenc` does not drive. +//! +//! Plain `cargo check`, `cargo build`, and IDE analysis expand the Miden SDK macros without a +//! surrounding `cargo miden build`. Those macros read compiled dependency packages from the +//! directory named by `MIDENC_PACKAGE_CACHE`. This script asks `cargo-miden` for that +//! directory, populates it with a nested build when the project has source dependencies, and +//! exports the variable to the compilation of this crate. +//! +//! See . + +use std::{ + env, fs, + path::{Path, PathBuf}, + process::{Command, Output}, +}; + +fn main() { + // Re-evaluate this script when the build mode or the tool selection changes. + println!("cargo:rerun-if-env-changed=MIDENC_PACKAGE_CACHE"); + println!("cargo:rerun-if-env-changed=CARGO_MIDEN"); + // These inputs shape the compiler's package-cache fingerprint. + println!("cargo:rerun-if-env-changed=RUSTFLAGS"); + println!("cargo:rerun-if-env-changed=RUSTUP_TOOLCHAIN"); + + // Inside a midenc-driven build the compiler owns the package cache, macro expansion + // already sees the variable, and a nested build would recurse into this script forever. + if env::var_os("MIDENC_PACKAGE_CACHE").is_some() { + return; + } + + let manifest_dir = PathBuf::from(env::var_os("CARGO_MANIFEST_DIR").unwrap()); + + // Ask the compiler where this project's package cache lives and which inputs shape it. + let query = run_cargo_miden(&manifest_dir, "package-cache"); + if !query.status.success() { + panic!( + "`cargo miden package-cache` failed ({}).\nInstall cargo-miden (`cargo install \ + cargo-miden`) or point the CARGO_MIDEN environment variable at a cargo-miden \ + binary.\n--- stderr ---\n{}", + query.status, + String::from_utf8_lossy(&query.stderr), + ); + } + + let stdout = + String::from_utf8(query.stdout).expect("cargo miden package-cache output is UTF-8"); + let mut cache_dir = None; + let mut source_dependencies = 0usize; + for line in stdout.lines() { + if let Some(value) = line.strip_prefix("cache-dir=") { + cache_dir = Some(PathBuf::from(value)); + } else if let Some(value) = line.strip_prefix("source-dependencies=") { + source_dependencies = value.parse().expect("source-dependencies is a number"); + } else if let Some(value) = line.strip_prefix("watch=") { + println!("cargo:rerun-if-changed={value}"); + } + } + let cache_dir = cache_dir.expect("cargo miden package-cache printed no cache-dir"); + + if source_dependencies > 0 { + // Populate the cache. Dependency packages publish before the root target compiles, so + // even a failing build (for example, this crate is mid-edit) usually leaves the + // dependency packages usable; the macros report anything that is genuinely missing. + let build = run_cargo_miden(&manifest_dir, "build"); + if !build.status.success() { + println!( + "cargo:warning=`cargo miden build --release` failed ({}); dependency packages \ + may be stale or missing: {}", + build.status, + last_stderr_line(&build.stderr), + ); + } + } + + // The macros treat a missing directory as an empty cache; create it so the exported + // variable always points at a real location. Watching the directory re-runs this script + // when another build rewrites or prunes the cache (cargo re-runs unconditionally while a + // watched path is missing), which keeps the exported path and its packages live. + fs::create_dir_all(&cache_dir).expect("failed to create the Miden package cache directory"); + println!("cargo:rerun-if-changed={}", cache_dir.display()); + println!("cargo:rustc-env=MIDENC_PACKAGE_CACHE={}", cache_dir.display()); +} + +/// Runs `cargo miden --release` for the project in `manifest_dir`. +/// +/// `CARGO_MIDEN` selects a specific `cargo-miden` binary; otherwise the `cargo miden` plugin +/// is resolved through the `cargo` that drives this build. The nested build gets its own +/// cargo target directory: the outer cargo holds a lock on this build's target directory +/// while build scripts run, and a nested build against the same directory would deadlock. +/// `--release` keeps the cache fingerprint identical to a `cargo miden build --release` run +/// by hand, so both share one cache. +fn run_cargo_miden(manifest_dir: &Path, subcommand: &str) -> Output { + let mut command = match env::var_os("CARGO_MIDEN") { + Some(cargo_miden) => Command::new(cargo_miden), + None => Command::new(env::var_os("CARGO").unwrap_or_else(|| "cargo".into())), + }; + command + .args(["miden", subcommand, "--release"]) + .current_dir(manifest_dir) + .env( + "CARGO_TARGET_DIR", + manifest_dir.join("target").join("miden").join("build-script"), + ); + command.output().unwrap_or_else(|err| { + panic!( + "failed to run `cargo miden {subcommand}`: {err}.\nInstall cargo-miden (`cargo \ + install cargo-miden`) or point the CARGO_MIDEN environment variable at a \ + cargo-miden binary." + ) + }) +} + +/// Returns the last non-empty stderr line for a compact warning. +fn last_stderr_line(stderr: &[u8]) -> String { + String::from_utf8_lossy(stderr) + .lines() + .rev() + .find(|line| !line.trim().is_empty()) + .unwrap_or("no error output") + .to_string() +} diff --git a/extra/templates/rust/tx-script/template/miden-project.toml b/extra/templates/rust/tx-script/template/miden-project.toml index f7294c6ea..8dcc17df6 100644 --- a/extra/templates/rust/tx-script/template/miden-project.toml +++ b/extra/templates/rust/tx-script/template/miden-project.toml @@ -5,6 +5,7 @@ version = "0.1.0" [lib] kind = "tx-script" namespace = "miden:base/transaction-script@1.0.0" +path = "src/lib.rs" [dependencies] miden-core = "*" diff --git a/midenc-session/src/lib.rs b/midenc-session/src/lib.rs index 1c6ad8bfb..26f564bba 100644 --- a/midenc-session/src/lib.rs +++ b/midenc-session/src/lib.rs @@ -48,6 +48,8 @@ pub use miden_package_registry; pub use miden_project; use midenc_hir_symbol::Symbol; +#[cfg(feature = "std")] +pub use self::package_cache::PackageCacheBuildInputs; pub use self::{ color::ColorChoice, diagnostics::{DiagnosticsHandler, Emitter, Report, SourceManager}, @@ -395,21 +397,7 @@ impl Session { /// `Cargo.toml` input, and a rebuilt package has no manifest path — so an executable project /// silently got no filesystem cache at all, while a library project of the same shape got one. pub fn filesystem_package_cache_dir(&self) -> Option { - let input = self.input.as_ref()?; - if !matches!(input.file_type(), FileType::Toml) { - return None; - } - let project_dir = input.as_path()?.parent()?; - let project_dir = if project_dir.is_absolute() { - project_dir.to_path_buf() - } else { - self.options.current_dir.join(project_dir) - }; - // Canonicalized because the loaded manifest path this replaces was: the cache directory - // is compared by path across nested builds, so `.`-relative and symlinked spellings of - // one directory must not resolve to two caches. - #[cfg(feature = "std")] - let project_dir = project_dir.canonicalize().unwrap_or(project_dir); + let project_dir = self.package_cache_project_dir()?; #[cfg(feature = "std")] let package_cache_dir = package_cache::package_cache_parent(&project_dir); #[cfg(not(feature = "std"))] @@ -436,6 +424,41 @@ impl Session { } } + /// The project directory whose `target/miden/packages` tree holds this session's cache. + /// + /// `None` unless this session's input is a project locator, mirroring + /// [`Session::filesystem_package_cache_dir`]. + fn package_cache_project_dir(&self) -> Option { + let input = self.input.as_ref()?; + if !matches!(input.file_type(), FileType::Toml) { + return None; + } + let project_dir = input.as_path()?.parent()?; + let project_dir = if project_dir.is_absolute() { + project_dir.to_path_buf() + } else { + self.options.current_dir.join(project_dir) + }; + // Canonicalized because the loaded manifest path this replaces was: the cache directory + // is compared by path across nested builds, so `.`-relative and symlinked spellings of + // one directory must not resolve to two caches. + #[cfg(feature = "std")] + let project_dir = project_dir.canonicalize().unwrap_or(project_dir); + Some(project_dir) + } + + /// Build-script inputs of this session's project package cache. + /// + /// `None` under the same condition as [`Session::filesystem_package_cache_dir`]: the + /// session input must be a project locator. The watch list and the dependency count let a + /// contract build script re-run its nested build exactly when the cache contents could + /// change; `cargo miden package-cache` is the consumer. + #[cfg(feature = "std")] + pub fn package_cache_build_inputs(&self) -> Option { + let project_dir = self.package_cache_project_dir()?; + Some(package_cache::build_script_inputs(&project_dir)) + } + /// Get the [OutputFile] to write the assembled MAST output to pub fn out_file(&self) -> OutputFile { let out_file = self.output_files.output_file(OutputType::Masp, None); diff --git a/midenc-session/src/package_cache.rs b/midenc-session/src/package_cache.rs index 19465cdba..32547e7ce 100644 --- a/midenc-session/src/package_cache.rs +++ b/midenc-session/src/package_cache.rs @@ -360,7 +360,7 @@ pub(crate) fn fingerprint( record_options(&mut transcript, options, inherited_rustflags, inherited_rustup_toolchain); let source_manager = DefaultSourceManager::default(); - let mut manifests = ManifestClosure::new(&mut transcript, &source_manager); + let mut manifests = ManifestClosure::new(&mut transcript, &source_manager, None); manifests.visit_project(project_dir, None); let digest = Blake3_256::hash(transcript.as_bytes()); @@ -373,6 +373,74 @@ pub(crate) fn fingerprint( fingerprint } +/// Build-script inputs of a project's package cache. +/// +/// Contract build scripts consume this through `cargo miden package-cache`: the watch list +/// drives their `cargo:rerun-if-changed` directives, and the dependency count decides whether +/// a nested `cargo miden build` is required at all. +#[derive(Debug, Default)] +pub struct PackageCacheBuildInputs { + /// Manifest, source, and package paths whose changes require a new nested build. + /// + /// Only paths that exist are listed: cargo re-runs a build script unconditionally while a + /// watched path is missing, which would turn every check into a nested build. + pub watch_paths: Vec, + /// The number of direct dependencies whose packages a build compiles into the cache. + /// + /// Registry dependencies and explicit `.masp` file paths are excluded: the assembler + /// resolves the former, and macros read the latter straight from the manifest's path. + pub source_dependency_count: usize, +} + +/// Collects the build-script inputs of the project at `project_dir`. +/// +/// The watch list covers the manifest closure the fingerprint walks: every project's +/// manifests, each dependency project's `src` and `wit` directories, and each preassembled +/// package file. The root project's own sources are deliberately excluded — they do not +/// change dependency packages, and watching them would re-run the nested build on every edit. +pub(crate) fn build_script_inputs(project_dir: &Path) -> PackageCacheBuildInputs { + let source_manager = DefaultSourceManager::default(); + let mut transcript = Transcript::new(); + let mut watch_paths = BTreeSet::new(); + let mut manifests = + ManifestClosure::new(&mut transcript, &source_manager, Some(&mut watch_paths)); + manifests.visit_project(project_dir, None); + + PackageCacheBuildInputs { + watch_paths: watch_paths.into_iter().collect(), + source_dependency_count: source_dependency_count(project_dir, &source_manager), + } +} + +/// Counts the root project's direct dependencies whose packages a build compiles into the cache. +fn source_dependency_count(project_dir: &Path, source_manager: &dyn SourceManager) -> usize { + let Ok(project) = Project::load(project_dir, source_manager) else { + return 0; + }; + project + .package() + .dependencies() + .iter() + .filter(|dependency| match dependency.scheme() { + DependencyVersionScheme::Registry(_) => false, + DependencyVersionScheme::Path { path, .. } + | DependencyVersionScheme::WorkspacePath { path, .. } => { + !is_package_file_uri(path.inner()) + } + DependencyVersionScheme::Workspace { .. } | DependencyVersionScheme::Git { .. } => true, + }) + .count() +} + +/// Returns true when a path dependency's URI names a preassembled `.masp` package file. +/// +/// Extension-classified like the fingerprint walk, before any canonicalization. +fn is_package_file_uri(uri: &miden_project::Uri) -> bool { + Path::new(uri.path()) + .extension() + .is_some_and(|extension| extension.eq_ignore_ascii_case(Package::EXTENSION)) +} + /// A length-prefixed, domain-separated byte transcript. struct Transcript { bytes: Vec, @@ -559,17 +627,33 @@ struct ManifestClosure<'a> { visited_projects: BTreeSet, visited_packages: BTreeSet, visited_workspace_roots: BTreeSet, + /// Existing filesystem inputs collected for build scripts, when a collector is attached. + watch_paths: Option<&'a mut BTreeSet>, } impl<'a> ManifestClosure<'a> { /// Creates an empty manifest-closure walk. - fn new(transcript: &'a mut Transcript, source_manager: &'a dyn SourceManager) -> Self { + fn new( + transcript: &'a mut Transcript, + source_manager: &'a dyn SourceManager, + watch_paths: Option<&'a mut BTreeSet>, + ) -> Self { Self { transcript, source_manager, visited_projects: BTreeSet::new(), visited_packages: BTreeSet::new(), visited_workspace_roots: BTreeSet::new(), + watch_paths, + } + } + + /// Records a path for build-script watching when collection is active and the path exists. + fn watch(&mut self, path: &Path) { + if let Some(watch_paths) = self.watch_paths.as_deref_mut() + && path.exists() + { + watch_paths.insert(path.to_path_buf()); } } @@ -618,6 +702,15 @@ impl<'a> ManifestClosure<'a> { }; self.transcript.field("project.load", b"succeeded"); + // Dependency sources feed dependency packages, so build scripts watch them. The root + // project's sources do not: its package is not read back by its own macro expansion, + // and watching them would re-run the nested build on every edit. The root is the one + // project visited without an expected dependency name. + if expected_name.is_some() { + self.watch(&project_dir.join("src")); + self.watch(&project_dir.join("wit")); + } + let package = project.package(); let workspace = match &project { Project::WorkspacePackage { workspace, .. } => Some(workspace.as_ref()), @@ -648,6 +741,7 @@ impl<'a> ManifestClosure<'a> { self.transcript.field("manifest.name", name.as_bytes()); match std::fs::read(path) { Ok(bytes) => { + self.watch(path); self.transcript.field("manifest.state", b"present"); self.transcript.field("manifest.bytes", &bytes); } @@ -769,6 +863,7 @@ impl<'a> ManifestClosure<'a> { self.transcript.field("package.file", b"begin"); match std::fs::read(path) { Ok(bytes) => { + self.watch(path); self.transcript.field("package.file.state", b"present"); let digest = Blake3_256::hash(&bytes); self.transcript.field("package.file.digest", digest.as_bytes()); @@ -1119,6 +1214,44 @@ mod tests { fingerprint(options, project_dir, None, None, version, rev) } + #[test] + fn build_script_inputs_watch_dependency_sources_but_not_root_sources() { + let temp = TempDir::new().unwrap(); + let root = temp.path().join("root"); + let dependency = temp.path().join("dependency"); + let prebuilt = temp.path().join("prebuilt.masp"); + write_project( + &root, + "root", + "\n[dependencies]\nregistry-dep = \"*\"\ndependency = { path = \"../dependency\" \ + }\nprebuilt = { path = \"../prebuilt.masp\" }\n", + ); + write_project(&dependency, "dependency", ""); + fs::create_dir_all(root.join("src")).unwrap(); + fs::create_dir_all(dependency.join("src")).unwrap(); + fs::create_dir_all(dependency.join("wit")).unwrap(); + fs::write(&prebuilt, b"package bytes").unwrap(); + + let inputs = build_script_inputs(&root); + + // Paths may carry `..` components from manifest-relative joins; compare by suffix. + let watched = |suffix: &str| inputs.watch_paths.iter().any(|path| path.ends_with(suffix)); + assert!(watched("root/miden-project.toml"), "the root manifests must be watched"); + assert!(watched("root/Cargo.toml"), "the root manifests must be watched"); + assert!(watched("dependency/miden-project.toml")); + assert!(watched("dependency/Cargo.toml")); + assert!(watched("dependency/src"), "dependency sources must be watched"); + assert!(watched("dependency/wit"), "dependency WIT must be watched"); + assert!(watched("prebuilt.masp"), "preassembled packages must be watched"); + assert!(!watched("root/src"), "root sources must not re-run the nested build"); + assert!(!watched("root/wit"), "a nonexistent path must never be watched"); + + assert_eq!( + inputs.source_dependency_count, 1, + "only the source-project dependency counts; registry and `.masp` deps do not" + ); + } + #[test] fn fingerprint_is_stable_for_unchanged_inputs() { let temp = TempDir::new().unwrap(); diff --git a/sdk/sdk/MIGRATION.md b/sdk/sdk/MIGRATION.md index 134b29e55..fe462a0b6 100644 --- a/sdk/sdk/MIGRATION.md +++ b/sdk/sdk/MIGRATION.md @@ -14,6 +14,23 @@ directly below this paragraph, above the previous one (newest first, like the +### Contract crates gain a `build.rs` for IDE and plain-cargo builds + +New projects created by `cargo miden new` include a `build.rs` in each contract crate. The +script makes plain `cargo check`, `cargo build`, and IDE analysis (rust-analyzer) resolve +compiled dependency packages: outside a `cargo miden build`, it runs +`cargo miden package-cache` to locate the project's package cache, populates the cache with a +nested `cargo miden build --release` when the project has source dependencies, and exports +`MIDENC_PACKAGE_CACHE` to the crate's macro expansion. Inside a midenc-driven build the script +does nothing. + +Adoption is optional but recommended for existing projects: copy `build.rs` from a freshly +generated template contract (for example `cargo miden new --account demo`) or from any example +in the compiler repository (for example `examples/p2id-note/build.rs`) into each contract +crate, next to its `Cargo.toml`. The script needs `cargo miden` on `PATH`; set the +`CARGO_MIDEN` environment variable to use a specific `cargo-miden` binary instead. A missing +tool fails the build script with an install hint. + ### Kernel scalars are typed instead of `Felt` (counts, block heights, nonces, attachments) Binding surfaces whose values are counts now return `u32`: `tx::get_num_input_notes`, diff --git a/tests/integration/src/sdk/build_script.rs b/tests/integration/src/sdk/build_script.rs new file mode 100644 index 000000000..1bf9f3fc5 --- /dev/null +++ b/tests/integration/src/sdk/build_script.rs @@ -0,0 +1,315 @@ +//! Tests for the contract `build.rs` package-cache population (#1298). +//! +//! The script under test is the file the templates and examples ship, included byte-for-byte +//! from the canonical copy (the account template); [`template_build_scripts_are_identical`] +//! pins every other copy to those bytes, so these tests cover exactly what users get. The script +//! makes plain `cargo check`/`cargo build` and IDE analysis resolve compiled dependency +//! packages: outside a midenc-driven build it locates the fingerprinted package cache with +//! `cargo miden package-cache`, populates it with a nested `cargo miden build --release`, and +//! exports `MIDENC_PACKAGE_CACHE` to macro expansion. + +use std::{ + fs, + path::{Path, PathBuf}, + process::Output, +}; + +use super::basic_wallet_swapp_note_project; +use crate::cargo_proj::project; + +/// The canonical contract build script; every template ships these exact bytes. +const TEMPLATE_BUILD_SCRIPT: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../extra/templates/rust/account/template/build.rs" +)); + +/// Returns the repository root of this workspace. +fn workspace_root() -> &'static Path { + Path::new(env!("CARGO_MANIFEST_DIR")) + .parent() + .and_then(Path::parent) + .expect("the integration tests live under tests/integration") +} + +/// Every template and every Miden example must ship the canonical build script byte-for-byte. +/// +/// This is what entitles the tests in this module to speak for all of them while executing +/// one included copy. +#[test] +fn template_build_scripts_are_identical() { + let templates = workspace_root().join("extra").join("templates"); + let mut copies: Vec = [ + "rust/auth-component/template/build.rs", + "rust/note/template/build.rs", + "rust/tx-script/template/build.rs", + "rust/program/template/build.rs", + "project/contracts/counter-account/build.rs", + "project/contracts/increment-note/build.rs", + ] + .into_iter() + .map(|copy| templates.join(copy)) + .collect(); + + // Every example that is a Miden project must carry the script too, so IDE analysis of the + // examples works the same way it does for generated projects. + let examples = workspace_root().join("examples"); + for entry in fs::read_dir(&examples).expect("failed to list the examples directory") { + let example = entry.expect("failed to read an examples entry").path(); + if example.join("miden-project.toml").is_file() { + copies.push(example.join("build.rs")); + } + } + assert!(copies.len() > 6, "the examples walk must find Miden example projects"); + + for copy_path in copies { + let bytes = fs::read(©_path) + .unwrap_or_else(|err| panic!("missing build.rs copy '{}': {err}", copy_path.display())); + assert_eq!( + bytes, + TEMPLATE_BUILD_SCRIPT.as_bytes(), + "'{}' differs from the canonical rust/account/template/build.rs; keep every build.rs \ + copy byte-identical", + copy_path.display() + ); + } +} + +/// Builds the workspace's `cargo-miden` binary once and returns its path. +fn cargo_miden_binary() -> &'static Path { + static BINARY: std::sync::OnceLock = std::sync::OnceLock::new(); + BINARY.get_or_init(|| { + let workspace_root = workspace_root(); + let output = std::process::Command::new("cargo") + .args(["build", "-p", "cargo-miden", "--bin", "cargo-miden"]) + .current_dir(workspace_root) + .output() + .expect("failed to spawn cargo to build cargo-miden"); + assert!( + output.status.success(), + "failed to build cargo-miden:\n{}", + String::from_utf8_lossy(&output.stderr) + ); + let target_dir = std::env::var_os("CARGO_TARGET_DIR") + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from("target")); + // A relative target dir resolves against the build's working directory above. + let target_dir = if target_dir.is_absolute() { + target_dir + } else { + workspace_root.join(target_dir) + }; + target_dir.join("debug").join("cargo-miden") + }) +} + +/// Runs a plain (non-midenc) `cargo check` of `consumer`, the way an IDE does. +fn plain_cargo_check(consumer: &Path) -> Output { + std::process::Command::new("cargo") + .arg("check") + .env("CARGO_MIDEN", cargo_miden_binary()) + .env_remove("MIDENC_PACKAGE_CACHE") + .env_remove("CARGO_TARGET_DIR") + .env_remove("RUSTFLAGS") + .env_remove("CARGO_ENCODED_RUSTFLAGS") + .current_dir(consumer) + .output() + .expect("failed to spawn cargo check") +} + +/// Asserts one check succeeded, with its stderr in the failure message. +#[track_caller] +fn assert_check_succeeded(phase: &str, output: &Output) { + assert!( + output.status.success(), + "{phase}: plain cargo check must succeed with the template build script:\n{}", + String::from_utf8_lossy(&output.stderr) + ); +} + +/// Finds the cached `basic-wallet.masp` under the consumer's fingerprinted package cache. +fn cached_basic_wallet(consumer: &Path) -> Option { + let packages_root = consumer.join("target").join("miden").join("packages"); + fs::read_dir(&packages_root) + .ok()? + .filter_map(|entry| Some(entry.ok()?.path())) + .filter(|path| path.is_dir()) + .map(|fingerprint_dir| fingerprint_dir.join("basic-wallet.masp")) + .find(|path| path.is_file()) +} + +/// A plain `cargo check` (the LSP flow) must resolve dependency packages through the template +/// build script, refresh them when dependency sources change, and recover a pruned cache. +/// +/// Three phases against one generated basic-wallet/swapp-note pair: +/// 1. the first check populates the fingerprinted cache with a nested +/// `cargo miden build --release` and exports `MIDENC_PACKAGE_CACHE` to macro expansion; +/// 2. editing the dependency's source re-runs the script through its `watch=` list (the +/// dependency `src` directory) and republishes a package with different contents; +/// 3. deleting the fingerprint directory re-runs the script through its missing-watched-path +/// rule and repopulates the cache. +#[test] +fn rust_sdk_build_script_populates_package_cache_for_plain_cargo_check() { + let swapp_note_source = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../fixtures/components/swapp-note/src/lib.rs" + )); + let project = basic_wallet_swapp_note_project( + "build_script_package_cache", + swapp_note_source, + Some(TEMPLATE_BUILD_SCRIPT), + ); + let consumer = project.root().join("swapp-note"); + let dependency_source = project.root().join("basic-wallet").join("src").join("lib.rs"); + + // Phase 1: the first check populates the cache. + assert_check_succeeded("initial check", &plain_cargo_check(&consumer)); + let cached = cached_basic_wallet(&consumer).expect( + "the first check must publish basic-wallet.masp into a fingerprint directory of the \ + consumer's package cache", + ); + let original_package = fs::read(&cached).expect("failed to read the cached package"); + + // Phase 2: a dependency source edit must reach the cache through the watch list. + let original_source = fs::read_to_string(&dependency_source).unwrap(); + let mutation_anchor = " self.add_asset(asset);"; + assert_eq!( + original_source.matches(mutation_anchor).count(), + 1, + "the basic-wallet mutation anchor must match exactly once" + ); + let changed_source = original_source.replacen( + mutation_anchor, + " self.add_asset(asset);\n self.remove_asset(asset);\n \ + self.add_asset(asset);", + 1, + ); + fs::write(&dependency_source, changed_source).unwrap(); + + assert_check_succeeded("check after dependency edit", &plain_cargo_check(&consumer)); + let refreshed = cached_basic_wallet(&consumer) + .expect("the cache must still hold basic-wallet.masp after the dependency edit"); + let refreshed_package = fs::read(&refreshed).expect("failed to read the refreshed package"); + assert_ne!( + refreshed_package, original_package, + "editing the dependency source must republish a different basic-wallet package" + ); + + // Phase 3: a pruned cache directory is a missing watched path and must be repopulated. + let fingerprint_dir = refreshed.parent().expect("a cached package lives in a directory"); + fs::remove_dir_all(fingerprint_dir).expect("failed to prune the package cache"); + + assert_check_succeeded("check after cache prune", &plain_cargo_check(&consumer)); + assert!( + cached_basic_wallet(&consumer).is_some(), + "the check after pruning must repopulate the package cache" + ); +} + +/// The p2id-note example must pass an IDE-style plain `cargo check` in place, through its +/// shipped build script, with the basic-wallet dependency package resolved from the cache. +/// +/// Other tests build this example through the driven pipeline concurrently, and cache +/// preparation prunes every unlocked sibling fingerprint directory. The test therefore joins +/// the cache liveness protocol: it resolves its fingerprint directory up front with the same +/// `cargo miden package-cache --release` query the build script runs, and holds the shared +/// sibling lock across the check and the assertion, so concurrent pruners skip this cache the +/// same way they skip any live build's. +#[test] +fn rust_sdk_build_script_p2id_note_plain_cargo_check() { + let consumer = workspace_root().join("examples").join("p2id-note"); + + let query = std::process::Command::new(cargo_miden_binary()) + .args(["miden", "package-cache", "--release"]) + .env_remove("MIDENC_PACKAGE_CACHE") + .env_remove("CARGO_TARGET_DIR") + .env_remove("RUSTFLAGS") + .env_remove("CARGO_ENCODED_RUSTFLAGS") + .current_dir(&consumer) + .output() + .expect("failed to spawn cargo miden package-cache"); + assert!( + query.status.success(), + "the package-cache query must succeed:\n{}", + String::from_utf8_lossy(&query.stderr) + ); + let stdout = String::from_utf8(query.stdout).unwrap(); + let cache_dir = PathBuf::from( + stdout + .lines() + .find_map(|line| line.strip_prefix("cache-dir=")) + .expect("the query must name the cache directory"), + ); + + let lock_path = cache_dir.with_extension("lock"); + fs::create_dir_all(lock_path.parent().expect("a fingerprint lock has a packages parent")) + .expect("failed to create the package cache parent"); + let cache_liveness_lock = fs::OpenOptions::new() + .read(true) + .write(true) + .create(true) + .truncate(false) + .open(&lock_path) + .expect("failed to open the cache liveness lock"); + cache_liveness_lock + .lock_shared() + .expect("failed to hold the cache liveness lock"); + + assert_check_succeeded("p2id-note check", &plain_cargo_check(&consumer)); + // In place, a concurrent driven build's cache could also hold basic-wallet.masp, so the + // assertion targets this check's own fingerprint directory. + assert!( + cache_dir.join("basic-wallet.masp").is_file(), + "the check must publish basic-wallet.masp into '{}'", + cache_dir.display() + ); + drop(cache_liveness_lock); +} + +/// A missing `cargo-miden` is a hard build-script error with an actionable message. +#[test] +fn rust_sdk_build_script_fails_without_cargo_miden() { + let project = project("build_script_missing_tool") + .file( + "Cargo.toml", + r#" +[package] +name = "missing-tool" +version = "0.1.0" +edition = "2024" + +[lib] +crate-type = ["rlib"] +"#, + ) + .file( + "miden-project.toml", + r#" +[package] +name = "missing-tool" +version = "0.1.0" + +[lib] +kind = "account-component" +namespace = "miden:missing-tool/missing-tool@0.1.0" +path = "src/lib.rs" +"#, + ) + .file("build.rs", TEMPLATE_BUILD_SCRIPT) + .file("src/lib.rs", "") + .build(); + + let output = std::process::Command::new("cargo") + .arg("check") + .env("CARGO_MIDEN", project.root().join("definitely-missing-cargo-miden")) + .env_remove("MIDENC_PACKAGE_CACHE") + .env_remove("CARGO_TARGET_DIR") + .current_dir(project.root()) + .output() + .expect("failed to spawn cargo check"); + assert!(!output.status.success(), "cargo check must fail without cargo-miden"); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("failed to run `cargo miden package-cache`"), + "the build script must name the missing tool, got:\n{stderr}" + ); +} diff --git a/tests/integration/src/sdk/mod.rs b/tests/integration/src/sdk/mod.rs index 96ac69590..f14c4aba5 100644 --- a/tests/integration/src/sdk/mod.rs +++ b/tests/integration/src/sdk/mod.rs @@ -14,6 +14,7 @@ use crate::{ }; mod base; +mod build_script; mod canonabi; mod macros; mod stdlib; @@ -128,6 +129,32 @@ fn assert_component_export_signatures_match_wit(package: &miden_mast_package::Pa /// Creates a generated workspace containing the existing basic-wallet/swapp-note FPI pair. #[track_caller] fn fpi_package_cache_regression_project() -> crate::Project { + let original_swapp_note_source = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../fixtures/components/swapp-note/src/lib.rs" + )); + let swapp_note_mutation = " let offered_asset = ¬e_assets[0];"; + assert_eq!( + original_swapp_note_source.matches(swapp_note_mutation).count(), + 1, + "the swapp-note fixture mutation must match exactly once" + ); + let swapp_note_source = original_swapp_note_source.replacen( + swapp_note_mutation, + " let offered_asset = ¬e_assets[0];\n let foreign_wallet = \ + Wallet::new(self.creator);\n foreign_wallet.receive_asset(*offered_asset);", + 1, + ); + basic_wallet_swapp_note_project("fpi_package_cache_stale_root", &swapp_note_source, None) +} + +/// Creates a generated workspace with the basic-wallet/swapp-note pair and optional build script. +#[track_caller] +fn basic_wallet_swapp_note_project( + name: &str, + swapp_note_source: &str, + swapp_note_build_script: Option<&str>, +) -> crate::Project { let sdk_path = sdk_crate_path(); let workspace_manifest = r#" [workspace] @@ -195,24 +222,7 @@ path = "src/lib.rs" [dependencies] basic-wallet = { path = "../basic-wallet" } "#; - let original_swapp_note_source = include_str!(concat!( - env!("CARGO_MANIFEST_DIR"), - "/../fixtures/components/swapp-note/src/lib.rs" - )); - let swapp_note_mutation = " let offered_asset = ¬e_assets[0];"; - assert_eq!( - original_swapp_note_source.matches(swapp_note_mutation).count(), - 1, - "the swapp-note fixture mutation must match exactly once" - ); - let swapp_note_source = original_swapp_note_source.replacen( - swapp_note_mutation, - " let offered_asset = ¬e_assets[0];\n let foreign_wallet = \ - Wallet::new(self.creator);\n foreign_wallet.receive_asset(*offered_asset);", - 1, - ); - - project("fpi_package_cache_stale_root") + let mut builder = project(name) .file("Cargo.toml", workspace_manifest) .file( ".cargo/config.toml", @@ -238,8 +248,11 @@ basic-wallet = { path = "../basic-wallet" } ) .file("swapp-note/Cargo.toml", &swapp_note_cargo) .file("swapp-note/miden-project.toml", swapp_note_miden_manifest) - .file("swapp-note/src/lib.rs", &swapp_note_source) - .build() + .file("swapp-note/src/lib.rs", swapp_note_source); + if let Some(build_script) = swapp_note_build_script { + builder = builder.file("swapp-note/build.rs", build_script); + } + builder.build() } /// Reads the named dependency package from a compiled consumer's filesystem cache. diff --git a/tests/support/src/compiler_test.rs b/tests/support/src/compiler_test.rs index 56f9e8c7f..38b70d9c4 100644 --- a/tests/support/src/compiler_test.rs +++ b/tests/support/src/compiler_test.rs @@ -331,8 +331,6 @@ impl CompilerTestBuilder { None }; - maybe_dump_cargo_expand(&config, rustflags_env.as_deref()); - argv.extend(self.midenc_flags.iter().cloned()); setup::install_reporting_hooks(); @@ -344,12 +342,18 @@ impl CompilerTestBuilder { argv, ) .unwrap_or_else(|err| err.exit()); - options.rustflags = rustflags_env; + options.rustflags = rustflags_env.clone(); options.link_modules.extend(self.link_masm_modules); let source_manager = Arc::new(DefaultSourceManager::default()); let session = Rc::new(Session::new(input.clone(), options, None, source_manager).unwrap()); + maybe_dump_cargo_expand( + &config, + rustflags_env.as_deref(), + session.filesystem_package_cache_dir().as_deref(), + ); + // The session stays pointed at the `Cargo.toml`, and that is the whole change: // the manifest is compiled as a *project*, so the namespace, target kind and // dependencies the crate declares are the ones the build uses. Extracting the @@ -1234,7 +1238,11 @@ fn maybe_dump_public_package_wit(artifact_name: &str, package: &miden_mast_packa /// the current working directory. When set to `1`, it is treated as enabled and also defaults to /// the current working directory. When set to a non-empty value other than `1`, it is treated as /// the output directory. -fn maybe_dump_cargo_expand(test: &CargoTest, rustflags_env: Option<&str>) { +fn maybe_dump_cargo_expand( + test: &CargoTest, + rustflags_env: Option<&str>, + package_cache_dir: Option<&Path>, +) { let Some(out_dir) = emit_output_dir("MIDENC_EMIT_MACRO_EXPAND") else { return; }; @@ -1263,6 +1271,12 @@ fn maybe_dump_cargo_expand(test: &CargoTest, rustflags_env: Option<&str>) { if let Some(rustflags_env) = rustflags_env { cmd.env("RUSTFLAGS", rustflags_env); } + // Point macro expansion at the session's package cache. This is also the contract-build + // script's recursion guard, so a fixture with a `build.rs` expands instead of spawning a + // nested `cargo miden build` from inside `cargo expand`. + if let Some(package_cache_dir) = package_cache_dir { + cmd.env("MIDENC_PACKAGE_CACHE", package_cache_dir); + } let output = cmd.output().unwrap_or_else(|err| { panic!("failed to invoke 'cargo expand' (is cargo-expand installed?): {err}") diff --git a/tools/cargo-miden/src/cli.rs b/tools/cargo-miden/src/cli.rs index 59a184db8..36dc1325a 100644 --- a/tools/cargo-miden/src/cli.rs +++ b/tools/cargo-miden/src/cli.rs @@ -1,6 +1,6 @@ use clap::{Parser, Subcommand}; -use crate::commands::{BuildCommand, NewCommand, TestCommand}; +use crate::commands::{BuildCommand, NewCommand, PackageCacheCommand, TestCommand}; /// Top-level command-line interface for `cargo-miden`. #[derive(Debug, Parser)] @@ -25,4 +25,9 @@ pub enum CargoMidenCommand { Build(BuildCommand), /// Run the miden-tests in the project. Test(TestCommand), + /// Print the package-cache location and build-script inputs of the current project. + /// + /// Contract build scripts use this to populate `MIDENC_PACKAGE_CACHE` for builds that + /// `midenc` does not drive. + PackageCache(PackageCacheCommand), } diff --git a/tools/cargo-miden/src/commands/mod.rs b/tools/cargo-miden/src/commands/mod.rs index 5a186686a..a7512e438 100644 --- a/tools/cargo-miden/src/commands/mod.rs +++ b/tools/cargo-miden/src/commands/mod.rs @@ -1,7 +1,9 @@ pub mod build; pub mod new_project; +pub mod package_cache; pub mod test; pub use build::BuildCommand; pub use new_project::NewCommand; +pub use package_cache::PackageCacheCommand; pub use test::TestCommand; diff --git a/tools/cargo-miden/src/commands/package_cache.rs b/tools/cargo-miden/src/commands/package_cache.rs new file mode 100644 index 000000000..a8d0e698a --- /dev/null +++ b/tools/cargo-miden/src/commands/package_cache.rs @@ -0,0 +1,65 @@ +use std::rc::Rc; + +use anyhow::{Result, anyhow}; +use clap::Args; +use midenc_compile::Compiler; +use midenc_session::{InputFile, diagnostics::PrintDiagnostic}; + +/// Command-line arguments accepted by `cargo miden package-cache`. +/// +/// All arguments are parsed by the `midenc` compiler's argument parser, exactly like +/// `cargo miden build`. The printed cache directory therefore matches the directory a build +/// with the same arguments uses. +#[derive(Clone, Debug, Args)] +#[command(disable_version_flag = true, trailing_var_arg = true)] +pub struct PackageCacheCommand { + /// Arguments parsed by midenc (includes cargo-compatible options). + #[arg(value_name = "ARG", allow_hyphen_values = true)] + pub args: Vec, +} + +impl PackageCacheCommand { + /// Prints the package-cache location and the build-script inputs of the current project. + /// + /// The output is line oriented, one `key=value` item per line: + /// - `cache-dir=` — the fingerprinted package-cache directory of this project; + /// - `source-dependencies=` — direct dependencies compiled into the cache; + /// - `watch=` — an input a contract build script must watch (repeated). The list + /// ends with this `cargo-miden` binary itself, so a compiler update re-runs the build + /// script and rotates the emitted cache path. + pub fn exec(self) -> Result<()> { + let cwd = std::env::current_dir()?; + let compiler_opts = + Compiler::try_parse_from(cwd.clone(), &self.args).unwrap_or_else(|err| err.exit()); + + let manifest_path = match compiler_opts.manifest_path.as_deref() { + Some(manifest_path) => manifest_path.to_path_buf(), + None => cwd.join("Cargo.toml"), + }; + let input = InputFile::from_path(&manifest_path) + .map_err(|err| anyhow!("failed to read '{}': {err}", manifest_path.display()))?; + let session = Rc::new( + compiler_opts + .into_session(input, None, None) + .map_err(|err| anyhow!("{}", PrintDiagnostic::new(err)))?, + ); + + let cache_dir = session.filesystem_package_cache_dir().ok_or_else(|| { + anyhow!( + "'{}' does not locate a Miden project, so it has no package cache", + manifest_path.display() + ) + })?; + let inputs = session.package_cache_build_inputs().unwrap_or_default(); + + println!("cache-dir={}", cache_dir.display()); + println!("source-dependencies={}", inputs.source_dependency_count); + for path in &inputs.watch_paths { + println!("watch={}", path.display()); + } + if let Ok(current_exe) = std::env::current_exe() { + println!("watch={}", current_exe.display()); + } + Ok(()) + } +} diff --git a/tools/cargo-miden/src/lib.rs b/tools/cargo-miden/src/lib.rs index 6a1ca2eac..d091d916b 100644 --- a/tools/cargo-miden/src/lib.rs +++ b/tools/cargo-miden/src/lib.rs @@ -53,6 +53,10 @@ where cmd.exec()?; Ok(None) } + cli::CargoMidenCommand::PackageCache(cmd) => { + cmd.exec()?; + Ok(None) + } } } diff --git a/tools/cargo-miden/tests/mod.rs b/tools/cargo-miden/tests/mod.rs index 54ec0ea2d..80695c5d0 100755 --- a/tools/cargo-miden/tests/mod.rs +++ b/tools/cargo-miden/tests/mod.rs @@ -1,4 +1,5 @@ mod masm_dependency; mod p2id_cargo_miden_build; +mod package_cache_cmd; mod utils; mod workspace; diff --git a/tools/cargo-miden/tests/package_cache_cmd.rs b/tools/cargo-miden/tests/package_cache_cmd.rs new file mode 100644 index 000000000..7f8556221 --- /dev/null +++ b/tools/cargo-miden/tests/package_cache_cmd.rs @@ -0,0 +1,92 @@ +//! Tests for the `cargo miden package-cache` build-script query. + +use std::{env, fs, path::Path, process::Command}; + +/// Writes a minimal Miden project with the given `[dependencies]` tail. +fn write_project(dir: &Path, name: &str, dependencies: &str) { + fs::create_dir_all(dir.join("src")).unwrap(); + fs::write( + dir.join("miden-project.toml"), + format!( + "[package]\nname = \"{name}\"\nversion = \"1.0.0\"\n\n[lib]\npath = \ + \"src/lib.rs\"\n{dependencies}" + ), + ) + .unwrap(); + fs::write( + dir.join("Cargo.toml"), + format!("[package]\nname = \"{name}\"\nversion = \"1.0.0\"\n"), + ) + .unwrap(); + fs::write(dir.join("src/lib.rs"), "").unwrap(); +} + +#[test] +fn package_cache_command_prints_cache_dir_and_build_script_inputs() { + let scratch = + env::temp_dir().join(format!("cargo_miden_package_cache_cmd_{}", std::process::id())); + let _ = fs::remove_dir_all(&scratch); + let root = scratch.join("root"); + let dependency = scratch.join("dependency"); + write_project( + &root, + "root", + "\n[dependencies]\nregistry-dep = \"*\"\ndependency = { path = \"../dependency\" }\n", + ); + write_project(&dependency, "dependency", ""); + + let output = Command::new(env!("CARGO_BIN_EXE_cargo-miden")) + .args(["miden", "package-cache", "--release"]) + .current_dir(&root) + .output() + .expect("failed to run cargo-miden"); + assert!( + output.status.success(), + "package-cache failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + let stdout = String::from_utf8(output.stdout).unwrap(); + + let cache_dir = stdout + .lines() + .find_map(|line| line.strip_prefix("cache-dir=")) + .expect("the output must name the cache directory"); + let cache_dir = Path::new(cache_dir); + let canonical_root = root.canonicalize().unwrap(); + assert!( + cache_dir.starts_with(canonical_root.join("target").join("miden").join("packages")), + "the cache must live in the owned project layout, got '{}'", + cache_dir.display() + ); + let fingerprint = cache_dir.file_name().unwrap().to_str().unwrap(); + assert_eq!(fingerprint.len(), 16, "the cache directory must be a fingerprint"); + assert!( + fingerprint + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)), + "the fingerprint must be lowercase hexadecimal, got '{fingerprint}'" + ); + + let source_dependencies = stdout + .lines() + .find_map(|line| line.strip_prefix("source-dependencies=")) + .expect("the output must report the source-dependency count"); + assert_eq!(source_dependencies, "1", "only the source-project dependency counts"); + + let watches: Vec<&Path> = stdout + .lines() + .filter_map(|line| line.strip_prefix("watch=")) + .map(Path::new) + .collect(); + let watched = |suffix: &str| watches.iter().any(|path| path.ends_with(suffix)); + assert!(watched("root/miden-project.toml"), "watch list: {watches:?}"); + assert!(watched("dependency/miden-project.toml"), "watch list: {watches:?}"); + assert!(watched("dependency/src"), "watch list: {watches:?}"); + assert!(!watched("root/src"), "root sources must not be watched: {watches:?}"); + assert!( + watches.iter().any(|path| path.ends_with("cargo-miden")), + "the tool binary itself must be watched: {watches:?}" + ); + + let _ = fs::remove_dir_all(&scratch); +} From 8137743f0579a71f1aca5a738624a88af033cb7b Mon Sep 17 00:00:00 2001 From: Denys Zadorozhnyi Date: Fri, 7 Aug 2026 17:21:20 +0300 Subject: [PATCH 12/12] refactor: resolve dependency packages only from the package cache in SDK macros The macro-side resolver had grown its own dependency discovery: it walked the dependency's, enclosing workspaces', and ambient target/miden/ directories, picked the freshest stem match, adopted solitary packages, and re-checked package ids and version pins. With the package cache fingerprinted by build inputs, rewritten by every build, and exported to every macro expansion by midenc-driven builds and the contract build script, that machinery duplicated the compiler's dependency management and could observe artifacts the current build never produced. BREAKING: resolution now has exactly two paths. A manifest path that names a `.masp` file is read from that location, with no name matching, so renamed prebuilt packages from other toolchains keep working. Every other dependency is read from the `MIDENC_PACKAGE_CACHE` directory under its package name, trying the hyphen and underscore stem spellings, and the found package is trusted as-is; id, version, and digest verification belong to the compiler's project resolution and the assembler. Without a configured cache, expansion fails with instructions to build through `cargo miden build` or to add the contract `build.rs`, instead of searching the filesystem. Unit tests point resolution at per-fixture caches through a thread-local override, since the process environment is shared across parallel tests. The sibling-component test harness publishes its synthesized package into a project-local cache directory and exports the variable to its builds. Closes the discovery-cleanup follow-up of #1298 --- CHANGELOG.md | 9 + sdk/base-macros/src/dependency_package.rs | 675 +++++----------------- sdk/base-macros/src/wit_world.rs | 117 ++-- sdk/sdk/MIGRATION.md | 12 +- tests/integration/src/sdk/macros.rs | 13 +- 5 files changed, 211 insertions(+), 615 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 619ac7ff7..de7609d97 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -44,6 +44,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 embeds WIT is an error, and packages built by older Miden toolchains are rejected unless the key supplies their WIT. See the [migration guide](./sdk/sdk/MIGRATION.md) for the manifest edits and rebuild steps #1248 +- BREAKING: The SDK macros now read dependency packages only from the `MIDENC_PACKAGE_CACHE` + directory (or from a manifest path that names a `.masp` file directly). The previous search + of `target/miden/` output directories — the dependency's own, surrounding + workspaces', and ambient (`CARGO_TARGET_DIR`, `OUT_DIR`, working-directory) targets — was + removed, along with its freshest-first selection and macro-side package id and version + checks; the fingerprinted cache is rewritten by every build and its contents are trusted. + Builds driven by `cargo miden build` export the variable already; plain `cargo build`, + `cargo check`, and IDE analysis need the contract `build.rs`. An expansion without a + configured cache now fails with instructions instead of searching the filesystem #1298 ## [0.10.0-rc.1] diff --git a/sdk/base-macros/src/dependency_package.rs b/sdk/base-macros/src/dependency_package.rs index 9ab1ca660..168becc03 100644 --- a/sdk/base-macros/src/dependency_package.rs +++ b/sdk/base-macros/src/dependency_package.rs @@ -2,6 +2,13 @@ //! //! A Miden path dependency is consumed through its compiled package: the `.masp` carries both the //! dependency's embedded component WIT (read here) and its procedure roots (read by [`crate::fpi`]). +//! +//! Every dependency package comes from the build-owned package cache named by +//! `MIDENC_PACKAGE_CACHE`. A midenc-driven build compiles the dependencies, publishes them into +//! the fingerprinted cache, and exports the variable to its nested cargo builds; the contract +//! `build.rs` does the same for plain `cargo build`/`cargo check` and IDE analysis. The macros +//! never search the filesystem for packages themselves — the one exception is a dependency whose +//! manifest path names a `.masp` file directly, which is read from that explicit location. use std::{ env, fs, @@ -45,7 +52,7 @@ pub(crate) fn collect_dependency_wit_sources( for dependency in package.dependencies() { match dependency.scheme() { - miden_project::DependencyVersionScheme::Path { path, version } => { + miden_project::DependencyVersionScheme::Path { path, .. } => { let absolute_path = manifest_dir.join(path.path()); let dependency_root = fs::canonicalize(&absolute_path).map_err(|err| { Error::new( @@ -57,11 +64,8 @@ pub(crate) fn collect_dependency_wit_sources( ), ) })?; - let resolved = resolve_dependency_package( - dependency.name().as_ref(), - &dependency_root, - version.as_ref(), - )?; + let resolved = + resolve_dependency_package(dependency.name().as_ref(), &dependency_root)?; let wit_override = dependency_wit_override(package, dependency.name().as_ref())?; let wit = match (package_wit(&resolved.package, &resolved.path)?, wit_override) { (Some(_), Some(_)) => { @@ -324,15 +328,16 @@ impl core::fmt::Debug for ResolvedDependencyPackage { /// Finds and reads the `.masp` package artifact for the dependency named `name` rooted at `root`. /// -/// Every candidate located by searching is deserialized and accepted only when its package id -/// matches the dependency's name — and, when the manifest pins a version, when its version -/// satisfies the pin — so a renamed, unrelated, or outdated artifact is never adopted. A `root` -/// that is itself a `.masp` file is the manifest's explicit choice and is read without those -/// checks (the manifest key need not equal the prebuilt package's id). +/// A `root` that is itself a `.masp` file is the manifest's explicit choice and is read from +/// that location (the manifest key need not equal the prebuilt package's id). Every other +/// dependency package is read from the `MIDENC_PACKAGE_CACHE` directory under its package name, +/// trying the hyphen/underscore stem spellings the cache writers use. The cache is fingerprinted +/// by the build inputs and rewritten by every build, so the package found under the dependency's +/// name is trusted as-is; id, version, and digest verification belong to the compiler's project +/// resolution. Without a configured cache the dependency cannot be resolved at all. pub(crate) fn resolve_dependency_package( name: &str, root: &Path, - version: Option<&miden_project::VersionRequirement>, ) -> Result { if root.is_file() { return Ok(ResolvedDependencyPackage { @@ -341,384 +346,106 @@ pub(crate) fn resolve_dependency_package( }); } - let preferred_profile = env::var("PROFILE").unwrap_or_else(|_| "debug".to_string()); - let mut profiles = vec![preferred_profile.clone()]; - if preferred_profile != "release" { - profiles.push("release".to_string()); - } - if preferred_profile != "debug" { - profiles.push("debug".to_string()); - } + let Some(filesystem_cache_dir) = package_cache_dir() else { + return Err(Error::new(Span::call_site(), missing_package_cache_message(name, root))); + }; let package_stems = dependency_package_stems(name, root); - - // When midenc communicates its package cache, compiled dependency packages live there and - // nowhere else; searching the conventional output directories would find stale artifacts. - if let Some(filesystem_cache_dir) = env::var_os("MIDENC_PACKAGE_CACHE") { - let filesystem_cache_dir = PathBuf::from(filesystem_cache_dir); - let candidates = - sort_by_freshness(stem_matches_in_dir(&filesystem_cache_dir, &package_stems)?); - - let mut id_mismatches = Vec::new(); - for candidate in candidates { - let package = read_package(&candidate)?; - if package_id_matches(&package, &package_stems) { - return Ok(ResolvedDependencyPackage { - path: candidate, - package, - }); - } - id_mismatches.push((candidate, package.name.to_string())); - } - - return Err(Error::new( - Span::call_site(), - missing_cached_dependency_package_message( - name, - root, - &package_stems, - &filesystem_cache_dir, - &id_mismatches, - ), - )); - } - - let output_dirs = dependency_output_dirs(root, &profiles); - - // Candidates in preference order. Name matches are ordered freshest-first within each - // directory class: `PROFILE` is never set for proc macros, so profile order alone would let - // a stale debug package shadow a fresh release build. - let mut candidates = Vec::new(); - - let mut own_matches = Vec::new(); - for dir in output_dirs.private.iter().chain(output_dirs.shared.iter()) { - own_matches.extend(stem_matches_in_dir(dir, &package_stems)?); - } - candidates.extend(sort_by_freshness(own_matches)); - - // A solitary `.masp` is considered only in the dependency's private `/target` - // directories: a shared workspace or ambient target directory may hold a package of an - // unrelated project. - for dir in &output_dirs.private { - candidates.extend(find_solitary_package_in_dir(dir)?); - } - - let mut ambient_matches = Vec::new(); - for dir in &output_dirs.ambient { - ambient_matches.extend(stem_matches_in_dir(dir, &package_stems)?); - } - candidates.extend(sort_by_freshness(ambient_matches)); - - let mut rejected = Vec::new(); - let mut seen = Vec::new(); - for candidate in candidates { - if seen.contains(&candidate) { - continue; - } - seen.push(candidate.clone()); - - let package = read_package(&candidate)?; - if !package_id_matches(&package, &package_stems) { - rejected.push((candidate, format!("package id '{}'", package.name))); - continue; - } - if !package_version_matches(&package, version) { - rejected.push(( - candidate, - format!("version {} does not satisfy the manifest requirement", package.version), - )); - continue; + for stem in &package_stems { + let candidate = filesystem_cache_dir.join(format!("{stem}.{}", Package::EXTENSION)); + if candidate.is_file() { + return Ok(ResolvedDependencyPackage { + package: read_package(&candidate)?, + path: candidate, + }); } - return Ok(ResolvedDependencyPackage { - path: candidate, - package, - }); } Err(Error::new( Span::call_site(), - missing_dependency_package_message( + missing_cached_dependency_package_message( name, root, &package_stems, - &output_dirs, - &profiles, - &rejected, + &filesystem_cache_dir, ), )) } +/// Returns the package cache directory of this expansion, when one is configured. +fn package_cache_dir() -> Option { + #[cfg(test)] + if let Some(overridden) = TEST_PACKAGE_CACHE_DIR.with(|dir| dir.borrow().clone()) { + return overridden; + } + env::var_os("MIDENC_PACKAGE_CACHE").map(PathBuf::from) +} + +#[cfg(test)] +thread_local! { + /// Test override for the package cache directory. + /// + /// The process environment is global, so parallel unit tests cannot use it to point each + /// expansion at its own fixture cache. `Some(None)` simulates an unset variable. + static TEST_PACKAGE_CACHE_DIR: core::cell::RefCell>> = + const { core::cell::RefCell::new(None) }; +} + +/// Runs `run` with the package cache directory overridden for the current thread. +/// +/// `None` simulates a build without a configured cache. +#[cfg(test)] +pub(crate) fn with_test_package_cache_dir( + cache_dir: Option<&Path>, + run: impl FnOnce() -> R, +) -> R { + TEST_PACKAGE_CACHE_DIR.with(|dir| { + *dir.borrow_mut() = Some(cache_dir.map(Path::to_path_buf)); + }); + let result = run(); + TEST_PACKAGE_CACHE_DIR.with(|dir| { + *dir.borrow_mut() = None; + }); + result +} + /// Formats the diagnostic for a dependency package missing from the build-owned package cache. fn missing_cached_dependency_package_message( name: &str, root: &Path, package_stems: &[String], filesystem_cache_dir: &Path, - id_mismatches: &[(PathBuf, String)], ) -> String { let expected_files = package_stems .iter() .map(|stem| format!("'{stem}.masp'")) .collect::>() .join(", "); - let rejected = if id_mismatches.is_empty() { - String::new() - } else { - let rejected = id_mismatches - .iter() - .map(|(path, id)| format!("'{}' (package id '{id}')", path.display())) - .collect::>() - .join(", "); - format!(" Rejected candidates whose package id does not match: {rejected}.") - }; format!( "could not find a built `.masp` package for Miden dependency '{name}' (root '{}'). The \ SDK macros need the dependency package during Rust macro expansion to read its embedded \ WIT and procedure roots. Expected one of these package names: {expected_files}. Searched \ - MIDENC_PACKAGE_CACHE directory '{}'.{rejected} This cache is populated by the enclosing \ - midenc-driven build; compile this crate as part of that build so its dependency packages \ - are available during macro expansion.", + MIDENC_PACKAGE_CACHE directory '{}'. The cache is populated by a midenc-driven build \ + (`cargo miden build`), and by the contract `build.rs` for plain cargo builds; rebuild \ + through either so the dependency package is available during macro expansion.", root.display(), filesystem_cache_dir.display(), ) } -/// Returns true when the package's id matches one of the dependency's name stems. -/// -/// Hyphens and underscores are interchangeable between manifest keys, Cargo package names, and -/// Miden package ids, so the comparison normalizes them. -fn package_id_matches(package: &Package, package_stems: &[String]) -> bool { - let package_id = package.name.to_string().replace('-', "_"); - package_stems.iter().any(|stem| stem.replace('-', "_") == package_id) -} - -/// Returns true when the package's version satisfies the manifest's version pin, if any. -/// -/// Digest pins are enforced by the assembler at link time; checking them here would require -/// computing content digests during macro expansion, so they are accepted as-is. -fn package_version_matches( - package: &Package, - version: Option<&miden_project::VersionRequirement>, -) -> bool { - match version { - Some(miden_project::VersionRequirement::Semantic(requirement)) => { - requirement.inner().matches(&package.version) - } - Some(miden_project::VersionRequirement::Exact(exact)) => exact.version == package.version, - Some(miden_project::VersionRequirement::Digest(_)) | None => true, - } -} - -/// Sorts package paths by modification time, freshest first. -/// -/// Ties (including unreadable timestamps, which sort last) preserve the input order, i.e. the -/// most precise search directory. -fn sort_by_freshness(packages: Vec) -> Vec { - let mut packages_with_mtime = packages - .into_iter() - .map(|path| { - let mtime = fs::metadata(&path).and_then(|metadata| metadata.modified()).ok(); - (path, mtime) - }) - .collect::>(); - packages_with_mtime.sort_by_key(|(_, mtime)| core::cmp::Reverse(*mtime)); - packages_with_mtime.into_iter().map(|(path, _)| path).collect() -} - -/// Formats the diagnostic emitted when a dependency's compiled package cannot be located. -fn missing_dependency_package_message( - name: &str, - root: &Path, - package_stems: &[String], - output_dirs: &DependencyOutputDirs, - profiles: &[String], - rejected: &[(PathBuf, String)], -) -> String { - let searched = output_dirs - .private - .iter() - .chain(output_dirs.shared.iter()) - .chain(output_dirs.ambient.iter()) - .map(|dir| format!("'{}'", dir.display())) - .collect::>() - .join(", "); - let expected_files = package_stems - .iter() - .flat_map(|stem| profiles.iter().map(move |profile| format!("{stem}.masp in {profile}"))) - .collect::>() - .join(", "); - let rejected = if rejected.is_empty() { - String::new() - } else { - let rejected = rejected - .iter() - .map(|(path, reason)| format!("'{}' ({reason})", path.display())) - .collect::>() - .join(", "); - format!(" Rejected candidates that do not match the dependency: {rejected}.") - }; - let build_hint = dependency_build_hint(root); - +/// Formats the diagnostic for an expansion without a configured package cache. +fn missing_package_cache_message(name: &str, root: &Path) -> String { format!( - "could not find a built `.masp` package for Miden dependency '{name}' (root '{}'). The \ - SDK macros need the dependency package during Rust macro expansion to read its embedded \ - WIT and procedure roots. Expected one of: {expected_files}. Searched: \ - {searched}.{rejected} {build_hint}", + "the Miden package cache is not configured (MIDENC_PACKAGE_CACHE is not set), so the \ + compiled package for Miden dependency '{name}' (root '{}') cannot be resolved during \ + Rust macro expansion. Build through `cargo miden build`, which exports the variable to \ + its nested builds, or add the contract `build.rs` from a generated template so plain \ + `cargo build`/`cargo check` and IDE analysis populate and export the cache.", root.display(), ) } -/// Returns a command hint for building a dependency package before expanding dependent macros. -fn dependency_build_hint(root: &Path) -> String { - let manifest_path = root.join("Cargo.toml"); - if manifest_path.is_file() { - format!( - "Build the dependency first with `cargo miden build --manifest-path {} --release`, or \ - persist the compiled package to '{}/target/miden/' before compiling this \ - crate.", - manifest_path.display(), - root.display(), - ) - } else { - format!( - "Build the dependency first with `cargo miden build`, or persist the compiled package \ - to '{}/target/miden/' before compiling this crate.", - root.display(), - ) - } -} - -/// Candidate output directories where a dependency `.masp` may have been written. -struct DependencyOutputDirs { - /// The dependency's own `/target` directories; a package found here belongs to it. - private: Vec, - /// Target directories of the dependency root's ancestors — a surrounding workspace's target - /// holds packages of all its members, so a package here needs a name/id match. - shared: Vec, - /// Ambient directories (`CARGO_TARGET_DIR`, `OUT_DIR`, cwd targets) that may hold packages - /// of entirely unrelated projects. - ambient: Vec, -} - -/// Returns candidate output directories where a dependency `.masp` may have been written. -fn dependency_output_dirs(root: &Path, profiles: &[String]) -> DependencyOutputDirs { - // The dependency root is the most precise location for path dependencies. Prefer it over - // shared and ambient target directories so restored or previously built artifacts cannot - // shadow the package that belongs to the dependency being wrapped. - let mut private = Vec::new(); - push_profile_dirs(&mut private, root.join("target"), profiles); - - let mut shared = Vec::new(); - push_manifest_ancestor_target_profile_dirs(&mut shared, root, profiles); - push_ancestor_target_profile_dirs(&mut shared, root, profiles); - // The ancestor walks start at the root itself, re-discovering the private dirs. - shared.retain(|dir| !private.contains(dir)); - - let mut ambient = Vec::new(); - if let Ok(target_dir) = env::var("CARGO_TARGET_DIR") { - push_profile_dirs(&mut ambient, PathBuf::from(target_dir), profiles); - } - - if let Ok(out_dir) = env::var("OUT_DIR") { - for ancestor in Path::new(&out_dir).ancestors() { - push_profile_dirs(&mut ambient, ancestor.to_path_buf(), profiles); - } - } - - if let Ok(current_dir) = env::current_dir() { - push_profile_dirs(&mut ambient, current_dir.join("target"), profiles); - push_manifest_ancestor_target_profile_dirs(&mut ambient, ¤t_dir, profiles); - push_ancestor_target_profile_dirs(&mut ambient, ¤t_dir, profiles); - } - - DependencyOutputDirs { - private, - shared, - ambient, - } -} - -/// Adds `target/miden/` directories while preserving insertion order. -fn push_profile_dirs(dirs: &mut Vec, target_root: PathBuf, profiles: &[String]) { - for profile in profiles { - let dir = target_root.join("miden").join(profile); - if !dirs.iter().any(|existing| existing == &dir) { - dirs.push(dir); - } - } -} - -/// Adds `target/miden/` directories found in ancestors of `path`. -fn push_ancestor_target_profile_dirs(dirs: &mut Vec, path: &Path, profiles: &[String]) { - for ancestor in path.ancestors() { - if ancestor.file_name().is_some_and(|name| name == "target") { - push_profile_dirs(dirs, ancestor.to_path_buf(), profiles); - } - } -} - -/// Adds `target/miden/` directories for Cargo manifest ancestors. -fn push_manifest_ancestor_target_profile_dirs( - dirs: &mut Vec, - path: &Path, - profiles: &[String], -) { - for ancestor in path.ancestors() { - if ancestor.join("Cargo.toml").is_file() || ancestor.join("Cargo.lock").is_file() { - push_profile_dirs(dirs, ancestor.join("target"), profiles); - } - } -} - -/// Lists the `.masp` packages in `dir`, sorted by path. -fn packages_in_dir(dir: &Path) -> Result, Error> { - if !dir.is_dir() { - return Ok(Vec::new()); - } - - let mut packages = fs::read_dir(dir) - .map_err(|err| { - Error::new( - Span::call_site(), - format!("failed to read dependency output directory '{}': {err}", dir.display()), - ) - })? - .collect::, _>>() - .map_err(|err| { - Error::new( - Span::call_site(), - format!("failed to iterate dependency output directory '{}': {err}", dir.display()), - ) - })? - .into_iter() - .map(|entry| entry.path()) - .filter(|path| { - path.extension() - .is_some_and(|extension| extension.eq_ignore_ascii_case(Package::EXTENSION)) - }) - .collect::>(); - packages.sort(); - Ok(packages) -} - -/// Returns all packages in `dir` whose filename matches one of the dependency's name stems. -fn stem_matches_in_dir(dir: &Path, package_stems: &[String]) -> Result, Error> { - let mut packages = packages_in_dir(dir)?; - packages.retain(|path| { - path.file_stem() - .and_then(|value| value.to_str()) - .is_some_and(|file_stem| package_stems.iter().any(|stem| stem == file_stem)) - }); - Ok(packages) -} - -/// Returns the package in `dir` when it is the directory's only one, regardless of its name. -fn find_solitary_package_in_dir(dir: &Path) -> Result, Error> { - let mut packages = packages_in_dir(dir)?; - Ok((packages.len() == 1).then(|| packages.remove(0))) -} - /// Returns likely `.masp` filename stems for a dependency. fn dependency_package_stems(name: &str, root: &Path) -> Vec { let mut stems = Vec::new(); @@ -766,62 +493,6 @@ fn push_dependency_stem(stems: &mut Vec, name: &str) { #[cfg(test)] mod tests { use super::*; - - #[test] - fn missing_cached_dependency_package_message_describes_the_cache_contract() { - let stems = vec!["counter".to_string(), "counter_component".to_string()]; - let cache_dir = Path::new("/target/miden/packages/0123456789abcdef"); - - let message = missing_cached_dependency_package_message( - "counter", - Path::new("/projects/counter"), - &stems, - cache_dir, - &[], - ); - - assert!(message.contains("'counter.masp'")); - assert!(message.contains("'counter_component.masp'")); - assert!(message.contains(&cache_dir.display().to_string())); - assert!(message.contains("populated by the enclosing midenc-driven build")); - assert!(!message.contains(" in release")); - assert!(!message.contains("target/miden/")); - } - - #[test] - fn dependency_stem_preserves_package_filename_before_legacy_alias() { - let mut stems = Vec::new(); - - push_dependency_stem(&mut stems, "no-arg-account"); - - assert_eq!(stems, ["no-arg-account", "no_arg_account"]); - } - - #[test] - fn dependency_output_dirs_include_manifest_ancestor_targets() { - let temp_root = fixture_root("output-dirs"); - let workspace_root = temp_root.join("workspace"); - let dependency_root = workspace_root.join("tests/fixtures/dependency"); - std::fs::create_dir_all(&dependency_root).unwrap(); - std::fs::write(workspace_root.join("Cargo.lock"), "").unwrap(); - std::fs::write(dependency_root.join("Cargo.toml"), "").unwrap(); - - let mut dirs = Vec::new(); - push_manifest_ancestor_target_profile_dirs( - &mut dirs, - &dependency_root, - &[String::from("release")], - ); - - assert_eq!(dirs[0], dependency_root.join("target/miden/release")); - assert!( - dirs.contains(&workspace_root.join("target/miden/release")), - "expected workspace target in {dirs:?}" - ); - - std::fs::remove_dir_all(temp_root).unwrap(); - } - use crate::test_support::write_masp_fixture; /// Creates a unique fixture root under the temp dir. @@ -832,61 +503,45 @@ mod tests { root } - /// Backdates a package file so a sibling artifact is strictly fresher. - fn backdate(path: &Path) { - let stale = std::time::SystemTime::now() - std::time::Duration::from_secs(600); - std::fs::File::options() - .write(true) - .open(path) - .unwrap() - .set_modified(stale) - .unwrap(); - } - #[test] - fn prefers_the_freshest_stem_match_across_profile_dirs() { - let temp_root = fixture_root("freshest"); - let debug_path = temp_root.join("target/miden/debug/dep_fixture.masp"); - let release_path = temp_root.join("target/miden/release/dep_fixture.masp"); - write_masp_fixture(&debug_path, "dep-fixture", None); - write_masp_fixture(&release_path, "dep-fixture", None); - // `PROFILE` is unset for proc macros, so the debug dir is searched first; only the - // freshest-match rule makes the newer release artifact win. - backdate(&debug_path); - - let resolved = resolve_dependency_package("dep-fixture", &temp_root, None).unwrap(); + fn dependency_stem_preserves_package_filename_before_legacy_alias() { + let mut stems = Vec::new(); - assert_eq!(resolved.path, release_path); + push_dependency_stem(&mut stems, "no-arg-account"); - std::fs::remove_dir_all(temp_root).unwrap(); + assert_eq!(stems, ["no-arg-account", "no_arg_account"]); } #[test] - fn prefers_the_freshest_match_across_stem_aliases_in_one_dir() { - // The Cargo-name (underscore) and package-id (hyphen) naming schemes have both been used - // for `.masp` artifacts; a stale artifact under one alias must not shadow a fresh one - // under the other. - let temp_root = fixture_root("stem-aliases"); - let stale_path = temp_root.join("target/miden/debug/dep_fixture.masp"); - let fresh_path = temp_root.join("target/miden/debug/dep-fixture.masp"); - write_masp_fixture(&stale_path, "dep-fixture", None); - write_masp_fixture(&fresh_path, "dep-fixture", None); - backdate(&stale_path); + fn resolves_the_dependency_package_from_the_cache_by_stem() { + let temp_root = fixture_root("cache-hit"); + let cache_dir = temp_root.join("package-cache"); + let dependency_root = temp_root.join("dep-fixture"); + std::fs::create_dir_all(&dependency_root).unwrap(); + // The underscore spelling exercises the stem aliases: the hyphen probe misses first. + let package_path = cache_dir.join("dep_fixture.masp"); + write_masp_fixture(&package_path, "dep-fixture", None); - let resolved = resolve_dependency_package("dep-fixture", &temp_root, None).unwrap(); + let resolved = with_test_package_cache_dir(Some(&cache_dir), || { + resolve_dependency_package("dep-fixture", &dependency_root) + }) + .unwrap(); - assert_eq!(resolved.path, fresh_path); + assert_eq!(resolved.path, package_path); std::fs::remove_dir_all(temp_root).unwrap(); } #[test] - fn accepts_a_solitary_package_in_the_dependency_private_dirs() { - let temp_root = fixture_root("solitary"); - let package_path = temp_root.join("target/miden/debug/oddly_named.masp"); + fn explicit_package_file_dependency_bypasses_the_cache() { + let temp_root = fixture_root("explicit-file"); + let package_path = temp_root.join("prebuilt/renamed.masp"); write_masp_fixture(&package_path, "dep-fixture", None); - let resolved = resolve_dependency_package("dep-fixture", &temp_root, None).unwrap(); + let resolved = with_test_package_cache_dir(None, || { + resolve_dependency_package("dep-fixture", &package_path) + }) + .unwrap(); assert_eq!(resolved.path, package_path); @@ -894,77 +549,54 @@ mod tests { } #[test] - fn rejects_a_solitary_package_with_a_mismatched_id() { - let temp_root = fixture_root("solitary-mismatch"); - let package_path = temp_root.join("target/miden/debug/oddly_named.masp"); - write_masp_fixture(&package_path, "other-package", None); + fn missing_package_cache_reports_actionable_error() { + let temp_root = fixture_root("no-cache"); + let dependency_root = temp_root.join("dep-fixture"); + std::fs::create_dir_all(&dependency_root).unwrap(); - let error = resolve_dependency_package("dep-fixture", &temp_root, None) - .expect_err("a solitary package with a foreign id must not be adopted"); + let error = with_test_package_cache_dir(None, || { + resolve_dependency_package("dep-fixture", &dependency_root) + }) + .expect_err("resolution without a configured cache must fail"); let message = error.to_string(); assert!( - message.contains("could not find a built `.masp` package"), + message.contains("MIDENC_PACKAGE_CACHE is not set"), "unexpected error: {message}" ); - assert!(message.contains("package id 'other-package'"), "unexpected error: {message}"); + assert!(message.contains("cargo miden build"), "unexpected error: {message}"); + assert!(message.contains("build.rs"), "unexpected error: {message}"); std::fs::remove_dir_all(temp_root).unwrap(); } #[test] - fn does_not_adopt_a_solitary_package_from_a_shared_workspace_target() { - // The workspace-level target dir holds packages of all members; a solitary unrelated - // package there must not be adopted for a member that was never built. - let temp_root = fixture_root("workspace-solitary"); - let workspace_root = temp_root.join("workspace"); - let dependency_root = workspace_root.join("dep"); + fn missing_cached_dependency_package_reports_the_cache_contract() { + let temp_root = fixture_root("cache-miss"); + let cache_dir = temp_root.join("package-cache"); + std::fs::create_dir_all(&cache_dir).unwrap(); + let dependency_root = temp_root.join("dep-fixture"); std::fs::create_dir_all(&dependency_root).unwrap(); - std::fs::write(workspace_root.join("Cargo.lock"), "").unwrap(); - write_masp_fixture( - &workspace_root.join("target/miden/debug/unrelated.masp"), - "unrelated", - None, - ); - let error = resolve_dependency_package("dep-fixture", &dependency_root, None) - .expect_err("an unrelated solitary package in the workspace target must be ignored"); + let error = with_test_package_cache_dir(Some(&cache_dir), || { + resolve_dependency_package("dep-fixture", &dependency_root) + }) + .expect_err("an empty cache must fail resolution"); let message = error.to_string(); assert!( message.contains("could not find a built `.masp` package"), "unexpected error: {message}" ); - - std::fs::remove_dir_all(temp_root).unwrap(); - } - - #[test] - fn verifies_a_manifest_version_pin() { - // The test fixture package carries version 0.1.0. - let temp_root = fixture_root("version-pin"); - let package_path = temp_root.join("target/miden/debug/dep_fixture.masp"); - write_masp_fixture(&package_path, "dep-fixture", None); - - let satisfied = miden_project::VersionRequirement::Semantic( - miden_assembly_syntax::debuginfo::Span::unknown("^0.1".parse().unwrap()), - ); - let resolved = - resolve_dependency_package("dep-fixture", &temp_root, Some(&satisfied)).unwrap(); - assert_eq!(resolved.path, package_path); - - let unsatisfied = miden_project::VersionRequirement::Semantic( - miden_assembly_syntax::debuginfo::Span::unknown("^2.0".parse().unwrap()), - ); - let error = resolve_dependency_package("dep-fixture", &temp_root, Some(&unsatisfied)) - .expect_err("a version outside the manifest pin must reject the candidate"); - let message = error.to_string(); - + assert!(message.contains("'dep-fixture.masp'"), "unexpected error: {message}"); + assert!(message.contains("'dep_fixture.masp'"), "unexpected error: {message}"); assert!( - message.contains("does not satisfy the manifest requirement"), + message.contains(&cache_dir.display().to_string()), "unexpected error: {message}" ); - assert!(message.contains("0.1.0"), "unexpected error: {message}"); + assert!(message.contains("cargo miden build"), "unexpected error: {message}"); + assert!(message.contains("build.rs"), "unexpected error: {message}"); + assert!(!message.contains("target/miden/"), "unexpected error: {message}"); std::fs::remove_dir_all(temp_root).unwrap(); } @@ -972,12 +604,16 @@ mod tests { #[test] fn corrupt_dependency_package_reports_rebuild_hint() { let temp_root = fixture_root("corrupt"); - let package_path = temp_root.join("target/miden/debug/dep_fixture.masp"); - std::fs::create_dir_all(package_path.parent().unwrap()).unwrap(); - std::fs::write(&package_path, b"garbage").unwrap(); + let cache_dir = temp_root.join("package-cache"); + std::fs::create_dir_all(&cache_dir).unwrap(); + let dependency_root = temp_root.join("dep-fixture"); + std::fs::create_dir_all(&dependency_root).unwrap(); + std::fs::write(cache_dir.join("dep-fixture.masp"), b"garbage").unwrap(); - let error = resolve_dependency_package("dep-fixture", &temp_root, None) - .expect_err("a corrupt dependency package must fail resolution"); + let error = with_test_package_cache_dir(Some(&cache_dir), || { + resolve_dependency_package("dep-fixture", &dependency_root) + }) + .expect_err("a corrupt dependency package must fail resolution"); let message = error.to_string(); assert!(message.contains("failed to deserialize"), "unexpected error: {message}"); @@ -989,45 +625,4 @@ mod tests { std::fs::remove_dir_all(temp_root).unwrap(); } - - #[test] - fn missing_dependency_package_message_explains_macro_time_requirement() { - let temp_root = fixture_root("missing-message"); - std::fs::write(temp_root.join("Cargo.toml"), "[package]\nname = \"counter\"\n").unwrap(); - - let profiles = vec!["release".to_string(), "debug".to_string()]; - let stems = vec!["counter".to_string(), "counter_component".to_string()]; - let output_dirs = DependencyOutputDirs { - private: vec![ - temp_root.join("target/miden/release"), - temp_root.join("target/miden/debug"), - ], - shared: Vec::new(), - ambient: Vec::new(), - }; - let rejected = vec![( - temp_root.join("target/miden/debug/stray.masp"), - "package id 'stray'".to_string(), - )]; - - let message = missing_dependency_package_message( - "counter", - &temp_root, - &stems, - &output_dirs, - &profiles, - &rejected, - ); - - assert!(message.contains("could not find a built `.masp` package")); - assert!(message.contains("during Rust macro expansion")); - assert!(message.contains("embedded WIT and procedure roots")); - assert!(message.contains("counter.masp in release")); - assert!(message.contains("counter_component.masp in debug")); - assert!(message.contains("package id 'stray'")); - assert!(message.contains("cargo miden build --manifest-path")); - assert!(message.contains(&temp_root.display().to_string())); - - std::fs::remove_dir_all(temp_root).unwrap(); - } } diff --git a/sdk/base-macros/src/wit_world.rs b/sdk/base-macros/src/wit_world.rs index 6e7c0bd8a..8f90f1c12 100644 --- a/sdk/base-macros/src/wit_world.rs +++ b/sdk/base-macros/src/wit_world.rs @@ -585,21 +585,31 @@ world basic-wallet-world { crate::test_support::write_masp_fixture(package_path, "wit-world-fixture-dep", wit); } - /// Creates a dependency project root with a compiled package under `target/miden/debug`. - /// - /// The dependency and its artifact carry fixture-unique names: the package search consults - /// ambient directories (`CARGO_TARGET_DIR`, cwd targets), so a name shared with a real - /// workspace artifact could make these tests observe it instead of the fixture. + /// Creates a dependency project root with a compiled package in the fixture package cache. fn dependency_fixture_root() -> PathBuf { let unique = unique_fixture_suffix(); let root = std::env::temp_dir().join(format!("miden-base-macros-wit-world-{unique}")); write_masp_fixture( - &root.join("target/miden/debug/wit_world_fixture_dep.masp"), + &root.join("package-cache/wit_world_fixture_dep.masp"), Some(BASIC_WALLET_GENERATED_WIT), ); root } + /// Collects dependencies with the fixture's `package-cache` directory active. + /// + /// The macros read dependency packages only from the `MIDENC_PACKAGE_CACHE` directory; the + /// thread-local test override stands in for the process environment. + fn collect_with_cache( + fixture_root: &Path, + package: &miden_project::Package, + ) -> Result, syn::Error> { + crate::dependency_package::with_test_package_cache_dir( + Some(&fixture_root.join("package-cache")), + || collect_miden_dependencies(fixture_root, package, proc_macro2::Span::call_site()), + ) + } + fn empty_fixture_root() -> PathBuf { let unique = unique_fixture_suffix(); let root = std::env::temp_dir().join(format!("miden-base-macros-empty-wit-world-{unique}")); @@ -829,9 +839,7 @@ world empty-export-world { let package = package_with_dependency(dependency_root.clone()); - let dependencies = - collect_miden_dependencies(&fixture_root, &package, proc_macro2::Span::call_site()) - .unwrap(); + let dependencies = collect_with_cache(&fixture_root, &package).unwrap(); assert_eq!(dependencies.len(), 1); assert_eq!(dependencies[0].interface_names(), vec!["basic-wallet"]); @@ -840,7 +848,7 @@ world empty-export-world { assert!( dependencies[0] .package_path - .ends_with("target/miden/debug/wit_world_fixture_dep.masp"), + .ends_with("package-cache/wit_world_fixture_dep.masp"), "unexpected package path: {}", dependencies[0].package_path.display() ); @@ -858,9 +866,7 @@ world empty-export-world { let package = package_with_dependency(package_path.clone()); - let dependencies = - collect_miden_dependencies(&fixture_root, &package, proc_macro2::Span::call_site()) - .unwrap(); + let dependencies = collect_with_cache(&fixture_root, &package).unwrap(); let package_path = fs::canonicalize(package_path).expect("package path fixture must canonicalize"); @@ -879,9 +885,8 @@ world empty-export-world { fs::create_dir_all(&dependency_root).expect("dependency fixture directory must be created"); let package = package_with_dependency(dependency_root); - let error = - collect_miden_dependencies(&fixture_root, &package, proc_macro2::Span::call_site()) - .expect_err("dependency without a compiled package must fail metadata load"); + let error = collect_with_cache(&fixture_root, &package) + .expect_err("dependency without a compiled package must fail metadata load"); let message = error.to_string(); assert!( @@ -902,15 +907,12 @@ world empty-export-world { fn package_without_wit_section_reports_rebuild_error() { let fixture_root = empty_fixture_root(); let dependency_root = fixture_root.join("wit-world-fixture-dep"); - write_masp_fixture( - &dependency_root.join("target/miden/debug/wit_world_fixture_dep.masp"), - None, - ); + fs::create_dir_all(&dependency_root).expect("dependency fixture directory must be created"); + write_masp_fixture(&fixture_root.join("package-cache/wit_world_fixture_dep.masp"), None); let package = package_with_dependency(dependency_root); - let error = - collect_miden_dependencies(&fixture_root, &package, proc_macro2::Span::call_site()) - .expect_err("package without an embedded WIT section must fail metadata load"); + let error = collect_with_cache(&fixture_root, &package) + .expect_err("package without an embedded WIT section must fail metadata load"); let message = error.to_string(); assert!(message.contains("does not embed component WIT"), "unexpected error: {message}"); @@ -931,10 +933,8 @@ world empty-export-world { // named by the dependency's `wit` key in miden-project.toml. let fixture_root = empty_fixture_root(); let dependency_root = fixture_root.join("wit-world-fixture-dep"); - write_masp_fixture( - &dependency_root.join("target/miden/debug/wit_world_fixture_dep.masp"), - None, - ); + fs::create_dir_all(&dependency_root).expect("dependency fixture directory must be created"); + write_masp_fixture(&fixture_root.join("package-cache/wit_world_fixture_dep.masp"), None); let override_path = fixture_root.join("overrides/basic-wallet.wit"); fs::create_dir_all(override_path.parent().unwrap()) .expect("override fixture directory must be created"); @@ -942,9 +942,7 @@ world empty-export-world { .expect("override fixture must be written"); let package = package_with_dependency_and_wit_key(dependency_root, &override_path); - let dependencies = - collect_miden_dependencies(&fixture_root, &package, proc_macro2::Span::call_site()) - .unwrap(); + let dependencies = collect_with_cache(&fixture_root, &package).unwrap(); assert_eq!(dependencies.len(), 1); assert_eq!(dependencies[0].interface_names(), vec!["basic-wallet"]); @@ -958,19 +956,15 @@ world empty-export-world { // The `wit` key may name a directory holding exactly one top-level `.wit` file. let fixture_root = empty_fixture_root(); let dependency_root = fixture_root.join("wit-world-fixture-dep"); - write_masp_fixture( - &dependency_root.join("target/miden/debug/wit_world_fixture_dep.masp"), - None, - ); + fs::create_dir_all(&dependency_root).expect("dependency fixture directory must be created"); + write_masp_fixture(&fixture_root.join("package-cache/wit_world_fixture_dep.masp"), None); let override_dir = fixture_root.join("overrides"); fs::create_dir_all(&override_dir).expect("override fixture directory must be created"); fs::write(override_dir.join("basic-wallet.wit"), BASIC_WALLET_GENERATED_WIT) .expect("override fixture must be written"); let package = package_with_dependency_and_wit_key(dependency_root, &override_dir); - let dependencies = - collect_miden_dependencies(&fixture_root, &package, proc_macro2::Span::call_site()) - .unwrap(); + let dependencies = collect_with_cache(&fixture_root, &package).unwrap(); assert_eq!(dependencies.len(), 1); assert_eq!(dependencies[0].interface_names(), vec!["basic-wallet"]); @@ -984,16 +978,16 @@ world empty-export-world { // even before the key's path is inspected (the path here does not exist). let fixture_root = empty_fixture_root(); let dependency_root = fixture_root.join("wit-world-fixture-dep"); + fs::create_dir_all(&dependency_root).expect("dependency fixture directory must be created"); write_masp_fixture( - &dependency_root.join("target/miden/debug/wit_world_fixture_dep.masp"), + &fixture_root.join("package-cache/wit_world_fixture_dep.masp"), Some(BASIC_WALLET_GENERATED_WIT), ); let override_path = fixture_root.join("overrides/does-not-exist.wit"); let package = package_with_dependency_and_wit_key(dependency_root, &override_path); - let error = - collect_miden_dependencies(&fixture_root, &package, proc_macro2::Span::call_site()) - .expect_err("a wit key alongside embedded WIT must fail metadata load"); + let error = collect_with_cache(&fixture_root, &package) + .expect_err("a wit key alongside embedded WIT must fail metadata load"); let message = error.to_string(); assert!(message.contains("embeds component WIT"), "unexpected error: {message}"); @@ -1006,16 +1000,13 @@ world empty-export-world { fn wit_override_with_missing_path_reports_error() { let fixture_root = empty_fixture_root(); let dependency_root = fixture_root.join("wit-world-fixture-dep"); - write_masp_fixture( - &dependency_root.join("target/miden/debug/wit_world_fixture_dep.masp"), - None, - ); + fs::create_dir_all(&dependency_root).expect("dependency fixture directory must be created"); + write_masp_fixture(&fixture_root.join("package-cache/wit_world_fixture_dep.masp"), None); let override_path = fixture_root.join("overrides/does-not-exist.wit"); let package = package_with_dependency_and_wit_key(dependency_root, &override_path); - let error = - collect_miden_dependencies(&fixture_root, &package, proc_macro2::Span::call_site()) - .expect_err("a wit key pointing at a missing path must fail metadata load"); + let error = collect_with_cache(&fixture_root, &package) + .expect_err("a wit key pointing at a missing path must fail metadata load"); let message = error.to_string(); assert!( @@ -1034,10 +1025,8 @@ world empty-export-world { fn wit_override_directory_with_multiple_wit_files_reports_error() { let fixture_root = empty_fixture_root(); let dependency_root = fixture_root.join("wit-world-fixture-dep"); - write_masp_fixture( - &dependency_root.join("target/miden/debug/wit_world_fixture_dep.masp"), - None, - ); + fs::create_dir_all(&dependency_root).expect("dependency fixture directory must be created"); + write_masp_fixture(&fixture_root.join("package-cache/wit_world_fixture_dep.masp"), None); let override_dir = fixture_root.join("overrides"); fs::create_dir_all(&override_dir).expect("override fixture directory must be created"); fs::write(override_dir.join("first.wit"), BASIC_WALLET_GENERATED_WIT) @@ -1046,9 +1035,8 @@ world empty-export-world { .expect("override fixture must be written"); let package = package_with_dependency_and_wit_key(dependency_root, &override_dir); - let error = - collect_miden_dependencies(&fixture_root, &package, proc_macro2::Span::call_site()) - .expect_err("an override directory with two .wit files must fail metadata load"); + let error = collect_with_cache(&fixture_root, &package) + .expect_err("an override directory with two .wit files must fail metadata load"); let message = error.to_string(); assert!(message.contains("contains 2 `.wit` files"), "unexpected error: {message}"); @@ -1071,19 +1059,16 @@ world importer { "#; let fixture_root = empty_fixture_root(); let dependency_root = fixture_root.join("wit-world-fixture-dep"); - write_masp_fixture( - &dependency_root.join("target/miden/debug/wit_world_fixture_dep.masp"), - None, - ); + fs::create_dir_all(&dependency_root).expect("dependency fixture directory must be created"); + write_masp_fixture(&fixture_root.join("package-cache/wit_world_fixture_dep.masp"), None); let override_path = fixture_root.join("overrides/importing.wit"); fs::create_dir_all(override_path.parent().unwrap()) .expect("override fixture directory must be created"); fs::write(&override_path, importing_wit).expect("override fixture must be written"); let package = package_with_dependency_and_wit_key(dependency_root, &override_path); - let error = - collect_miden_dependencies(&fixture_root, &package, proc_macro2::Span::call_site()) - .expect_err("an override referencing a foreign package must fail metadata load"); + let error = collect_with_cache(&fixture_root, &package) + .expect_err("an override referencing a foreign package must fail metadata load"); let message = error.to_string(); assert!(message.contains("invalid WIT override"), "unexpected error: {message}"); @@ -1104,15 +1089,15 @@ world importer { "#; let fixture_root = empty_fixture_root(); let dependency_root = fixture_root.join("wit-world-fixture-dep"); + fs::create_dir_all(&dependency_root).expect("dependency fixture directory must be created"); write_masp_fixture( - &dependency_root.join("target/miden/debug/wit_world_fixture_dep.masp"), + &fixture_root.join("package-cache/wit_world_fixture_dep.masp"), Some(importing_wit), ); let package = package_with_dependency(dependency_root); - let error = - collect_miden_dependencies(&fixture_root, &package, proc_macro2::Span::call_site()) - .expect_err("embedded WIT referencing a foreign package must fail metadata load"); + let error = collect_with_cache(&fixture_root, &package) + .expect_err("embedded WIT referencing a foreign package must fail metadata load"); let message = error.to_string(); assert!( diff --git a/sdk/sdk/MIGRATION.md b/sdk/sdk/MIGRATION.md index fe462a0b6..759686243 100644 --- a/sdk/sdk/MIGRATION.md +++ b/sdk/sdk/MIGRATION.md @@ -24,10 +24,14 @@ nested `cargo miden build --release` when the project has source dependencies, a `MIDENC_PACKAGE_CACHE` to the crate's macro expansion. Inside a midenc-driven build the script does nothing. -Adoption is optional but recommended for existing projects: copy `build.rs` from a freshly -generated template contract (for example `cargo miden new --account demo`) or from any example -in the compiler repository (for example `examples/p2id-note/build.rs`) into each contract -crate, next to its `Cargo.toml`. The script needs `cargo miden` on `PATH`; set the +The build script is now required for plain cargo builds of crates with Miden source +dependencies. The SDK macros read dependency packages only from the `MIDENC_PACKAGE_CACHE` +directory (or from a manifest path naming a `.masp` file directly); they no longer search +`target/miden/` output directories, so a plain `cargo check` without the script fails +with instructions instead of finding previously built artifacts. Copy `build.rs` from a +freshly generated template contract (for example `cargo miden new --account demo`) or from any +example in the compiler repository (for example `examples/p2id-note/build.rs`) into each +contract crate, next to its `Cargo.toml`. The script needs `cargo miden` on `PATH`; set the `CARGO_MIDEN` environment variable to use a specific `cargo-miden` binary instead. A missing tool fails the build script with an install hint. diff --git a/tests/integration/src/sdk/macros.rs b/tests/integration/src/sdk/macros.rs index 3930f5497..7163bc852 100644 --- a/tests/integration/src/sdk/macros.rs +++ b/tests/integration/src/sdk/macros.rs @@ -8,6 +8,9 @@ fn cargo_check_miden_target(project: &crate::cargo_proj::Project) -> std::proces .arg("--target") .arg("wasm32-wasip2") .env("RUSTFLAGS", "--cfg miden -C target-feature=+bulk-memory,+wide-arithmetic") + // The macros read dependency packages only from this directory, the way a driven build + // or the contract build script exposes it. + .env("MIDENC_PACKAGE_CACHE", project.root().join("package-cache")) .current_dir(project.root()) .output() .expect("failed to spawn `cargo check` for the component macro regression test") @@ -636,9 +639,9 @@ world test-sibling-world { /// Builds an account component project with one sibling component dependency named `test-sibling`. /// -/// The sibling exists only as a synthesized `.masp` package (under `dep/target/miden/debug`) -/// embedding its component WIT, which is all the macros need: sibling calls resolve at link time -/// and read no procedure roots during expansion. +/// The sibling exists only as a synthesized `.masp` package in the project's `package-cache` +/// directory embedding its component WIT, which is all the macros need: sibling calls resolve at +/// link time and read no procedure roots during expansion. fn account_component_project_with_sibling_dep( name: &str, lib_rs: &str, @@ -732,7 +735,7 @@ supported-types = ["RegularAccountUpdatableCode"] .build() } -/// Synthesizes the sibling dependency `.masp` package under `dep/target/miden/debug`. +/// Synthesizes the sibling dependency `.masp` package into the project's `package-cache`. fn write_sibling_package(cargo_proj: &crate::cargo_proj::Project, wit: Option<&str>) { use miden_assembly::{Assembler, DefaultSourceManager, ModuleParser, ast::ModuleKind}; use miden_core::serde::Serializable; @@ -759,7 +762,7 @@ fn write_sibling_package(cargo_proj: &crate::cargo_proj::Project, wit: Option<&s .push(miden_mast_package::Section::new(wit_section_id, wit.as_bytes().to_vec())); } - let package_dir = cargo_proj.root().join("dep/target/miden/debug"); + let package_dir = cargo_proj.root().join("package-cache"); std::fs::create_dir_all(&package_dir).expect("sibling package directory must be created"); std::fs::write(package_dir.join("test_sibling.masp"), package.to_bytes()) .expect("sibling package fixture must be written");