lib

Core libraries for Radroots
git clone https://radroots.dev/git/lib.git
Log | Files | Refs | README

commit cb61277189af34ef5fc233ded9f2ad1182d74f09
parent 865378c0bdefc74fae2320e41ea5f5727d444cee
Author: triesap <tyson@radroots.org>
Date:   Thu,  6 Aug 2026 05:22:43 +0000

xtask: converge canonical sdk generation

- consolidate the SDK generator under the governed xtask command surface
- enforce explicit source, consumer, artifact, and deterministic output boundaries
- qualify imported SDK crates with exact catalogs, fixtures, coverage, and release gates
- preserve hermetic checks while removing the superseded imported tool surface

Diffstat:
MCargo.lock | 8++++++++
Mbuild/nix/common.nix | 9+++++++--
Mcontracts/consolidation/baseline.v1.toml | 7+------
Mcontracts/coverage-profiles.toml | 5+++++
Mcontracts/coverage.toml | 30++++++++++++++++++++++++++++++
Mcontracts/crates/catalog.v1.toml | 452+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------
Mcontracts/crates/generated/package_groups.v1.toml | 2+-
Mcontracts/crates/generated/platform_inventory.v1.toml | 2+-
Mcontracts/crates/generated/release_inventory.v2.toml | 2+-
Mcontracts/operations.toml | 21++++-----------------
Mcontracts/storage/backup_capture_policy_v1.toml | 7++++++-
Mcontracts/storage/backup_finalize_policy_v1.toml | 11++++++++++-
Mcontracts/storage/failure_injection_policy_v1.toml | 59+++++++++++++++++++++++++++++++++++++++++++++++++++++------
Mcontracts/storage/legacy_import_backup_policy_v1.toml | 7++++++-
Mcontracts/storage/legacy_import_journal_policy_v1.toml | 7++++++-
Mcontracts/storage/legacy_import_qualification_v1.toml | 21+++++++++++++++++++--
Mcontracts/storage/legacy_private_staging_policy_v1.toml | 17+++++++++++++++--
Mcontracts/storage/legacy_schema_classification_v1.toml | 7++++++-
Mcontracts/storage/private_schema_v1.toml | 79++++++++++++++++++++++++++++++++++++++-----------------------------------------
Mcontracts/storage/restore_finalize_policy_v1.toml | 11+++++++++--
Mcontracts/storage/restore_staging_policy_v1.toml | 8+++++++-
Mcontracts/storage/runtime_schema_v1.toml | 102++++++++++++++++++++++++++++++++++++++++----------------------------------------
Mcrates/blossom/Cargo.toml | 11++++++++++-
Mcrates/core/Cargo.toml | 12+++++++++++-
Mcrates/event/Cargo.toml | 11++++++++++-
Acrates/event_bindings/tests/fixtures/generated_constants.ts | 5+++++
Acrates/event_bindings/tests/fixtures/generated_kinds.ts | 149+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/event_bindings/tests/fixtures/generated_types.ts | 197+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/event_codec/Cargo.toml | 11++++++++++-
Mcrates/geonames/Cargo.toml | 11++++++++++-
Mcrates/identity/Cargo.toml | 12+++++++++++-
Mcrates/identity/src/username.rs | 2+-
Acrates/identity_bindings/tests/fixtures/generated_constants.ts | 5+++++
Mcrates/nostr/Cargo.toml | 21+++++++++++++++++----
Mcrates/nostr_connect/Cargo.toml | 11++++++++++-
Mcrates/protocol/Cargo.toml | 11++++++++++-
Mcrates/radroots/Cargo.toml | 11++++++++++-
Mcrates/radroots/LICENSE-APACHE | 2+-
Mcrates/radroots/LICENSE-MIT | 2+-
Acrates/replica_schema_bindings/tests/fixtures/generated_types.ts | 583+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/replica_sync_wasm/Cargo.toml | 5++++-
Mcrates/sdk/Cargo.toml | 57+++++++++++++++++++++++++++------------------------------
Mcrates/sdk/LICENSE-APACHE | 2+-
Mcrates/sdk/LICENSE-MIT | 2+-
Mcrates/sdk_ffi/src/bin/bindgen.rs | 3+++
Mcrates/secrets/Cargo.toml | 13++++++++++++-
Mcrates/signing/Cargo.toml | 11++++++++++-
Mcrates/storage/Cargo.toml | 17+++++++++++++++--
Mcrates/storage_sqlite/Cargo.toml | 29++++++++++++++++++++++++-----
Mcrates/sync/Cargo.toml | 10+++++++++-
Mcrates/test_fixtures/Cargo.toml | 6+-----
Mcrates/trade/Cargo.toml | 24++++++++++++------------
Mcrates/trade_bindings/Cargo.toml | 3+++
Mcrates/trade_bindings/src/lib.rs | 67++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Acrates/trade_bindings/tests/fixtures/generated_types.ts | 74++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/transport/Cargo.toml | 18++++++++++++------
Mcrates/transport_nostr/Cargo.toml | 22+++++++++++++++++++---
Mdocs/implementation/deviations.toml | 34+++++++++++++++++++++++++++++++---
Mfuzz/event_codec/Cargo.toml | 17+++++++++++++----
Dtools/sdk_xtask_import/Cargo.toml.imported | 33---------------------------------
Dtools/sdk_xtask_import/src/api_qualification.rs | 139-------------------------------------------------------------------------------
Dtools/sdk_xtask_import/src/architecture.rs | 1511-------------------------------------------------------------------------------
Dtools/sdk_xtask_import/src/architecture/api_leakage.rs | 1433-------------------------------------------------------------------------------
Dtools/sdk_xtask_import/src/architecture/dependency_boundary.rs | 718-------------------------------------------------------------------------------
Dtools/sdk_xtask_import/src/bindings.rs | 281-------------------------------------------------------------------------------
Dtools/sdk_xtask_import/src/check.rs | 3710-------------------------------------------------------------------------------
Dtools/sdk_xtask_import/src/contracts.rs | 694-------------------------------------------------------------------------------
Dtools/sdk_xtask_import/src/coverage.rs | 156-------------------------------------------------------------------------------
Dtools/sdk_xtask_import/src/coverage_policy.rs | 803-------------------------------------------------------------------------------
Dtools/sdk_xtask_import/src/coverage_policy_tests.rs | 661-------------------------------------------------------------------------------
Dtools/sdk_xtask_import/src/dto_roots.rs | 969-------------------------------------------------------------------------------
Dtools/sdk_xtask_import/src/fs.rs | 61-------------------------------------------------------------
Dtools/sdk_xtask_import/src/generate.rs | 35-----------------------------------
Dtools/sdk_xtask_import/src/main.rs | 281-------------------------------------------------------------------------------
Dtools/sdk_xtask_import/src/manifest.rs | 53-----------------------------------------------------
Dtools/sdk_xtask_import/src/output.rs | 493-------------------------------------------------------------------------------
Dtools/sdk_xtask_import/src/package_metadata.rs | 181-------------------------------------------------------------------------------
Dtools/sdk_xtask_import/src/portable_qualification.rs | 58----------------------------------------------------------
Dtools/sdk_xtask_import/src/release_graph.rs | 687-------------------------------------------------------------------------------
Dtools/sdk_xtask_import/src/release_qualification.rs | 135-------------------------------------------------------------------------------
Dtools/sdk_xtask_import/src/smoke.rs | 236-------------------------------------------------------------------------------
Dtools/sdk_xtask_import/src/supply_chain_qualification.rs | 729-------------------------------------------------------------------------------
Dtools/sdk_xtask_import/src/target_qualification.rs | 199-------------------------------------------------------------------------------
Dtools/sdk_xtask_import/src/wasm.rs | 1147-------------------------------------------------------------------------------
Dtools/sdk_xtask_import/src/wasm_declarations.rs | 1147-------------------------------------------------------------------------------
Dtools/sdk_xtask_import/tests/fixtures/api-leakage/adr-exception.rs | 3---
Dtools/sdk_xtask_import/tests/fixtures/api-leakage/allowed-concrete-adapter.rs | 3---
Dtools/sdk_xtask_import/tests/fixtures/api-leakage/generic-renamed-tokio.rs | 5-----
Dtools/sdk_xtask_import/tests/fixtures/api-leakage/generic-sqlx.rs | 5-----
Dtools/sdk_xtask_import/tests/fixtures/api-leakage/private-implementation.rs | 11-----------
Dtools/sdk_xtask_import/tests/fixtures/dependency-boundaries/domain-to-storage.json | 46----------------------------------------------
Dtools/sdk_xtask_import/tests/fixtures/dependency-boundaries/service-to-sdk.json | 46----------------------------------------------
Dtools/sdk_xtask_import/tests/fixtures/dependency-boundaries/spi-to-adapter.json | 46----------------------------------------------
Dtools/sdk_xtask_import/tests/fixtures/dependency-boundaries/valid-downward.json | 46----------------------------------------------
Mtools/xtask/Cargo.toml | 8++++++++
Mtools/xtask/src/build_control.rs | 46+++++++++++++++++++++++++++++++++++++---------
Mtools/xtask/src/coverage.rs | 40++++++++++++++++++++++++++++++----------
Mtools/xtask/src/hygiene.rs | 4++--
Mtools/xtask/src/main.rs | 44+++++++++++++++++++++++++++++++-------------
Atools/xtask/src/sdk_generation/bindings.rs | 362+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Atools/xtask/src/sdk_generation/contracts.rs | 690+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Atools/xtask/src/sdk_generation/dto_roots.rs | 851+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Atools/xtask/src/sdk_generation/fs.rs | 38++++++++++++++++++++++++++++++++++++++
Atools/xtask/src/sdk_generation/generate.rs | 92+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Atools/xtask/src/sdk_generation/manifest.rs | 53+++++++++++++++++++++++++++++++++++++++++++++++++++++
Atools/xtask/src/sdk_generation/mod.rs | 44++++++++++++++++++++++++++++++++++++++++++++
Atools/xtask/src/sdk_generation/output.rs | 494+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Rtools/sdk_xtask_import/src/package_matrix.rs -> tools/xtask/src/sdk_generation/package_matrix.rs | 0
Atools/xtask/src/sdk_generation/package_metadata.rs | 170+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Rtools/sdk_xtask_import/src/ts.rs -> tools/xtask/src/sdk_generation/ts.rs | 0
Atools/xtask/src/sdk_generation/wasm.rs | 1246+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Atools/xtask/src/sdk_generation/wasm_declarations.rs | 1150+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mtreefmt.nix | 1+
113 files changed, 7381 insertions(+), 17068 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock @@ -5733,20 +5733,28 @@ name = "xtask" version = "0.1.0-alpha" dependencies = [ "clap", + "dto_bindgen_backend_ts", "dto_bindgen_core", + "flate2", "fs2", "hex", "jsonschema", "prettyplease", "proc-macro2", "quote", + "radroots_core_bindings", + "radroots_event_bindings", "radroots_event_codec", + "radroots_identity", + "radroots_identity_bindings", "radroots_protocol", + "radroots_replica_schema_bindings", "semver", "serde", "serde_json", "sha2", "syn 2.0.117", + "tar", "tempfile", "toml 0.8.23", ] diff --git a/build/nix/common.nix b/build/nix/common.nix @@ -23,6 +23,9 @@ let darwinBuildInputs = lib.optionals pkgs.stdenv.isDarwin [ pkgs.libiconv ]; + processInspectionInputs = + lib.optionals pkgs.stdenv.isDarwin [ pkgs.unixtools.ps ] + ++ lib.optionals pkgs.stdenv.isLinux [ pkgs.procps ]; repoSource = lib.sources.cleanSource root; cargoSource = lib.fileset.toSource { root = root; @@ -101,7 +104,8 @@ let pkg-config python3 ] - ++ darwinBuildInputs; + ++ darwinBuildInputs + ++ processInspectionInputs; coverageRuntimeInputs = stableRuntimeInputs ++ [ toolchains.coverage cargoLlvmCov @@ -119,7 +123,8 @@ let pkgs.clang pkgs.llvmPackages.libclang pkgs.perl - ]; + ] + ++ processInspectionInputs; buildInputs = [ pkgs.libsodium ] diff --git a/contracts/consolidation/baseline.v1.toml b/contracts/consolidation/baseline.v1.toml @@ -73,12 +73,7 @@ worktree_count = 1 rust_version = "1.97.1" resolver = "3" package_version = "0.1.0-alpha" -commands = [ - "make format", - "make check", - "make bindings", - "make audit", -] +commands = ["make format", "make check", "make bindings", "make audit"] [[surface]] repository = "lib" diff --git a/contracts/coverage-profiles.toml b/contracts/coverage-profiles.toml @@ -22,3 +22,8 @@ test_threads = 1 no_default_features = false features = ["legacy-ingest"] test_threads = 1 + +[profiles.crates."radroots_sdk"] +no_default_features = false +features = ["full"] +test_threads = 1 diff --git a/contracts/coverage.toml b/contracts/coverage.toml @@ -15,6 +15,36 @@ require_branches = false temporary = true reason = "branch coverage is not applicable while the crate has no measured branch records" +[overrides.radroots] +require_branches = false +temporary = true +reason = "branch coverage is not applicable while the facade crate has no measured branch records" + +[overrides.radroots_core_bindings] +require_branches = false +temporary = true +reason = "branch coverage is not applicable while the binding inventory has no measured branch records" + +[overrides.radroots_event_bindings] +require_branches = false +temporary = true +reason = "branch coverage is not applicable while the binding inventory has no measured branch records" + +[overrides.radroots_identity_bindings] +require_branches = false +temporary = true +reason = "branch coverage is not applicable while the binding inventory has no measured branch records" + +[overrides.radroots_replica_schema_bindings] +require_branches = false +temporary = true +reason = "branch coverage is not applicable while the binding inventory has no measured branch records" + +[overrides.radroots_sdk_ffi] +require_branches = false +temporary = true +reason = "branch coverage is not applicable while the UniFFI facade has no measured branch records" + [overrides.radroots_test_fixtures] require_branches = false temporary = true diff --git a/contracts/crates/catalog.v1.toml b/contracts/crates/catalog.v1.toml @@ -358,7 +358,13 @@ license = "MIT OR Apache-2.0" platforms = ["native"] groups = ["coverage_required", "portable", "public_native"] owners = ["sync"] -permitted_dependency_tiers = ["foundation", "domain", "spi", "adapter", "orchestration"] +permitted_dependency_tiers = [ + "foundation", + "domain", + "spi", + "adapter", + "orchestration", +] source_repository = "https://github.com/radrootslabs/lib" source_revision = "3f35c869c827b35b27e7a7d789d409e6c3def6a8" source_path = "crates/sync" @@ -378,7 +384,14 @@ license = "MIT OR Apache-2.0" platforms = ["native"] groups = ["coverage_required", "preview"] owners = ["preview"] -permitted_dependency_tiers = ["foundation", "domain", "spi", "adapter", "orchestration", "preview"] +permitted_dependency_tiers = [ + "foundation", + "domain", + "spi", + "adapter", + "orchestration", + "preview", +] source_repository = "https://github.com/radrootslabs/lib" source_revision = "3f35c869c827b35b27e7a7d789d409e6c3def6a8" source_path = "crates/nostrdb" @@ -398,7 +411,14 @@ license = "MIT OR Apache-2.0" platforms = ["native"] groups = ["coverage_required", "preview"] owners = ["preview"] -permitted_dependency_tiers = ["foundation", "domain", "spi", "adapter", "orchestration", "preview"] +permitted_dependency_tiers = [ + "foundation", + "domain", + "spi", + "adapter", + "orchestration", + "preview", +] source_repository = "https://github.com/radrootslabs/lib" source_revision = "3f35c869c827b35b27e7a7d789d409e6c3def6a8" source_path = "crates/transport_reticulum" @@ -418,7 +438,14 @@ license = "MIT OR Apache-2.0" platforms = ["native"] groups = ["preview"] owners = ["preview"] -permitted_dependency_tiers = ["foundation", "domain", "spi", "adapter", "orchestration", "preview"] +permitted_dependency_tiers = [ + "foundation", + "domain", + "spi", + "adapter", + "orchestration", + "preview", +] source_repository = "https://github.com/radrootslabs/lib" source_revision = "3f35c869c827b35b27e7a7d789d409e6c3def6a8" source_path = "crates/simplex_app_store" @@ -438,7 +465,14 @@ license = "MIT OR Apache-2.0" platforms = ["any"] groups = ["preview"] owners = ["preview"] -permitted_dependency_tiers = ["foundation", "domain", "spi", "adapter", "orchestration", "preview"] +permitted_dependency_tiers = [ + "foundation", + "domain", + "spi", + "adapter", + "orchestration", + "preview", +] source_repository = "https://github.com/radrootslabs/lib" source_revision = "3f35c869c827b35b27e7a7d789d409e6c3def6a8" source_path = "crates/simplex_agent_proto" @@ -458,7 +492,14 @@ license = "MIT OR Apache-2.0" platforms = ["any"] groups = ["preview"] owners = ["preview"] -permitted_dependency_tiers = ["foundation", "domain", "spi", "adapter", "orchestration", "preview"] +permitted_dependency_tiers = [ + "foundation", + "domain", + "spi", + "adapter", + "orchestration", + "preview", +] source_repository = "https://github.com/radrootslabs/lib" source_revision = "3f35c869c827b35b27e7a7d789d409e6c3def6a8" source_path = "crates/simplex_chat_proto" @@ -478,7 +519,14 @@ license = "MIT OR Apache-2.0" platforms = ["any"] groups = ["preview"] owners = ["preview"] -permitted_dependency_tiers = ["foundation", "domain", "spi", "adapter", "orchestration", "preview"] +permitted_dependency_tiers = [ + "foundation", + "domain", + "spi", + "adapter", + "orchestration", + "preview", +] source_repository = "https://github.com/radrootslabs/lib" source_revision = "3f35c869c827b35b27e7a7d789d409e6c3def6a8" source_path = "crates/simplex_smp_crypto" @@ -498,7 +546,14 @@ license = "MIT OR Apache-2.0" platforms = ["any"] groups = ["preview"] owners = ["preview"] -permitted_dependency_tiers = ["foundation", "domain", "spi", "adapter", "orchestration", "preview"] +permitted_dependency_tiers = [ + "foundation", + "domain", + "spi", + "adapter", + "orchestration", + "preview", +] source_repository = "https://github.com/radrootslabs/lib" source_revision = "3f35c869c827b35b27e7a7d789d409e6c3def6a8" source_path = "crates/simplex_smp_proto" @@ -518,7 +573,14 @@ license = "MIT OR Apache-2.0" platforms = ["native"] groups = ["preview"] owners = ["preview"] -permitted_dependency_tiers = ["foundation", "domain", "spi", "adapter", "orchestration", "preview"] +permitted_dependency_tiers = [ + "foundation", + "domain", + "spi", + "adapter", + "orchestration", + "preview", +] source_repository = "https://github.com/radrootslabs/lib" source_revision = "3f35c869c827b35b27e7a7d789d409e6c3def6a8" source_path = "crates/simplex_smp_transport" @@ -538,7 +600,14 @@ license = "MIT OR Apache-2.0" platforms = ["any"] groups = ["coverage_required", "preview"] owners = ["preview"] -permitted_dependency_tiers = ["foundation", "domain", "spi", "adapter", "orchestration", "preview"] +permitted_dependency_tiers = [ + "foundation", + "domain", + "spi", + "adapter", + "orchestration", + "preview", +] source_repository = "https://github.com/radrootslabs/lib" source_revision = "3f35c869c827b35b27e7a7d789d409e6c3def6a8" source_path = "crates/replica_schema" @@ -558,7 +627,14 @@ license = "MIT OR Apache-2.0" platforms = ["native"] groups = ["coverage_required", "preview"] owners = ["preview"] -permitted_dependency_tiers = ["foundation", "domain", "spi", "adapter", "orchestration", "preview"] +permitted_dependency_tiers = [ + "foundation", + "domain", + "spi", + "adapter", + "orchestration", + "preview", +] source_repository = "https://github.com/radrootslabs/lib" source_revision = "3f35c869c827b35b27e7a7d789d409e6c3def6a8" source_path = "crates/replica_store" @@ -578,7 +654,15 @@ license = "MIT OR Apache-2.0" platforms = ["native"] groups = ["coverage_required", "preview"] owners = ["preview"] -permitted_dependency_tiers = ["foundation", "domain", "spi", "adapter", "orchestration", "preview", "fixture"] +permitted_dependency_tiers = [ + "foundation", + "domain", + "spi", + "adapter", + "orchestration", + "preview", + "fixture", +] source_repository = "https://github.com/radrootslabs/lib" source_revision = "3f35c869c827b35b27e7a7d789d409e6c3def6a8" source_path = "crates/replica_sync" @@ -598,7 +682,14 @@ license = "MIT OR Apache-2.0" platforms = ["native"] groups = ["coverage_required", "preview"] owners = ["preview"] -permitted_dependency_tiers = ["foundation", "domain", "spi", "adapter", "orchestration", "preview"] +permitted_dependency_tiers = [ + "foundation", + "domain", + "spi", + "adapter", + "orchestration", + "preview", +] source_repository = "https://github.com/radrootslabs/lib" source_revision = "3f35c869c827b35b27e7a7d789d409e6c3def6a8" source_path = "crates/mesh" @@ -618,7 +709,14 @@ license = "MIT OR Apache-2.0" platforms = ["native"] groups = ["coverage_required", "preview"] owners = ["preview"] -permitted_dependency_tiers = ["foundation", "domain", "spi", "adapter", "orchestration", "preview"] +permitted_dependency_tiers = [ + "foundation", + "domain", + "spi", + "adapter", + "orchestration", + "preview", +] source_repository = "https://github.com/radrootslabs/lib" source_revision = "3f35c869c827b35b27e7a7d789d409e6c3def6a8" source_path = "crates/mesh_agent_client" @@ -638,7 +736,14 @@ license = "MIT OR Apache-2.0" platforms = ["any"] groups = ["coverage_required", "preview"] owners = ["preview"] -permitted_dependency_tiers = ["foundation", "domain", "spi", "adapter", "orchestration", "preview"] +permitted_dependency_tiers = [ + "foundation", + "domain", + "spi", + "adapter", + "orchestration", + "preview", +] source_repository = "https://github.com/radrootslabs/lib" source_revision = "3f35c869c827b35b27e7a7d789d409e6c3def6a8" source_path = "crates/mesh_agent_proto" @@ -658,7 +763,15 @@ license = "MIT OR Apache-2.0" platforms = ["native"] groups = ["coverage_required", "preview"] owners = ["runtime"] -permitted_dependency_tiers = ["foundation", "domain", "spi", "adapter", "orchestration", "preview", "runtime"] +permitted_dependency_tiers = [ + "foundation", + "domain", + "spi", + "adapter", + "orchestration", + "preview", + "runtime", +] source_repository = "https://github.com/radrootslabs/lib" source_revision = "3f35c869c827b35b27e7a7d789d409e6c3def6a8" source_path = "crates/runtime_paths" @@ -678,7 +791,15 @@ license = "MIT OR Apache-2.0" platforms = ["native"] groups = ["coverage_required", "preview"] owners = ["runtime"] -permitted_dependency_tiers = ["foundation", "domain", "spi", "adapter", "orchestration", "preview", "runtime"] +permitted_dependency_tiers = [ + "foundation", + "domain", + "spi", + "adapter", + "orchestration", + "preview", + "runtime", +] source_repository = "https://github.com/radrootslabs/lib" source_revision = "3f35c869c827b35b27e7a7d789d409e6c3def6a8" source_path = "crates/runtime_distribution" @@ -698,7 +819,15 @@ license = "MIT OR Apache-2.0" platforms = ["native"] groups = ["coverage_required", "preview"] owners = ["runtime"] -permitted_dependency_tiers = ["foundation", "domain", "spi", "adapter", "orchestration", "preview", "runtime"] +permitted_dependency_tiers = [ + "foundation", + "domain", + "spi", + "adapter", + "orchestration", + "preview", + "runtime", +] source_repository = "https://github.com/radrootslabs/lib" source_revision = "3f35c869c827b35b27e7a7d789d409e6c3def6a8" source_path = "crates/runtime_manager" @@ -738,7 +867,16 @@ license = "MIT OR Apache-2.0" platforms = ["any"] groups = ["coverage_required", "preview"] owners = ["testing"] -permitted_dependency_tiers = ["foundation", "domain", "spi", "adapter", "orchestration", "preview", "runtime", "fixture"] +permitted_dependency_tiers = [ + "foundation", + "domain", + "spi", + "adapter", + "orchestration", + "preview", + "runtime", + "fixture", +] source_repository = "https://github.com/radrootslabs/lib" source_revision = "3f35c869c827b35b27e7a7d789d409e6c3def6a8" source_path = "crates/test_fixtures" @@ -758,7 +896,18 @@ license = "MIT OR Apache-2.0" platforms = ["native"] groups = ["coverage_required", "tools"] owners = ["architecture"] -permitted_dependency_tiers = ["foundation", "domain", "spi", "adapter", "orchestration", "preview", "runtime", "fixture", "tool"] +permitted_dependency_tiers = [ + "foundation", + "domain", + "spi", + "adapter", + "orchestration", + "preview", + "runtime", + "fixture", + "codegen", + "tool", +] source_repository = "https://github.com/radrootslabs/lib" source_revision = "3f35c869c827b35b27e7a7d789d409e6c3def6a8" source_path = "tools/xtask" @@ -778,7 +927,14 @@ license = "MIT OR Apache-2.0" platforms = ["any"] groups = ["coverage_required", "portable", "public_native", "sdk"] owners = ["sdk"] -permitted_dependency_tiers = ["foundation", "domain", "spi", "adapter", "orchestration", "sdk"] +permitted_dependency_tiers = [ + "foundation", + "domain", + "spi", + "adapter", + "orchestration", + "sdk", +] source_repository = "https://github.com/radrootslabs/sdk" source_revision = "170ecf2b620107fadca85fcb11fbf3798b4ca1ef" source_path = "crates/sdk" @@ -798,7 +954,15 @@ license = "MIT OR Apache-2.0" platforms = ["any"] groups = ["coverage_required", "portable", "public_native", "sdk"] owners = ["sdk"] -permitted_dependency_tiers = ["foundation", "domain", "spi", "adapter", "orchestration", "sdk", "facade"] +permitted_dependency_tiers = [ + "foundation", + "domain", + "spi", + "adapter", + "orchestration", + "sdk", + "facade", +] source_repository = "https://github.com/radrootslabs/sdk" source_revision = "170ecf2b620107fadca85fcb11fbf3798b4ca1ef" source_path = "crates/radroots" @@ -818,7 +982,16 @@ license = "MIT OR Apache-2.0" platforms = ["native"] groups = ["coverage_required", "sdk", "boundaries"] owners = ["sdk"] -permitted_dependency_tiers = ["foundation", "domain", "spi", "adapter", "orchestration", "sdk", "facade", "codegen"] +permitted_dependency_tiers = [ + "foundation", + "domain", + "spi", + "adapter", + "orchestration", + "sdk", + "facade", + "codegen", +] source_repository = "https://github.com/radrootslabs/sdk" source_revision = "170ecf2b620107fadca85fcb11fbf3798b4ca1ef" source_path = "crates/core_bindings" @@ -838,7 +1011,16 @@ license = "MIT OR Apache-2.0" platforms = ["native"] groups = ["coverage_required", "sdk", "boundaries"] owners = ["sdk"] -permitted_dependency_tiers = ["foundation", "domain", "spi", "adapter", "orchestration", "sdk", "facade", "codegen"] +permitted_dependency_tiers = [ + "foundation", + "domain", + "spi", + "adapter", + "orchestration", + "sdk", + "facade", + "codegen", +] source_repository = "https://github.com/radrootslabs/sdk" source_revision = "170ecf2b620107fadca85fcb11fbf3798b4ca1ef" source_path = "crates/event_bindings" @@ -858,7 +1040,16 @@ license = "MIT OR Apache-2.0" platforms = ["native"] groups = ["coverage_required", "sdk", "boundaries"] owners = ["sdk"] -permitted_dependency_tiers = ["foundation", "domain", "spi", "adapter", "orchestration", "sdk", "facade", "codegen"] +permitted_dependency_tiers = [ + "foundation", + "domain", + "spi", + "adapter", + "orchestration", + "sdk", + "facade", + "codegen", +] source_repository = "https://github.com/radrootslabs/sdk" source_revision = "170ecf2b620107fadca85fcb11fbf3798b4ca1ef" source_path = "crates/identity_bindings" @@ -878,7 +1069,16 @@ license = "MIT OR Apache-2.0" platforms = ["native"] groups = ["coverage_required", "sdk", "boundaries"] owners = ["sdk"] -permitted_dependency_tiers = ["foundation", "domain", "spi", "adapter", "orchestration", "sdk", "facade", "codegen"] +permitted_dependency_tiers = [ + "foundation", + "domain", + "spi", + "adapter", + "orchestration", + "sdk", + "facade", + "codegen", +] source_repository = "https://github.com/radrootslabs/sdk" source_revision = "170ecf2b620107fadca85fcb11fbf3798b4ca1ef" source_path = "crates/trade_bindings" @@ -898,7 +1098,17 @@ license = "MIT OR Apache-2.0" platforms = ["native"] groups = ["coverage_required", "sdk", "boundaries"] owners = ["sdk"] -permitted_dependency_tiers = ["foundation", "domain", "spi", "adapter", "orchestration", "preview", "sdk", "facade", "codegen"] +permitted_dependency_tiers = [ + "foundation", + "domain", + "spi", + "adapter", + "orchestration", + "preview", + "sdk", + "facade", + "codegen", +] source_repository = "https://github.com/radrootslabs/sdk" source_revision = "170ecf2b620107fadca85fcb11fbf3798b4ca1ef" source_path = "crates/replica_schema_bindings" @@ -918,7 +1128,16 @@ license = "MIT OR Apache-2.0" platforms = ["wasm32"] groups = ["coverage_required", "sdk", "wasm", "boundaries"] owners = ["sdk"] -permitted_dependency_tiers = ["foundation", "domain", "spi", "adapter", "orchestration", "sdk", "facade", "boundary"] +permitted_dependency_tiers = [ + "foundation", + "domain", + "spi", + "adapter", + "orchestration", + "sdk", + "facade", + "boundary", +] source_repository = "https://github.com/radrootslabs/sdk" source_revision = "170ecf2b620107fadca85fcb11fbf3798b4ca1ef" source_path = "crates/event_codec_wasm" @@ -938,7 +1157,17 @@ license = "MIT OR Apache-2.0" platforms = ["wasm32"] groups = ["coverage_required", "sdk", "wasm", "boundaries"] owners = ["sdk"] -permitted_dependency_tiers = ["foundation", "domain", "spi", "adapter", "orchestration", "preview", "sdk", "facade", "boundary"] +permitted_dependency_tiers = [ + "foundation", + "domain", + "spi", + "adapter", + "orchestration", + "preview", + "sdk", + "facade", + "boundary", +] source_repository = "https://github.com/radrootslabs/sdk" source_revision = "170ecf2b620107fadca85fcb11fbf3798b4ca1ef" source_path = "crates/replica_store_wasm" @@ -958,7 +1187,17 @@ license = "MIT OR Apache-2.0" platforms = ["wasm32"] groups = ["coverage_required", "sdk", "wasm", "boundaries"] owners = ["sdk"] -permitted_dependency_tiers = ["foundation", "domain", "spi", "adapter", "orchestration", "preview", "sdk", "facade", "boundary"] +permitted_dependency_tiers = [ + "foundation", + "domain", + "spi", + "adapter", + "orchestration", + "preview", + "sdk", + "facade", + "boundary", +] source_repository = "https://github.com/radrootslabs/sdk" source_revision = "170ecf2b620107fadca85fcb11fbf3798b4ca1ef" source_path = "crates/replica_sync_wasm" @@ -978,7 +1217,16 @@ license = "MIT OR Apache-2.0" platforms = ["native"] groups = ["coverage_required", "sdk", "boundaries"] owners = ["sdk"] -permitted_dependency_tiers = ["foundation", "domain", "spi", "adapter", "orchestration", "sdk", "facade", "boundary"] +permitted_dependency_tiers = [ + "foundation", + "domain", + "spi", + "adapter", + "orchestration", + "sdk", + "facade", + "boundary", +] source_repository = "https://github.com/radrootslabs/sdk" source_revision = "170ecf2b620107fadca85fcb11fbf3798b4ca1ef" source_path = "crates/ffi" @@ -1018,7 +1266,16 @@ license = "GPL-3.0-or-later" platforms = ["native", "wasm32"] groups = ["mobile"] owners = ["mobile"] -permitted_dependency_tiers = ["foundation", "domain", "spi", "adapter", "orchestration", "sdk", "facade", "runtime"] +permitted_dependency_tiers = [ + "foundation", + "domain", + "spi", + "adapter", + "orchestration", + "sdk", + "facade", + "runtime", +] source_repository = "https://github.com/radrootslabs/app_rt" source_revision = "7ab1a8624d50890d6d18545ffb47d8083afa8c67" source_path = "crates/core" @@ -1039,7 +1296,17 @@ license = "GPL-3.0-or-later" platforms = ["apple", "android"] groups = ["mobile", "boundaries"] owners = ["mobile"] -permitted_dependency_tiers = ["foundation", "domain", "spi", "adapter", "orchestration", "sdk", "facade", "runtime", "boundary"] +permitted_dependency_tiers = [ + "foundation", + "domain", + "spi", + "adapter", + "orchestration", + "sdk", + "facade", + "runtime", + "boundary", +] source_repository = "https://github.com/radrootslabs/app_rt" source_revision = "7ab1a8624d50890d6d18545ffb47d8083afa8c67" source_path = "crates/ffi" @@ -1060,7 +1327,17 @@ license = "GPL-3.0-or-later" platforms = ["wasm32"] groups = ["mobile", "wasm", "boundaries"] owners = ["mobile"] -permitted_dependency_tiers = ["foundation", "domain", "spi", "adapter", "orchestration", "sdk", "facade", "runtime", "boundary"] +permitted_dependency_tiers = [ + "foundation", + "domain", + "spi", + "adapter", + "orchestration", + "sdk", + "facade", + "runtime", + "boundary", +] source_repository = "https://github.com/radrootslabs/app_rt" source_revision = "7ab1a8624d50890d6d18545ffb47d8083afa8c67" source_path = "crates/wasm" @@ -1081,7 +1358,18 @@ license = "GPL-3.0-or-later" platforms = ["native"] groups = ["mobile", "boundaries"] owners = ["mobile"] -permitted_dependency_tiers = ["foundation", "domain", "spi", "adapter", "orchestration", "sdk", "facade", "runtime", "boundary", "codegen"] +permitted_dependency_tiers = [ + "foundation", + "domain", + "spi", + "adapter", + "orchestration", + "sdk", + "facade", + "runtime", + "boundary", + "codegen", +] source_repository = "https://github.com/radrootslabs/app_rt" source_revision = "7ab1a8624d50890d6d18545ffb47d8083afa8c67" source_path = "crates/bindgen" @@ -1102,7 +1390,16 @@ license = "GPL-3.0-only" platforms = ["native"] groups = ["studio"] owners = ["studio"] -permitted_dependency_tiers = ["foundation", "domain", "spi", "adapter", "orchestration", "sdk", "facade", "application_domain"] +permitted_dependency_tiers = [ + "foundation", + "domain", + "spi", + "adapter", + "orchestration", + "sdk", + "facade", + "application_domain", +] source_repository = "https://github.com/radrootslabs/studio_app" source_revision = "2b5fe5d8321fd0e248a9d304651b1ba54ee6a180" source_path = "core/crates/domain" @@ -1123,7 +1420,17 @@ license = "GPL-3.0-only" platforms = ["native"] groups = ["studio"] owners = ["studio"] -permitted_dependency_tiers = ["foundation", "domain", "spi", "adapter", "orchestration", "sdk", "facade", "application_domain", "application"] +permitted_dependency_tiers = [ + "foundation", + "domain", + "spi", + "adapter", + "orchestration", + "sdk", + "facade", + "application_domain", + "application", +] source_repository = "https://github.com/radrootslabs/studio_app" source_revision = "2b5fe5d8321fd0e248a9d304651b1ba54ee6a180" source_path = "core/crates/application" @@ -1144,7 +1451,18 @@ license = "GPL-3.0-only" platforms = ["native"] groups = ["studio"] owners = ["studio"] -permitted_dependency_tiers = ["foundation", "domain", "spi", "adapter", "orchestration", "sdk", "facade", "application_domain", "application", "application_adapter"] +permitted_dependency_tiers = [ + "foundation", + "domain", + "spi", + "adapter", + "orchestration", + "sdk", + "facade", + "application_domain", + "application", + "application_adapter", +] source_repository = "https://github.com/radrootslabs/studio_app" source_revision = "2b5fe5d8321fd0e248a9d304651b1ba54ee6a180" source_path = "core/crates/nostr" @@ -1165,7 +1483,18 @@ license = "GPL-3.0-only" platforms = ["native"] groups = ["studio"] owners = ["studio"] -permitted_dependency_tiers = ["foundation", "domain", "spi", "adapter", "orchestration", "sdk", "facade", "application_domain", "application", "application_adapter"] +permitted_dependency_tiers = [ + "foundation", + "domain", + "spi", + "adapter", + "orchestration", + "sdk", + "facade", + "application_domain", + "application", + "application_adapter", +] source_repository = "https://github.com/radrootslabs/studio_app" source_revision = "2b5fe5d8321fd0e248a9d304651b1ba54ee6a180" source_path = "core/crates/storage" @@ -1186,7 +1515,19 @@ license = "GPL-3.0-only" platforms = ["native"] groups = ["studio"] owners = ["studio"] -permitted_dependency_tiers = ["foundation", "domain", "spi", "adapter", "orchestration", "sdk", "facade", "application_domain", "application", "application_adapter", "runtime_composition"] +permitted_dependency_tiers = [ + "foundation", + "domain", + "spi", + "adapter", + "orchestration", + "sdk", + "facade", + "application_domain", + "application", + "application_adapter", + "runtime_composition", +] source_repository = "https://github.com/radrootslabs/studio_app" source_revision = "2b5fe5d8321fd0e248a9d304651b1ba54ee6a180" source_path = "core/crates/ffi" @@ -1207,7 +1548,20 @@ license = "GPL-3.0-only" platforms = ["linux", "macos", "windows"] groups = ["studio", "boundaries"] owners = ["studio"] -permitted_dependency_tiers = ["foundation", "domain", "spi", "adapter", "orchestration", "sdk", "facade", "application_domain", "application", "application_adapter", "runtime_composition", "boundary"] +permitted_dependency_tiers = [ + "foundation", + "domain", + "spi", + "adapter", + "orchestration", + "sdk", + "facade", + "application_domain", + "application", + "application_adapter", + "runtime_composition", + "boundary", +] source_repository = "https://github.com/radrootslabs/studio_app" source_revision = "2b5fe5d8321fd0e248a9d304651b1ba54ee6a180" source_path = "core/crates/ffi" @@ -1228,7 +1582,21 @@ license = "GPL-3.0-only" platforms = ["native"] groups = ["studio", "boundaries"] owners = ["studio"] -permitted_dependency_tiers = ["foundation", "domain", "spi", "adapter", "orchestration", "sdk", "facade", "application_domain", "application", "application_adapter", "runtime_composition", "boundary", "codegen"] +permitted_dependency_tiers = [ + "foundation", + "domain", + "spi", + "adapter", + "orchestration", + "sdk", + "facade", + "application_domain", + "application", + "application_adapter", + "runtime_composition", + "boundary", + "codegen", +] source_repository = "https://github.com/radrootslabs/studio_app" source_revision = "2b5fe5d8321fd0e248a9d304651b1ba54ee6a180" source_path = "core/tools/uniffi-bindgen" diff --git a/contracts/crates/generated/package_groups.v1.toml b/contracts/crates/generated/package_groups.v1.toml @@ -1,5 +1,5 @@ schema = "radroots.workspace.package-groups.v1" -catalog_sha256 = "08a97c61225ff876cad7e00739c421275d17766d602eff0bc0bac72171865c8e" +catalog_sha256 = "5ba3c5afc10a7fed3f3e19607d34509fb7a182389dae7e42dd9f8f35506a34c5" [[group]] id = "boundaries" diff --git a/contracts/crates/generated/platform_inventory.v1.toml b/contracts/crates/generated/platform_inventory.v1.toml @@ -1,5 +1,5 @@ schema = "radroots.workspace.platform-inventory.v1" -catalog_sha256 = "08a97c61225ff876cad7e00739c421275d17766d602eff0bc0bac72171865c8e" +catalog_sha256 = "5ba3c5afc10a7fed3f3e19607d34509fb7a182389dae7e42dd9f8f35506a34c5" [[platform]] id = "android" diff --git a/contracts/crates/generated/release_inventory.v2.toml b/contracts/crates/generated/release_inventory.v2.toml @@ -1,5 +1,5 @@ schema = "radroots.workspace.release-inventory.v2" -catalog_sha256 = "08a97c61225ff876cad7e00739c421275d17766d602eff0bc0bac72171865c8e" +catalog_sha256 = "5ba3c5afc10a7fed3f3e19607d34509fb7a182389dae7e42dd9f8f35506a34c5" architecture = "radroots.crates.release.v2" version = "0.1.0-alpha" public_packages = ["radroots", "radroots_blossom", "radroots_core", "radroots_event", "radroots_event_codec", "radroots_geonames", "radroots_identity", "radroots_nostr", "radroots_nostr_connect", "radroots_protocol", "radroots_sdk", "radroots_secrets", "radroots_signing", "radroots_storage", "radroots_storage_sqlite", "radroots_sync", "radroots_trade", "radroots_transport", "radroots_transport_nostr"] diff --git a/contracts/operations.toml b/contracts/operations.toml @@ -333,11 +333,7 @@ vector = "contracts/conformance/vectors/blossom/hash_path_and_descriptor.v1.json domain = "blossom" id = "blossom.verify_descriptor_bytes" stability = "beta" -inputs = [ - "ApprovedDescriptor", - "Bytes", - "MediaType", -] +inputs = ["ApprovedDescriptor", "Bytes", "MediaType"] outputs = ["ByteVerifiedDescriptor"] error_class = "validation_error" deterministic = true @@ -370,10 +366,7 @@ inputs = [ "CreatedAt", "LifetimeSeconds", ] -outputs = [ - "AuthoredUploadClaim", - "AuthorizationWireParts", -] +outputs = ["AuthoredUploadClaim", "AuthorizationWireParts"] error_class = "validation_error" deterministic = true signing = "none" @@ -411,10 +404,7 @@ vector = "contracts/conformance/vectors/blossom/bud11_claims.v1.json" domain = "blossom" id = "blossom.validate_authorization_claim" stability = "beta" -inputs = [ - "AuthorizationClaim", - "AuthorizationValidation", -] +inputs = ["AuthorizationClaim", "AuthorizationValidation"] outputs = ["ValidatedAuthorizationClaim"] error_class = "validation_error" deterministic = true @@ -1821,10 +1811,7 @@ rust_types = [ [operations.trade_prepare_workflow.conformance] vector = "contracts/conformance/vectors/trade/prepare_workflow.v1.json" -case_kinds = [ - "trade.prepare_workflow.valid", - "trade.prepare_workflow.invalid", -] +case_kinds = ["trade.prepare_workflow.valid", "trade.prepare_workflow.invalid"] [operations.knowledge_contract_manifest_json] domain = "knowledge" diff --git a/contracts/storage/backup_capture_policy_v1.toml b/contracts/storage/backup_capture_policy_v1.toml @@ -11,7 +11,12 @@ include_protected_members = ["runtime/runtime.sqlite", "private/private.sqlite"] created_at = "plan_requested_at_unix_ms" member_digest = "sha256" member_length = "exact_bytes" -filesystem_sync = ["member_file", "member_directory", "staging_directory", "backup_root"] +filesystem_sync = [ + "member_file", + "member_directory", + "staging_directory", + "backup_root", +] existing_staging_or_final = "reject" hidden_clock = false unsafe_ffi = false diff --git a/contracts/storage/backup_finalize_policy_v1.toml b/contracts/storage/backup_finalize_policy_v1.toml @@ -1,5 +1,14 @@ schema_version = 1 -verification = ["exact_plan_manifest", "exact_inventory", "no_symlinks", "exact_length", "sha256", "current_schema_catalog", "sqlite_integrity_check", "foreign_key_check"] +verification = [ + "exact_plan_manifest", + "exact_inventory", + "no_symlinks", + "exact_length", + "sha256", + "current_schema_catalog", + "sqlite_integrity_check", + "foreign_key_check", +] finalization = "same_root_atomic_directory_rename" root_sync_after_rename = true finalized_retry = "verify_and_succeed" diff --git a/contracts/storage/failure_injection_policy_v1.toml b/contracts/storage/failure_injection_policy_v1.toml @@ -1,11 +1,58 @@ schema_version = 1 strategy = "deterministic_state_construction_and_sql_faults" runtime_global_hooks = false -accepted_reopen_outcomes = ["fully_committed_replayable", "typed_recoverable_no_partial_success"] -atomic_sql_points = ["source_sequence", "event", "provenance", "projection_checkpoint", "commit_receipt", "journal", "outbox_item", "outbox_target", "delivery_evidence"] -migration_points = ["application_identity", "each_pending_step", "user_version", "exact_catalog", "transaction_commit"] -backup_points = ["runtime_snapshot", "protected_snapshot", "member_hash", "manifest", "complete_verification", "final_directory_rename", "root_sync"] -restore_points = ["runtime_staging", "protected_staging", "staged_validation", "marker", "runtime_previous_rename", "runtime_replacement_rename", "protected_previous_rename", "protected_replacement_rename", "installed_validation", "runtime_previous_cleanup", "protected_previous_cleanup", "marker_cleanup"] -lock_close_points = ["lock_file_open", "exclusive_acquisition", "cross_process_contention", "pool_drain", "lock_release", "closed_status"] +accepted_reopen_outcomes = [ + "fully_committed_replayable", + "typed_recoverable_no_partial_success", +] +atomic_sql_points = [ + "source_sequence", + "event", + "provenance", + "projection_checkpoint", + "commit_receipt", + "journal", + "outbox_item", + "outbox_target", + "delivery_evidence", +] +migration_points = [ + "application_identity", + "each_pending_step", + "user_version", + "exact_catalog", + "transaction_commit", +] +backup_points = [ + "runtime_snapshot", + "protected_snapshot", + "member_hash", + "manifest", + "complete_verification", + "final_directory_rename", + "root_sync", +] +restore_points = [ + "runtime_staging", + "protected_staging", + "staged_validation", + "marker", + "runtime_previous_rename", + "runtime_replacement_rename", + "protected_previous_rename", + "protected_replacement_rename", + "installed_validation", + "runtime_previous_cleanup", + "protected_previous_cleanup", + "marker_cleanup", +] +lock_close_points = [ + "lock_file_open", + "exclusive_acquisition", + "cross_process_contention", + "pool_drain", + "lock_release", + "closed_status", +] restore_recovery = "idempotent_forward_completion_before_connection_open" failure_reporting = "stable_typed_error_without_backend_details" diff --git a/contracts/storage/legacy_import_backup_policy_v1.toml b/contracts/storage/legacy_import_backup_policy_v1.toml @@ -10,7 +10,12 @@ staging = ".radroots-legacy-import-{import_id_hex}.staging" finalized = "radroots-legacy-import-{import_id_hex}" member_mode = "0600" manifest = "manifest.v1_exact_identity_target_generation_timestamp_source_provenance_inventory_lengths_sha256" -verification = ["sqlite_quick_check", "foreign_key_check", "exact_length", "sha256"] +verification = [ + "sqlite_quick_check", + "foreign_key_check", + "exact_length", + "sha256", +] finalization = "same_root_atomic_directory_rename" mutation_before_finalized_backup = false collision = "reject" diff --git a/contracts/storage/legacy_import_journal_policy_v1.toml b/contracts/storage/legacy_import_journal_policy_v1.toml @@ -1,6 +1,11 @@ schema_version = 1 authority = "runtime_sqlite_owned_forward_migration_v6" -identity = ["import_id", "target_generation", "manifest_sha256", "classification_sha256"] +identity = [ + "import_id", + "target_generation", + "manifest_sha256", + "classification_sha256", +] import_states = ["classified", "staging", "ready", "committing", "complete"] member_states = ["pending", "staging", "ready", "complete"] imports_per_target_generation = 1 diff --git a/contracts/storage/legacy_import_qualification_v1.toml b/contracts/storage/legacy_import_qualification_v1.toml @@ -1,6 +1,23 @@ schema_version = 1 -source_matrix = ["event_store_v1_to_v4", "outbox_v1", "private_v1", "studio_v1_host_handoff"] -required_cases = ["mandatory_backup", "unsupported_schema_rejection", "mixed_source_golden", "bounded_resume", "close_reopen", "invalid_row_rollback", "private_commit_recovery", "lost_success_retry", "conflicting_identity_rejection", "source_retention", "no_live_dual_write"] +source_matrix = [ + "event_store_v1_to_v4", + "outbox_v1", + "private_v1", + "studio_v1_host_handoff", +] +required_cases = [ + "mandatory_backup", + "unsupported_schema_rejection", + "mixed_source_golden", + "bounded_resume", + "close_reopen", + "invalid_row_rollback", + "private_commit_recovery", + "lost_success_retry", + "conflicting_identity_rejection", + "source_retention", + "no_live_dual_write", +] mixed_imported_row_count = 14 mixed_host_handoff_row_count = 0 exact_retry = true diff --git a/contracts/storage/legacy_private_staging_policy_v1.toml b/contracts/storage/legacy_private_staging_policy_v1.toml @@ -3,13 +3,26 @@ runtime_authority = "runtime_sqlite_import_journal_v8" private_authority = "private_sqlite_forward_migration_v2" source_kind = "private" supported_schema = "private_v1" -table_order = ["metadata", "wrapped_profile_keys", "signing_secrets", "farm_locations", "trade_artifacts", "cursor_keys", "nip46_sessions", "rotation_progress"] +table_order = [ + "metadata", + "wrapped_profile_keys", + "signing_secrets", + "farm_locations", + "trade_artifacts", + "cursor_keys", + "nip46_sessions", + "rotation_progress", +] cursor = "table_discriminator_u8_plus_utf8_canonical_key_max_1024" page_limit_max = 256 record = "sqlite_json_array_governed_column_order_with_blob_hex" secret_bearing_staging_database = "private.sqlite" wrapping_key_reference = "required_before_dependent_record" -recovery = ["runtime_enter_staging", "private_exact_idempotent_page_commit", "runtime_cursor_count_commit"] +recovery = [ + "runtime_enter_staging", + "private_exact_idempotent_page_commit", + "runtime_cursor_count_commit", +] crash_before_private_commit = "no_private_rows_and_old_runtime_cursor" crash_after_private_commit = "exact_replay_verification_from_old_runtime_cursor" conflicting_replay = "reject" diff --git a/contracts/storage/legacy_schema_classification_v1.toml b/contracts/storage/legacy_schema_classification_v1.toml @@ -15,7 +15,12 @@ schema_sha256 = [ "dd12467e04addcbddb5ea0f386c12a8ac05ef5ebaaf949f24dd2c62745f5aaac", "074f85b663444ac150239ecd8441ea4a96ad83a798a55e22d2e5e2f7ee943a8c", ] -names = ["event_store", "nip09", "food_availability_projection", "source_maintenance"] +names = [ + "event_store", + "nip09", + "food_availability_projection", + "source_maintenance", +] up_sha256 = [ "4c03906a1cffd418a48d40907aa9a1ca51bb41766cff7250c4dfc7c2fd6eddde", "0c1730ff36eaebd285f9c0c94b9b7346af60266afa55c24a18e30446d369581a", diff --git a/contracts/storage/private_schema_v1.toml b/contracts/storage/private_schema_v1.toml @@ -1,6 +1,6 @@ schema_version = 1 database = "private.sqlite" -application_id = 1380208722 # ASCII "RDPR" +application_id = 1380208722 # ASCII "RDPR" minimum_version = 1 current_version = 4 migration_name = "context_bound_envelopes" @@ -19,10 +19,7 @@ authorities = [ "context_bound_envelopes", ] -forbidden_tables = [ - "studio", - "ui_state", -] +forbidden_tables = ["studio", "ui_state"] [[migrations]] version = 1 @@ -62,21 +59,21 @@ version = 3 name = "legacy_import_commits" sha256 = "9377f0af8f070d977a5237e2a1294e6977f5b704e7a8434d97a3dc5f4ae75e86" owned_objects = [ - "radroots_private_artifacts", - "radroots_private_artifacts_delete_guard", - "radroots_private_artifacts_envelope_guard", - "radroots_private_artifacts_expiry_idx", - "radroots_private_artifacts_identity_guard", - "radroots_private_artifacts_key_version_idx", - "radroots_private_artifacts_kind_idx", - "radroots_private_legacy_import_commit_delete_guard", - "radroots_private_legacy_import_commit_update_guard", - "radroots_private_legacy_import_commits", - "radroots_private_legacy_import_staging", - "radroots_private_legacy_import_staging_delete_guard", - "radroots_private_legacy_import_staging_insert_guard", - "radroots_private_legacy_import_staging_parent_idx", - "radroots_private_legacy_import_staging_update_guard", + "radroots_private_artifacts", + "radroots_private_artifacts_delete_guard", + "radroots_private_artifacts_envelope_guard", + "radroots_private_artifacts_expiry_idx", + "radroots_private_artifacts_identity_guard", + "radroots_private_artifacts_key_version_idx", + "radroots_private_artifacts_kind_idx", + "radroots_private_legacy_import_commit_delete_guard", + "radroots_private_legacy_import_commit_update_guard", + "radroots_private_legacy_import_commits", + "radroots_private_legacy_import_staging", + "radroots_private_legacy_import_staging_delete_guard", + "radroots_private_legacy_import_staging_insert_guard", + "radroots_private_legacy_import_staging_parent_idx", + "radroots_private_legacy_import_staging_update_guard", ] [[migrations]] @@ -84,25 +81,25 @@ version = 4 name = "context_bound_envelopes" sha256 = "dd6bb42471db47fcd9c62f8d59b66ddba381776d879cfbffd94993f7ef7409de" owned_objects = [ - "radroots_private_artifacts", - "radroots_private_artifacts_delete_guard", - "radroots_private_artifacts_envelope_guard", - "radroots_private_artifacts_expiry_idx", - "radroots_private_artifacts_identity_guard", - "radroots_private_artifacts_insert_envelope_guard", - "radroots_private_artifacts_key_version_idx", - "radroots_private_artifacts_kind_idx", - "radroots_private_artifacts_reseal_audit", - "radroots_private_envelope_reseals", - "radroots_private_envelope_reseals_artifact_idx", - "radroots_private_envelope_reseals_delete_guard", - "radroots_private_envelope_reseals_update_guard", - "radroots_private_legacy_import_commit_delete_guard", - "radroots_private_legacy_import_commit_update_guard", - "radroots_private_legacy_import_commits", - "radroots_private_legacy_import_staging", - "radroots_private_legacy_import_staging_delete_guard", - "radroots_private_legacy_import_staging_insert_guard", - "radroots_private_legacy_import_staging_parent_idx", - "radroots_private_legacy_import_staging_update_guard", + "radroots_private_artifacts", + "radroots_private_artifacts_delete_guard", + "radroots_private_artifacts_envelope_guard", + "radroots_private_artifacts_expiry_idx", + "radroots_private_artifacts_identity_guard", + "radroots_private_artifacts_insert_envelope_guard", + "radroots_private_artifacts_key_version_idx", + "radroots_private_artifacts_kind_idx", + "radroots_private_artifacts_reseal_audit", + "radroots_private_envelope_reseals", + "radroots_private_envelope_reseals_artifact_idx", + "radroots_private_envelope_reseals_delete_guard", + "radroots_private_envelope_reseals_update_guard", + "radroots_private_legacy_import_commit_delete_guard", + "radroots_private_legacy_import_commit_update_guard", + "radroots_private_legacy_import_commits", + "radroots_private_legacy_import_staging", + "radroots_private_legacy_import_staging_delete_guard", + "radroots_private_legacy_import_staging_insert_guard", + "radroots_private_legacy_import_staging_parent_idx", + "radroots_private_legacy_import_staging_update_guard", ] diff --git a/contracts/storage/restore_finalize_policy_v1.toml b/contracts/storage/restore_finalize_policy_v1.toml @@ -7,8 +7,15 @@ marker_encoding = "fixed_binary_v1_exact_member_lengths_and_sha256" marker_durability = ["marker_file_fsync", "runtime_parent_fsync"] previous_runtime = ".runtime.sqlite.restore-{backup_id_hex}.previous" previous_protected = ".private.sqlite.restore-{backup_id_hex}.previous" -replacement = ["live_to_previous_atomic_rename", "staging_to_live_atomic_rename"] +replacement = [ + "live_to_previous_atomic_rename", + "staging_to_live_atomic_rename", +] recovery = "marker_driven_idempotent_forward_completion_before_open" read_only_recovery = "reject" -cleanup_order = ["verify_all_live_members", "remove_previous_members", "remove_marker"] +cleanup_order = [ + "verify_all_live_members", + "remove_previous_members", + "remove_marker", +] backend_after_attempt = "closed_reopen_required" diff --git a/contracts/storage/restore_staging_policy_v1.toml b/contracts/storage/restore_staging_policy_v1.toml @@ -4,7 +4,13 @@ authority = "writable_storage_only" runtime_staging = ".runtime.sqlite.restore-{backup_id_hex}.staging" protected_staging = ".private.sqlite.restore-{backup_id_hex}.staging" creation = "create_new_mode_0600" -verification = ["exact_length", "sha256", "current_schema_catalog", "sqlite_integrity_check", "foreign_key_check"] +verification = [ + "exact_length", + "sha256", + "current_schema_catalog", + "sqlite_integrity_check", + "foreign_key_check", +] live_mutation = false existing_staging = "reject" protected_member = "manifest_policy_controlled" diff --git a/contracts/storage/runtime_schema_v1.toml b/contracts/storage/runtime_schema_v1.toml @@ -1,6 +1,6 @@ schema_version = 1 database = "runtime.sqlite" -application_id = 1380209236 # ASCII "RDRT" +application_id = 1380209236 # ASCII "RDRT" minimum_version = 1 current_version = 9 migration_name = "legacy_import_commits" @@ -368,54 +368,54 @@ name = "legacy_import_commits" sha256 = "f0807eecd652a26844c3502d81386a9d54480cb178abe1b71035e0601916afb7" owned_objects = [ - "radroots_runtime_atomic_commits", - "radroots_runtime_delivery_evidence", - "radroots_runtime_delivery_evidence_item_idx", - "radroots_runtime_event_index_checkpoints", - "radroots_runtime_event_index_manifests", - "radroots_runtime_event_index_shards", - "radroots_runtime_event_provenance", - "radroots_runtime_event_provenance_observed_idx", - "radroots_runtime_events", - "radroots_runtime_events_admission_idx", - "radroots_runtime_events_delete_guard", - "radroots_runtime_events_event_id_idx", - "radroots_runtime_events_raw_update_guard", - "radroots_runtime_journal_idempotency_idx", - "radroots_runtime_journal_operations", - "radroots_runtime_journal_recovery_idx", - "radroots_runtime_legacy_event_staging", - "radroots_runtime_legacy_event_staging_delete_guard", - "radroots_runtime_legacy_event_staging_insert_guard", - "radroots_runtime_legacy_event_staging_update_guard", - "radroots_runtime_legacy_import_commit_delete_guard", - "radroots_runtime_legacy_import_commit_update_guard", - "radroots_runtime_legacy_import_commits", - "radroots_runtime_legacy_import_delete_guard", - "radroots_runtime_legacy_import_identity_guard", - "radroots_runtime_legacy_import_member_delete_guard", - "radroots_runtime_legacy_import_member_identity_guard", - "radroots_runtime_legacy_import_member_state_guard", - "radroots_runtime_legacy_import_members", - "radroots_runtime_legacy_import_state_guard", - "radroots_runtime_legacy_import_state_idx", - "radroots_runtime_legacy_imports", - "radroots_runtime_legacy_outbox_staging", - "radroots_runtime_legacy_outbox_staging_delete_guard", - "radroots_runtime_legacy_outbox_staging_insert_guard", - "radroots_runtime_legacy_outbox_staging_parent_idx", - "radroots_runtime_legacy_outbox_staging_update_guard", - "radroots_runtime_outbox_items", - "radroots_runtime_outbox_operation_idx", - "radroots_runtime_outbox_ready_idx", - "radroots_runtime_outbox_targets", - "radroots_runtime_projection_checkpoints", - "radroots_runtime_projection_invalidations", - "radroots_runtime_projection_rebuilds", - "radroots_runtime_projection_rebuilds_stage_idx", - "radroots_runtime_source_generations", - "radroots_runtime_source_generations_active_idx", - "radroots_runtime_source_generations_delete_guard", - "radroots_runtime_source_generations_identity_guard", - "radroots_runtime_source_generations_sequence_guard", + "radroots_runtime_atomic_commits", + "radroots_runtime_delivery_evidence", + "radroots_runtime_delivery_evidence_item_idx", + "radroots_runtime_event_index_checkpoints", + "radroots_runtime_event_index_manifests", + "radroots_runtime_event_index_shards", + "radroots_runtime_event_provenance", + "radroots_runtime_event_provenance_observed_idx", + "radroots_runtime_events", + "radroots_runtime_events_admission_idx", + "radroots_runtime_events_delete_guard", + "radroots_runtime_events_event_id_idx", + "radroots_runtime_events_raw_update_guard", + "radroots_runtime_journal_idempotency_idx", + "radroots_runtime_journal_operations", + "radroots_runtime_journal_recovery_idx", + "radroots_runtime_legacy_event_staging", + "radroots_runtime_legacy_event_staging_delete_guard", + "radroots_runtime_legacy_event_staging_insert_guard", + "radroots_runtime_legacy_event_staging_update_guard", + "radroots_runtime_legacy_import_commit_delete_guard", + "radroots_runtime_legacy_import_commit_update_guard", + "radroots_runtime_legacy_import_commits", + "radroots_runtime_legacy_import_delete_guard", + "radroots_runtime_legacy_import_identity_guard", + "radroots_runtime_legacy_import_member_delete_guard", + "radroots_runtime_legacy_import_member_identity_guard", + "radroots_runtime_legacy_import_member_state_guard", + "radroots_runtime_legacy_import_members", + "radroots_runtime_legacy_import_state_guard", + "radroots_runtime_legacy_import_state_idx", + "radroots_runtime_legacy_imports", + "radroots_runtime_legacy_outbox_staging", + "radroots_runtime_legacy_outbox_staging_delete_guard", + "radroots_runtime_legacy_outbox_staging_insert_guard", + "radroots_runtime_legacy_outbox_staging_parent_idx", + "radroots_runtime_legacy_outbox_staging_update_guard", + "radroots_runtime_outbox_items", + "radroots_runtime_outbox_operation_idx", + "radroots_runtime_outbox_ready_idx", + "radroots_runtime_outbox_targets", + "radroots_runtime_projection_checkpoints", + "radroots_runtime_projection_invalidations", + "radroots_runtime_projection_rebuilds", + "radroots_runtime_projection_rebuilds_stage_idx", + "radroots_runtime_source_generations", + "radroots_runtime_source_generations_active_idx", + "radroots_runtime_source_generations_delete_guard", + "radroots_runtime_source_generations_identity_guard", + "radroots_runtime_source_generations_sequence_guard", ] diff --git a/crates/blossom/Cargo.toml b/crates/blossom/Cargo.toml @@ -13,7 +13,16 @@ documentation = "https://docs.rs/radroots_blossom" readme = "README.md" keywords = ["radroots", "blossom", "nostr", "media"] categories = ["network-programming", "no-std", "web-programming"] -include = ["src/**", "tests/**", "!tests/package_boundary.rs", "examples/**", "Cargo.toml", "README.md", "LICENSE-APACHE", "LICENSE-MIT"] +include = [ + "src/**", + "tests/**", + "!tests/package_boundary.rs", + "examples/**", + "Cargo.toml", + "README.md", + "LICENSE-APACHE", + "LICENSE-MIT", +] [package.metadata.docs.rs] features = ["std", "serde"] diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml @@ -13,7 +13,17 @@ documentation = "https://docs.rs/radroots_core" readme = "README.md" keywords = ["radroots", "local-food", "domain-model"] categories = ["data-structures", "no-std"] -include = ["src/**", "tests/**", "!tests/package_boundary.rs", "!tests/package_fixture_freshness.rs", "examples/**", "Cargo.toml", "README.md", "LICENSE-APACHE", "LICENSE-MIT"] +include = [ + "src/**", + "tests/**", + "!tests/package_boundary.rs", + "!tests/package_fixture_freshness.rs", + "examples/**", + "Cargo.toml", + "README.md", + "LICENSE-APACHE", + "LICENSE-MIT", +] [package.metadata.docs.rs] features = ["std", "serde"] diff --git a/crates/event/Cargo.toml b/crates/event/Cargo.toml @@ -13,7 +13,16 @@ documentation = "https://docs.rs/radroots_event" readme = "README.md" keywords = ["radroots", "nostr", "events", "local-food"] categories = ["data-structures", "no-std"] -include = ["src/**", "tests/**", "!tests/package_boundary.rs", "!tests/source_boundary.rs", "Cargo.toml", "README.md", "LICENSE-APACHE", "LICENSE-MIT"] +include = [ + "src/**", + "tests/**", + "!tests/package_boundary.rs", + "!tests/source_boundary.rs", + "Cargo.toml", + "README.md", + "LICENSE-APACHE", + "LICENSE-MIT", +] [package.metadata.docs.rs] features = ["std", "serde", "knowledge"] diff --git a/crates/event_bindings/tests/fixtures/generated_constants.ts b/crates/event_bindings/tests/fixtures/generated_constants.ts @@ -0,0 +1,5 @@ +// @generated by cargo xtask generate ts +// Do not edit by hand. +import type { RadrootsOperationalListingProductTagKeys } from "./types.js"; + +export const RADROOTS_OPERATIONAL_LISTING_PRODUCT_TAG_KEYS: RadrootsOperationalListingProductTagKeys = ["key", "title", "category", "summary", "process", "lot", "location", "profile", "year"]; diff --git a/crates/event_bindings/tests/fixtures/generated_kinds.ts b/crates/event_bindings/tests/fixtures/generated_kinds.ts @@ -0,0 +1,149 @@ +// @generated by cargo xtask generate ts +// Do not edit by hand. +export const KIND_PROFILE = 0; + +export const KIND_POST = 1; + +export const KIND_FOLLOW = 3; + +export const KIND_REACTION = 7; + +export const KIND_SEAL = 13; + +export const KIND_MESSAGE = 14; + +export const KIND_MESSAGE_FILE = 15; + +export const KIND_GIFT_WRAP = 1059; + +export const KIND_COMMENT = 1111; + +export const KIND_GEOCHAT = 20000; + +export const KIND_WIKI_MERGE_REQUEST = 818; + +export const KIND_WIKI_ARTICLE = 30818; + +export const KIND_WIKI_REDIRECT = 30819; + +export const KIND_LIST_MUTE = 10000; + +export const KIND_LIST_PINNED_NOTES = 10001; + +export const KIND_LIST_READ_WRITE_RELAYS = 10002; + +export const KIND_LIST_BOOKMARKS = 10003; + +export const KIND_LIST_COMMUNITIES = 10004; + +export const KIND_LIST_PUBLIC_CHATS = 10005; + +export const KIND_LIST_BLOCKED_RELAYS = 10006; + +export const KIND_LIST_SEARCH_RELAYS = 10007; + +export const KIND_LIST_SIMPLE_GROUPS = 10009; + +export const KIND_LIST_RELAY_FEEDS = 10012; + +export const KIND_LIST_INTERESTS = 10015; + +export const KIND_LIST_MEDIA_FOLLOWS = 10020; + +export const KIND_LIST_EMOJIS = 10030; + +export const KIND_LIST_DM_RELAYS = 10050; + +export const KIND_LIST_GOOD_WIKI_AUTHORS = 10101; + +export const KIND_LIST_GOOD_WIKI_RELAYS = 10102; + +export const KIND_LIST_SET_FOLLOW = 30000; + +export const KIND_LIST_SET_GENERIC = 30001; + +export const KIND_LIST_SET_RELAY = 30002; + +export const KIND_LIST_SET_BOOKMARK = 30003; + +export const KIND_LIST_SET_CURATION = 30004; + +export const KIND_LIST_SET_VIDEO = 30005; + +export const KIND_LIST_SET_PICTURE = 30006; + +export const KIND_LIST_SET_KIND_MUTE = 30007; + +export const KIND_LIST_SET_INTEREST = 30015; + +export const KIND_LIST_SET_EMOJI = 30030; + +export const KIND_LIST_SET_RELEASE_ARTIFACT = 30063; + +export const KIND_LIST_SET_APP_CURATION = 30267; + +export const KIND_LIST_SET_CALENDAR = 31924; + +export const KIND_LIST_SET_STARTER_PACK = 39089; + +export const KIND_LIST_SET_MEDIA_STARTER_PACK = 39092; + +export const KIND_FARM = 30340; + +export const KIND_PLOT = 30350; + +export const KIND_COOP = 30360; + +export const KIND_DOCUMENT = 30361; + +export const KIND_RESOURCE_AREA = 30370; + +export const KIND_RESOURCE_HARVEST_CAP = 30371; + +export const KIND_ACCOUNT_CLAIM = 30380; + +export const KIND_APP_DATA = 30078; + +export const KIND_CLASSIFIED_LISTING = 30402; + +export const KIND_APPLICATION_HANDLER = 31990; + +export const KIND_TRADE_PROPOSAL = 3470; + +export const KIND_TRADE_DECISION = 3471; + +export const KIND_TRADE_REVISION_PROPOSAL = 3472; + +export const KIND_TRADE_REVISION_DECISION = 3473; + +export const KIND_TRADE_CANCELLATION = 3474; + +export const KIND_TRADE_SELLER_RESERVATION_ASSERTION = 3475; + +export const KIND_TRADE_VALIDATION_RECEIPT = 3440; + +export const KIND_KNOWLEDGE_CLAIM = 3460; + +export const KIND_KNOWLEDGE_RELATION = 3461; + +export const KIND_KNOWLEDGE_REVIEW = 3462; + +export const KIND_KNOWLEDGE_FIELD_REPORT = 3463; + +export const KIND_KNOWLEDGE_CHANGE_PROPOSAL = 3464; + +export const KIND_CONTRIBUTION_ATTESTATION = 3465; + +export const KIND_KNOWLEDGE_SOURCE = 30450; + +export const KIND_EVIDENCE_BOUNTY = 30451; + +export const KIND_JOB_REQUEST_MIN = 5000; + +export const KIND_JOB_REQUEST_MAX = 5999; + +export const KIND_JOB_RESULT_MIN = 6000; + +export const KIND_JOB_RESULT_MAX = 6999; + +export const KIND_JOB_FEEDBACK = 7000; diff --git a/crates/event_bindings/tests/fixtures/generated_types.ts b/crates/event_bindings/tests/fixtures/generated_types.ts @@ -0,0 +1,197 @@ +// @generated by cargo xtask generate ts +// Do not edit by hand. +import type { Decimal, Discount, Money, Quantity, QuantityPrice, Unit } from "@radroots/core-bindings"; + +export type JobFeedbackStatus = "payment_required" | "processing" | "error" | "success" | "partial"; + +export type JobInputType = "url" | "event" | "job" | "text"; + +export type JobPaymentRequest = { amount_sat: number, bolt11?: string | null, }; + +export type RadrootsAccountClaim = { username: string, pubkey: string, nip05?: string | null, }; + +export type RadrootsAddressableRef = { kind: number, pubkey: string, d_tag: string, relays: Array<string>, }; + +export type RadrootsAppData = { d_tag: string, content: string, }; + +export type RadrootsCommercialDomain = "trade:listing"; + +export type RadrootsContributionAttestation = { schema: string, schema_version: number, contributor_pubkey: string, contribution_type: string, subject_refs: Array<RadrootsEventRef>, summary: string, evidence_refs: Array<RadrootsEventRef>, }; + +export type RadrootsCoop = { d_tag: string, name: string, about?: string | null, website?: string | null, picture?: string | null, banner?: string | null, location?: RadrootsCoopLocation | null, tags?: Array<string> | null, }; + +export type RadrootsCoopLocation = { primary?: string | null, city?: string | null, region?: string | null, country?: string | null, gcs: RadrootsGcsLocation, }; + +export type RadrootsCoopRef = { pubkey: string, d_tag: string, }; + +export type RadrootsDocument = { d_tag: string, doc_type: string, title: string, version: string, summary?: string | null, effective_at?: number | null, body_markdown?: string | null, subject: RadrootsDocumentSubject, tags?: Array<string> | null, }; + +export type RadrootsDocumentSubject = { pubkey: string, address?: string | null, }; + +export type RadrootsEventEnvelopeDto = { id: string, author: string, created_at: number, kind: number, tags: Array<Array<string>>, content: string, sig: string, }; + +export type RadrootsEventPtr = { id: string, relays?: string | null, }; + +export type RadrootsEventRef = { id: string, author: string, kind: number, d_tag?: string | null, relays?: Array<string> | null, }; + +export type RadrootsEvidenceBounty = { schema: string, schema_version: number, d_tag: string, title: string, summary?: string | null, topics: Array<string>, target_refs: Array<RadrootsEventRef>, reward_note?: string | null, closes_at?: string | null, }; + +export type RadrootsFarm = { d_tag: string, name: string, about?: string | null, website?: string | null, picture?: string | null, banner?: string | null, location?: RadrootsFarmPublicLocation | null, tags?: Array<string> | null, }; + +export type RadrootsFarmPublicLocation = { primary: string, city?: string | null, region?: string | null, country?: string | null, geohash: string, }; + +export type RadrootsFarmRef = { pubkey: string, d_tag: string, }; + +export type RadrootsFollow = { list: Array<RadrootsFollowProfile>, }; + +export type RadrootsFollowProfile = { published_at: number, public_key: string, relay_url?: string | null, contact_name?: string | null, }; + +export type RadrootsGcsLocation = { lat: number, lng: number, geohash: string, point: RadrootsGeoJsonPoint, polygon: RadrootsGeoJsonPolygon, accuracy?: number | null, altitude?: number | null, tag_0?: string | null, label?: string | null, area?: number | null, elevation?: number | null, soil?: string | null, climate?: string | null, gc_id?: string | null, gc_name?: string | null, gc_admin1_id?: string | null, gc_admin1_name?: string | null, gc_country_id?: string | null, gc_country_name?: string | null, }; + +export type RadrootsGeoChat = { geohash: string, content: string, nickname?: string | null, teleported: boolean, }; + +export type RadrootsGeoJsonPoint = { type: string, coordinates: [number, number], }; + +export type RadrootsGeoJsonPolygon = { type: string, coordinates: Array<Array<[number, number]>>, }; + +export type RadrootsGiftWrap = { recipient: RadrootsGiftWrapRecipient, content: string, expiration?: number | null, }; + +export type RadrootsGiftWrapRecipient = { public_key: string, relay_url?: string | null, }; + +export type RadrootsJobFeedback = { kind: number, status: JobFeedbackStatus, extra_info?: string | null, request_event: RadrootsEventPtr, customer_pubkey?: string | null, payment?: JobPaymentRequest | null, content?: string | null, encrypted: boolean, }; + +export type RadrootsJobInput = { data: string, input_type: JobInputType, relay?: string | null, marker?: string | null, }; + +export type RadrootsJobParam = { key: string, value: string, }; + +export type RadrootsJobRequest = { kind: number, inputs: Array<RadrootsJobInput>, output?: string | null, params: Array<RadrootsJobParam>, bid_sat?: number | null, relays: Array<string>, providers: Array<string>, topics: Array<string>, encrypted: boolean, }; + +export type RadrootsJobResult = { kind: number, request_event: RadrootsEventPtr, request_json?: string | null, inputs: Array<RadrootsJobInput>, customer_pubkey?: string | null, payment?: JobPaymentRequest | null, content?: string | null, encrypted: boolean, }; + +export type RadrootsKnowledgeChangeProposal = { schema: string, schema_version: number, target: RadrootsEventRef, proposal_type: string, summary: string, rationale?: string | null, evidence_refs: Array<RadrootsEventRef>, supersedes: Array<RadrootsEventRef>, }; + +export type RadrootsKnowledgeCitationSpan = { source_ref: RadrootsEventRef, artifact_ref?: RadrootsEventRef | null, page_start?: number | null, page_end?: number | null, section_path: Array<string>, quote_hash?: string | null, chunk_id?: string | null, }; + +export type RadrootsKnowledgeClaim = { schema: string, schema_version: number, claim_type: string, text: string, citation_spans: Array<RadrootsKnowledgeCitationSpan>, topics: Array<string>, applies_to: Array<string>, author_asserted_confidence?: string | null, supersedes: Array<RadrootsEventRef>, }; + +export type RadrootsKnowledgeFieldContext = { location_precision: RadrootsKnowledgeLocationPrecision, public_location?: RadrootsKnowledgeLocation | null, private_location_ref?: RadrootsEventRef | null, topics: Array<string>, context_tags: Array<string>, }; + +export type RadrootsKnowledgeFieldReport = { schema: string, schema_version: number, report_type: string, title: string, summary?: string | null, context: RadrootsKnowledgeFieldContext, observations: Array<RadrootsKnowledgeObservation>, artifact_refs: Array<RadrootsEventRef>, related_refs: Array<RadrootsEventRef>, limitations: Array<string>, }; + +export type RadrootsKnowledgeLocation = { label?: string | null, region?: string | null, locality?: string | null, geohash?: string | null, }; + +export type RadrootsKnowledgeLocationPrecision = "none" | "region" | "locality" | "coarse_geohash" | "exact_public" | "exact_private_reference"; + +export type RadrootsKnowledgeNodeRef = { node_type: string, event_ref?: RadrootsEventRef | null, address_ref?: RadrootsAddressableRef | null, external_id?: string | null, label?: string | null, }; + +export type RadrootsKnowledgeObservation = { observation_type: string, text: string, observed_at?: string | null, values: Array<RadrootsKnowledgeObservationValue>, }; + +export type RadrootsKnowledgeObservationValue = { key: string, value: string, unit?: string | null, }; + +export type RadrootsKnowledgeRelation = { schema: string, schema_version: number, subject: RadrootsKnowledgeNodeRef, predicate: string, object: RadrootsKnowledgeNodeRef, support_refs: Array<RadrootsEventRef>, author_asserted_confidence?: string | null, supersedes: Array<RadrootsEventRef>, }; + +export type RadrootsKnowledgeReview = { schema: string, schema_version: number, target: RadrootsKnowledgeReviewTarget, reviewer_role: string, verdict: string, scores: Array<RadrootsKnowledgeReviewScore>, notes?: string | null, evidence_refs: Array<RadrootsEventRef>, }; + +export type RadrootsKnowledgeReviewScope = "specific_version" | "addressable_coordinate_at_published_at" | "policy_latest"; + +export type RadrootsKnowledgeReviewScore = { dimension: string, value: string, note?: string | null, }; + +export type RadrootsKnowledgeReviewTarget = { event_id: string, author_pubkey: string, kind: number, address?: string | null, relays: Array<string>, review_scope: RadrootsKnowledgeReviewScope, }; + +export type RadrootsKnowledgeSource = { schema: string, schema_version: number, d_tag: string, title: string, source_type: string, authors: Array<string>, publisher?: string | null, publication_year?: number | null, edition?: string | null, canonical_url?: string | null, artifact_refs: Array<RadrootsEventRef>, author_asserted_rights?: RadrootsRightsAssertion | null, topics: Array<string>, summary?: string | null, }; + +export type RadrootsList = { content: string, entries: Array<RadrootsListEntry>, }; + +export type RadrootsListEntry = { tag: string, values: Array<string>, }; + +export type RadrootsListSet = { d_tag: string, content: string, entries: Array<RadrootsListEntry>, title?: string | null, description?: string | null, image?: string | null, }; + +export type RadrootsMessage = { recipients: Array<RadrootsMessageRecipient>, content: string, reply_to?: RadrootsEventPtr | null, subject?: string | null, }; + +export type RadrootsMessageFile = { recipients: Array<RadrootsMessageRecipient>, file_url: string, reply_to?: RadrootsEventPtr | null, subject?: string | null, file_type: string, encryption_algorithm: string, decryption_key: string, decryption_nonce: string, encrypted_hash: string, original_hash?: string | null, size?: string | null, dimensions?: RadrootsMessageFileDimensions | null, blurhash?: string | null, thumb?: string | null, fallbacks: Array<string>, }; + +export type RadrootsMessageFileDimensions = { w: number, h: number, }; + +export type RadrootsMessageRecipient = { public_key: string, relay_url?: string | null, }; + +export type RadrootsNip01EventWireDto = { id: string, pubkey: string, created_at: number, kind: number, tags: Array<Array<string>>, content: string, sig: string, extra: { [key: string]: unknown }, }; + +export type RadrootsOperationalListing = { d_tag: string, published_at?: string | null, farm?: RadrootsFarmRef, product: RadrootsOperationalListingProduct, primary_bin_id: string, bins: Array<RadrootsOperationalListingBin>, resource_area?: RadrootsResourceAreaRef | null, plot?: RadrootsPlotRef | null, discounts?: Array<Discount> | null, inventory_available?: Decimal | null, availability?: RadrootsOperationalListingAvailability | null, delivery_method?: RadrootsOperationalListingDeliveryMethod | null, location?: RadrootsOperationalListingPublicLocation | null, images?: Array<RadrootsOperationalListingImage> | null, }; + +export type RadrootsOperationalListingAvailability = { kind: "window", amount: { start?: string | null, end?: string | null, }, } | { kind: "status", amount: { status: RadrootsOperationalListingStatus, }, }; + +export type RadrootsOperationalListingBin = { bin_id: string, quantity: Quantity, price_per_canonical_unit: QuantityPrice, display_amount?: Decimal | null, display_unit?: Unit | null, display_label?: string | null, display_price?: Money | null, display_price_unit?: Unit | null, }; + +export type RadrootsOperationalListingDeliveryMethod = { kind: "pickup", } | { kind: "local_delivery", } | { kind: "shipping", } | { kind: "other", amount: { method: string, }, }; + +export type RadrootsOperationalListingImage = { url: string, size?: RadrootsOperationalListingImageSize | null, }; + +export type RadrootsOperationalListingImageSize = { w: number, h: number, }; + +export type RadrootsOperationalListingParseError = { InvalidKind: number, } | { MissingTag: string, } | { InvalidTag: string, } | { InvalidNumber: string, } | "InvalidUnit" | "InvalidCurrency" | { InvalidJson: string, } | { InvalidDiscount: string, }; + +export type RadrootsOperationalListingProduct = { key: string, title: string, category: string, summary?: string | null, process?: string | null, lot?: string | null, location?: string | null, profile?: string | null, year?: string | null, }; + +export type RadrootsOperationalListingProductTagKeys = readonly ["key", "title", "category", "summary", "process", "lot", "location", "profile", "year"]; + +export type RadrootsOperationalListingPublicLocation = { primary: string, city?: string | null, region?: string | null, country?: string | null, geohash: string, }; + +export type RadrootsOperationalListingStatus = { kind: "active", } | { kind: "sold", } | { kind: "other", amount: { value: string, }, }; + +export type RadrootsOperationalListingValidationError = { kind: "invalid_kind", amount: { kind: number, }, } | { kind: "invalid_profile", } | { kind: "missing_listing_id", } | { kind: "listing_event_not_found", amount: { listing_addr: string, }, } | { kind: "listing_event_fetch_failed", amount: { listing_addr: string, }, } | { kind: "parse_error", amount: { error: RadrootsOperationalListingParseError, }, } | { kind: "invalid_seller", } | { kind: "missing_farm_profile", } | { kind: "missing_farm_record", } | { kind: "missing_title", } | { kind: "missing_description", } | { kind: "missing_product_type", } | { kind: "missing_bins", } | { kind: "missing_primary_bin", } | { kind: "invalid_bin", } | { kind: "missing_price", } | { kind: "invalid_price", } | { kind: "missing_inventory", } | { kind: "invalid_inventory", } | { kind: "missing_availability", } | { kind: "missing_location", } | { kind: "missing_location_locality", } | { kind: "missing_location_geohash", } | { kind: "invalid_location_geohash", } | { kind: "missing_delivery_method", }; + +export type RadrootsPlot = { d_tag: string, farm: RadrootsFarmRef, name: string, about?: string | null, location?: RadrootsPlotLocation | null, tags?: Array<string> | null, }; + +export type RadrootsPlotLocation = { primary?: string | null, city?: string | null, region?: string | null, country?: string | null, gcs: RadrootsGcsLocation, }; + +export type RadrootsPlotRef = { pubkey: string, d_tag: string, }; + +export type RadrootsPost = { content: string, farm?: RadrootsSocialFarmAnchor | null, address_refs?: Array<RadrootsSocialTarget> | null, location?: RadrootsSocialLocation | null, topics?: Array<string> | null, quote_refs?: Array<RadrootsSocialTarget> | null, media?: Array<RadrootsSocialMediaMetadata> | null, }; + +export type RadrootsProfileType = "individual" | "farm" | "coop" | "any" | "radrootsd"; + +export type RadrootsReaction = { target: RadrootsSocialTarget, content: string, }; + +export type RadrootsRelayDocument = { name?: string | null, description?: string | null, pubkey?: string | null, contact?: string | null, supported_nips?: Array<number> | null, software?: string | null, version?: string | null, }; + +export type RadrootsResourceArea = { d_tag: string, name: string, about?: string | null, location: RadrootsResourceAreaLocation, tags?: Array<string> | null, }; + +export type RadrootsResourceAreaLocation = { primary?: string | null, city?: string | null, region?: string | null, country?: string | null, gcs: RadrootsGcsLocation, }; + +export type RadrootsResourceAreaRef = { pubkey: string, d_tag: string, }; + +export type RadrootsResourceHarvestCap = { d_tag: string, resource_area: RadrootsResourceAreaRef, product: RadrootsResourceHarvestProduct, start: string, end: string, cap_quantity: Quantity, display_amount?: Decimal | null, display_unit?: Unit | null, display_label?: string | null, tags?: Array<string> | null, }; + +export type RadrootsResourceHarvestProduct = { key: string, category?: string | null, }; + +export type RadrootsRightsAssertion = { assertion: string, holder?: string | null, license?: string | null, url?: string | null, }; + +export type RadrootsSeal = { content: string, }; + +export type RadrootsSignedEventDto = { state: RadrootsSignedEventVerificationStateDto, envelope: RadrootsEventEnvelopeDto, wire: RadrootsNip01EventWireDto, raw_json: string, }; + +export type RadrootsSignedEventVerificationStateDto = "id_verified"; + +export type RadrootsSocialFarmAnchor = { farm: RadrootsFarmRef, relays?: Array<string> | null, }; + +export type RadrootsSocialLocation = { name?: string | null, geohash?: string | null, }; + +export type RadrootsSocialMediaDimensions = { width: number, height: number, }; + +export type RadrootsSocialMediaMetadata = { url?: string | null, mime_type?: string | null, sha256?: string | null, original_sha256?: string | null, size?: string | null, dimensions?: RadrootsSocialMediaDimensions | null, blurhash?: string | null, thumbnails?: Array<RadrootsSocialMediaThumbnail> | null, image?: string | null, summary?: string | null, alt?: string | null, fallback?: string | null, magnet?: string | null, content_hashes?: Array<string> | null, services?: Array<string> | null, imeta?: Array<Array<string>> | null, }; + +export type RadrootsSocialMediaThumbnail = { url: string, dimensions?: RadrootsSocialMediaDimensions | null, }; + +export type RadrootsSocialTarget = { kind: "event", id: string, author?: string | null, event_kind?: number | null, relays?: Array<string> | null, } | { kind: "address", address: string, author?: string | null, event_kind?: number | null, relays?: Array<string> | null, } | { kind: "external", id: string, external_kind: string, hint?: string | null, }; + +export type RadrootsVerifiedSignedEventDto = { state: RadrootsVerifiedSignedEventVerificationStateDto, signed_event: RadrootsSignedEventDto, }; + +export type RadrootsVerifiedSignedEventVerificationStateDto = "signature_verified"; + +export type RadrootsWikiArticle = { d_tag: string, title?: string | null, content_djot: string, summary?: string | null, topics: Array<string>, references: Array<RadrootsEventRef>, forked_from: Array<RadrootsWikiArticleVersionRef>, deferred_to?: RadrootsWikiArticleVersionRef | null, }; + +export type RadrootsWikiArticleVersionRef = { event_id: string, address_ref: RadrootsAddressableRef, }; + +export type RadrootsWikiMergeRequest = { target_article: RadrootsAddressableRef, destination_pubkey: string, base_version_event_id?: string | null, source_version_event_id: string, explanation?: string | null, }; + +export type RadrootsWikiRedirect = { d_tag: string, target: RadrootsAddressableRef, }; diff --git a/crates/event_codec/Cargo.toml b/crates/event_codec/Cargo.toml @@ -13,7 +13,16 @@ documentation = "https://docs.rs/radroots_event_codec" readme = "README.md" keywords = ["radroots", "nostr", "codec", "events"] categories = ["encoding", "no-std"] -include = ["src/**", "tests/**", "!tests/package_boundary.rs", "examples/**", "Cargo.toml", "README.md", "LICENSE-APACHE", "LICENSE-MIT"] +include = [ + "src/**", + "tests/**", + "!tests/package_boundary.rs", + "examples/**", + "Cargo.toml", + "README.md", + "LICENSE-APACHE", + "LICENSE-MIT", +] [package.metadata.docs.rs] features = ["std", "json", "knowledge", "manifests"] diff --git a/crates/geonames/Cargo.toml b/crates/geonames/Cargo.toml @@ -13,7 +13,16 @@ publish = ["crates-io"] documentation = "https://docs.rs/radroots_geonames" keywords = ["radroots", "geonames", "geocoding", "locality"] categories = ["database"] -include = ["src/**", "tests/**", "!tests/package_boundary.rs", "examples/**", "Cargo.toml", "README.md", "LICENSE-APACHE", "LICENSE-MIT"] +include = [ + "src/**", + "tests/**", + "!tests/package_boundary.rs", + "examples/**", + "Cargo.toml", + "README.md", + "LICENSE-APACHE", + "LICENSE-MIT", +] [package.metadata.docs.rs] features = [] diff --git a/crates/identity/Cargo.toml b/crates/identity/Cargo.toml @@ -13,7 +13,17 @@ documentation = "https://docs.rs/radroots_identity" readme = "README.md" keywords = ["radroots", "identity", "nostr", "secp256k1"] categories = ["cryptography", "data-structures", "no-std"] -include = ["src/**", "tests/**", "!tests/package_boundary.rs", "!tests/retired_surface.rs", "examples/**", "Cargo.toml", "README.md", "LICENSE-APACHE", "LICENSE-MIT"] +include = [ + "src/**", + "tests/**", + "!tests/package_boundary.rs", + "!tests/retired_surface.rs", + "examples/**", + "Cargo.toml", + "README.md", + "LICENSE-APACHE", + "LICENSE-MIT", +] [package.metadata.docs.rs] features = ["std", "serde"] diff --git a/crates/identity/src/username.rs b/crates/identity/src/username.rs @@ -162,7 +162,7 @@ impl<'de> serde::Deserialize<'de> for Username { #[cfg(test)] mod tests { - use alloc::string::ToString; + use alloc::{format, string::ToString}; use super::*; diff --git a/crates/identity_bindings/tests/fixtures/generated_constants.ts b/crates/identity_bindings/tests/fixtures/generated_constants.ts @@ -0,0 +1,5 @@ +// @generated by cargo xtask generate ts +// Do not edit by hand. +export const RADROOTS_USERNAME_MIN_LENGTH = 3; + +export const RADROOTS_USERNAME_MAX_LENGTH = 30; diff --git a/crates/nostr/Cargo.toml b/crates/nostr/Cargo.toml @@ -13,7 +13,17 @@ documentation = "https://docs.rs/radroots_nostr" readme = "README.md" keywords = ["radroots", "nostr", "events", "signing"] categories = ["network-programming", "cryptography"] -include = ["src/**", "tests/**", "!tests/generic_builder_boundary.rs", "!tests/package_boundary.rs", "examples/**", "Cargo.toml", "README.md", "LICENSE-APACHE", "LICENSE-MIT"] +include = [ + "src/**", + "tests/**", + "!tests/generic_builder_boundary.rs", + "!tests/package_boundary.rs", + "examples/**", + "Cargo.toml", + "README.md", + "LICENSE-APACHE", + "LICENSE-MIT", +] [package.metadata.docs.rs] features = ["std", "events", "blossom", "nip17", "signing"] @@ -54,11 +64,14 @@ radroots_event = { workspace = true, default-features = false } radroots_event_codec = { workspace = true, default-features = false } radroots_identity = { workspace = true, default-features = false } radroots_signing = { workspace = true, optional = true, default-features = false } -nostr = { workspace = true, default-features = false, features = [ +nostr = { workspace = true, default-features = false, features = ["alloc"] } +serde = { workspace = true, default-features = false, features = [ + "alloc", + "derive", +] } +serde_json = { workspace = true, default-features = false, features = [ "alloc", ] } -serde = { workspace = true, default-features = false, features = ["alloc", "derive"] } -serde_json = { workspace = true, default-features = false, features = ["alloc"] } thiserror = { version = "2", default-features = false } [dev-dependencies] diff --git a/crates/nostr_connect/Cargo.toml b/crates/nostr_connect/Cargo.toml @@ -13,7 +13,16 @@ documentation = "https://docs.rs/radroots_nostr_connect" readme = "README.md" keywords = ["radroots", "nostr", "nip46", "signing"] categories = ["cryptography", "network-programming"] -include = ["src/**", "tests/**", "!tests/package_boundary.rs", "examples/**", "Cargo.toml", "README.md", "LICENSE-APACHE", "LICENSE-MIT"] +include = [ + "src/**", + "tests/**", + "!tests/package_boundary.rs", + "examples/**", + "Cargo.toml", + "README.md", + "LICENSE-APACHE", + "LICENSE-MIT", +] [package.metadata.docs.rs] features = ["serde"] diff --git a/crates/protocol/Cargo.toml b/crates/protocol/Cargo.toml @@ -13,7 +13,16 @@ documentation = "https://docs.rs/radroots_protocol" readme = "README.md" keywords = ["radroots", "protocol", "local-food", "wire-format"] categories = ["encoding", "no-std"] -include = ["src/**", "tests/**", "!tests/package_boundary.rs", "examples/**", "Cargo.toml", "README.md", "LICENSE-APACHE", "LICENSE-MIT"] +include = [ + "src/**", + "tests/**", + "!tests/package_boundary.rs", + "examples/**", + "Cargo.toml", + "README.md", + "LICENSE-APACHE", + "LICENSE-MIT", +] [package.metadata.docs.rs] features = ["std", "serde"] diff --git a/crates/radroots/Cargo.toml b/crates/radroots/Cargo.toml @@ -12,7 +12,16 @@ readme = "README.md" documentation = "https://docs.rs/radroots" keywords = ["radroots", "local-food", "sdk", "nostr"] categories = ["api-bindings", "asynchronous", "network-programming"] -include = ["src/**", "tests/**", "!tests/package_boundary.rs", "examples/**", "Cargo.toml", "README.md", "LICENSE-APACHE", "LICENSE-MIT"] +include = [ + "src/**", + "tests/**", + "!tests/package_boundary.rs", + "examples/**", + "Cargo.toml", + "README.md", + "LICENSE-APACHE", + "LICENSE-MIT", +] publish = ["crates-io"] autoexamples = false diff --git a/crates/radroots/LICENSE-APACHE b/crates/radroots/LICENSE-APACHE @@ -186,7 +186,7 @@ APPENDIX: How to apply the Apache License to your work. same "printed page" as the copyright notice for easier identification within third-party archives. -Copyright 2026 Tyson Lupul +Copyright 2025 Tyson Lupul Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/crates/radroots/LICENSE-MIT b/crates/radroots/LICENSE-MIT @@ -1,6 +1,6 @@ The MIT License (MIT) -Copyright (c) 2026 Tyson Lupul +Copyright (c) 2025 Tyson Lupul Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/crates/replica_schema_bindings/tests/fixtures/generated_types.ts b/crates/replica_schema_bindings/tests/fixtures/generated_types.ts @@ -0,0 +1,583 @@ +// @generated by cargo xtask generate ts +// Do not edit by hand. +export type Farm = { id: string, created_at: string, updated_at: string, d_tag: string, pubkey: string, name: string, about: string | null, website: string | null, picture: string | null, banner: string | null, location_primary: string | null, location_city: string | null, location_region: string | null, location_country: string | null, }; + +export type FarmGcsLocation = { id: string, created_at: string, updated_at: string, farm_id: string, gcs_location_id: string, role: string, }; + +export type FarmGcsLocationQueryBindValues = { id: string, } | { farm_id: string, } | { gcs_location_id: string, }; + +export type FarmMember = { id: string, created_at: string, updated_at: string, farm_id: string, member_pubkey: string, role: string, }; + +export type FarmMemberClaim = { id: string, created_at: string, updated_at: string, member_pubkey: string, farm_pubkey: string, }; + +export type FarmMemberClaimQueryBindValues = { id: string, } | { member_pubkey: string, } | { farm_pubkey: string, }; + +export type FarmMemberQueryBindValues = { id: string, } | { farm_id: string, } | { member_pubkey: string, }; + +export type FarmQueryBindValues = { id: string, } | { d_tag: string, } | { pubkey: string, }; + +export type FarmTag = { id: string, created_at: string, updated_at: string, farm_id: string, tag: string, }; + +export type FarmTagQueryBindValues = { id: string, } | { farm_id: string, } | { tag: string, }; + +export type GcsLocation = { id: string, created_at: string, updated_at: string, d_tag: string, lat: number, lng: number, geohash: string, point: string, polygon: string, accuracy: number | null, altitude: number | null, tag_0: string | null, label: string | null, area: number | null, elevation: number | null, soil: string | null, climate: string | null, gc_id: string | null, gc_name: string | null, gc_admin1_id: string | null, gc_admin1_name: string | null, gc_country_id: string | null, gc_country_name: string | null, }; + +export type GcsLocationFarmArgs = { id: string, }; + +export type GcsLocationFindManyRel = { on_trade_product: GcsLocationTradeProductArgs, } | { off_trade_product: GcsLocationTradeProductArgs, } | { on_farm: GcsLocationFarmArgs, } | { off_farm: GcsLocationFarmArgs, } | { on_plot: GcsLocationPlotArgs, } | { off_plot: GcsLocationPlotArgs, }; + +export type GcsLocationPlotArgs = { id: string, }; + +export type GcsLocationQueryBindValues = { id: string, } | { d_tag: string, } | { geohash: string, }; + +export type GcsLocationTradeProductArgs = { id: string, }; + +export type IFarmCreate = IFarmFields; + +export type IFarmCreateResolve = ReplicaSchemaResult<Farm>; + +export type IFarmDelete = IFarmFindOne; + +export type IFarmDeleteResolve = ReplicaSchemaResult<string>; + +export type IFarmFields = { d_tag: string, pubkey: string, name: string, about?: string | null, website?: string | null, picture?: string | null, banner?: string | null, location_primary?: string | null, location_city?: string | null, location_region?: string | null, location_country?: string | null, }; + +export type IFarmFieldsFilter = { id?: string, created_at?: string, updated_at?: string, d_tag?: string, pubkey?: string, name?: string, about?: string, website?: string, picture?: string, banner?: string, location_primary?: string, location_city?: string, location_region?: string, location_country?: string, }; + +export type IFarmFieldsPartial = { d_tag?: ReplicaStoreJsonValue | null, pubkey?: ReplicaStoreJsonValue | null, name?: ReplicaStoreJsonValue | null, about?: ReplicaStoreJsonValue | null, website?: ReplicaStoreJsonValue | null, picture?: ReplicaStoreJsonValue | null, banner?: ReplicaStoreJsonValue | null, location_primary?: ReplicaStoreJsonValue | null, location_city?: ReplicaStoreJsonValue | null, location_region?: ReplicaStoreJsonValue | null, location_country?: ReplicaStoreJsonValue | null, }; + +export type IFarmFindMany = IFarmFindManyArgs; + +export type IFarmFindManyArgs = { filter: IFarmFieldsFilter | null, }; + +export type IFarmFindManyResolve = ReplicaSchemaResultList<Farm>; + +export type IFarmFindOne = IFarmFindOneArgs; + +export type IFarmFindOneArgs = { on: FarmQueryBindValues, }; + +export type IFarmFindOneResolve = ReplicaSchemaResult<Farm | null>; + +export type IFarmGcsLocationCreate = IFarmGcsLocationFields; + +export type IFarmGcsLocationCreateResolve = ReplicaSchemaResult<FarmGcsLocation>; + +export type IFarmGcsLocationDelete = IFarmGcsLocationFindOne; + +export type IFarmGcsLocationDeleteResolve = ReplicaSchemaResult<string>; + +export type IFarmGcsLocationFields = { farm_id: string, gcs_location_id: string, role: string, }; + +export type IFarmGcsLocationFieldsFilter = { id?: string, created_at?: string, updated_at?: string, farm_id?: string, gcs_location_id?: string, role?: string, }; + +export type IFarmGcsLocationFieldsPartial = { farm_id?: ReplicaStoreJsonValue | null, gcs_location_id?: ReplicaStoreJsonValue | null, role?: ReplicaStoreJsonValue | null, }; + +export type IFarmGcsLocationFindMany = IFarmGcsLocationFindManyArgs; + +export type IFarmGcsLocationFindManyArgs = { filter: IFarmGcsLocationFieldsFilter | null, }; + +export type IFarmGcsLocationFindManyResolve = ReplicaSchemaResultList<FarmGcsLocation>; + +export type IFarmGcsLocationFindOne = IFarmGcsLocationFindOneArgs; + +export type IFarmGcsLocationFindOneArgs = { on: FarmGcsLocationQueryBindValues, }; + +export type IFarmGcsLocationFindOneResolve = ReplicaSchemaResult<FarmGcsLocation | null>; + +export type IFarmGcsLocationUpdate = IFarmGcsLocationUpdateArgs; + +export type IFarmGcsLocationUpdateArgs = { on: FarmGcsLocationQueryBindValues, fields: IFarmGcsLocationFieldsPartial, }; + +export type IFarmGcsLocationUpdateResolve = ReplicaSchemaResult<FarmGcsLocation>; + +export type IFarmMemberClaimCreate = IFarmMemberClaimFields; + +export type IFarmMemberClaimCreateResolve = ReplicaSchemaResult<FarmMemberClaim>; + +export type IFarmMemberClaimDelete = IFarmMemberClaimFindOne; + +export type IFarmMemberClaimDeleteResolve = ReplicaSchemaResult<string>; + +export type IFarmMemberClaimFields = { member_pubkey: string, farm_pubkey: string, }; + +export type IFarmMemberClaimFieldsFilter = { id?: string, created_at?: string, updated_at?: string, member_pubkey?: string, farm_pubkey?: string, }; + +export type IFarmMemberClaimFieldsPartial = { member_pubkey?: ReplicaStoreJsonValue | null, farm_pubkey?: ReplicaStoreJsonValue | null, }; + +export type IFarmMemberClaimFindMany = IFarmMemberClaimFindManyArgs; + +export type IFarmMemberClaimFindManyArgs = { filter: IFarmMemberClaimFieldsFilter | null, }; + +export type IFarmMemberClaimFindManyResolve = ReplicaSchemaResultList<FarmMemberClaim>; + +export type IFarmMemberClaimFindOne = IFarmMemberClaimFindOneArgs; + +export type IFarmMemberClaimFindOneArgs = { on: FarmMemberClaimQueryBindValues, }; + +export type IFarmMemberClaimFindOneResolve = ReplicaSchemaResult<FarmMemberClaim | null>; + +export type IFarmMemberClaimUpdate = IFarmMemberClaimUpdateArgs; + +export type IFarmMemberClaimUpdateArgs = { on: FarmMemberClaimQueryBindValues, fields: IFarmMemberClaimFieldsPartial, }; + +export type IFarmMemberClaimUpdateResolve = ReplicaSchemaResult<FarmMemberClaim>; + +export type IFarmMemberCreate = IFarmMemberFields; + +export type IFarmMemberCreateResolve = ReplicaSchemaResult<FarmMember>; + +export type IFarmMemberDelete = IFarmMemberFindOne; + +export type IFarmMemberDeleteResolve = ReplicaSchemaResult<string>; + +export type IFarmMemberFields = { farm_id: string, member_pubkey: string, role: string, }; + +export type IFarmMemberFieldsFilter = { id?: string, created_at?: string, updated_at?: string, farm_id?: string, member_pubkey?: string, role?: string, }; + +export type IFarmMemberFieldsPartial = { farm_id?: ReplicaStoreJsonValue | null, member_pubkey?: ReplicaStoreJsonValue | null, role?: ReplicaStoreJsonValue | null, }; + +export type IFarmMemberFindMany = IFarmMemberFindManyArgs; + +export type IFarmMemberFindManyArgs = { filter: IFarmMemberFieldsFilter | null, }; + +export type IFarmMemberFindManyResolve = ReplicaSchemaResultList<FarmMember>; + +export type IFarmMemberFindOne = IFarmMemberFindOneArgs; + +export type IFarmMemberFindOneArgs = { on: FarmMemberQueryBindValues, }; + +export type IFarmMemberFindOneResolve = ReplicaSchemaResult<FarmMember | null>; + +export type IFarmMemberUpdate = IFarmMemberUpdateArgs; + +export type IFarmMemberUpdateArgs = { on: FarmMemberQueryBindValues, fields: IFarmMemberFieldsPartial, }; + +export type IFarmMemberUpdateResolve = ReplicaSchemaResult<FarmMember>; + +export type IFarmTagCreate = IFarmTagFields; + +export type IFarmTagCreateResolve = ReplicaSchemaResult<FarmTag>; + +export type IFarmTagDelete = IFarmTagFindOne; + +export type IFarmTagDeleteResolve = ReplicaSchemaResult<string>; + +export type IFarmTagFields = { farm_id: string, tag: string, }; + +export type IFarmTagFieldsFilter = { id?: string, created_at?: string, updated_at?: string, farm_id?: string, tag?: string, }; + +export type IFarmTagFieldsPartial = { farm_id?: ReplicaStoreJsonValue | null, tag?: ReplicaStoreJsonValue | null, }; + +export type IFarmTagFindMany = IFarmTagFindManyArgs; + +export type IFarmTagFindManyArgs = { filter: IFarmTagFieldsFilter | null, }; + +export type IFarmTagFindManyResolve = ReplicaSchemaResultList<FarmTag>; + +export type IFarmTagFindOne = IFarmTagFindOneArgs; + +export type IFarmTagFindOneArgs = { on: FarmTagQueryBindValues, }; + +export type IFarmTagFindOneResolve = ReplicaSchemaResult<FarmTag | null>; + +export type IFarmTagUpdate = IFarmTagUpdateArgs; + +export type IFarmTagUpdateArgs = { on: FarmTagQueryBindValues, fields: IFarmTagFieldsPartial, }; + +export type IFarmTagUpdateResolve = ReplicaSchemaResult<FarmTag>; + +export type IFarmUpdate = IFarmUpdateArgs; + +export type IFarmUpdateArgs = { on: FarmQueryBindValues, fields: IFarmFieldsPartial, }; + +export type IFarmUpdateResolve = ReplicaSchemaResult<Farm>; + +export type IGcsLocationCreate = IGcsLocationFields; + +export type IGcsLocationCreateResolve = ReplicaSchemaResult<GcsLocation>; + +export type IGcsLocationDelete = IGcsLocationFindOne; + +export type IGcsLocationDeleteResolve = ReplicaSchemaResult<string>; + +export type IGcsLocationFields = { d_tag: string, lat: number, lng: number, geohash: string, point: string, polygon: string, accuracy?: number | null, altitude?: number | null, tag_0?: string | null, label?: string | null, area?: number | null, elevation?: number | null, soil?: string | null, climate?: string | null, gc_id?: string | null, gc_name?: string | null, gc_admin1_id?: string | null, gc_admin1_name?: string | null, gc_country_id?: string | null, gc_country_name?: string | null, }; + +export type IGcsLocationFieldsFilter = { id?: string, created_at?: string, updated_at?: string, d_tag?: string, lat?: number, lng?: number, geohash?: string, point?: string, polygon?: string, accuracy?: number, altitude?: number, tag_0?: string, label?: string, area?: number, elevation?: number, soil?: string, climate?: string, gc_id?: string, gc_name?: string, gc_admin1_id?: string, gc_admin1_name?: string, gc_country_id?: string, gc_country_name?: string, }; + +export type IGcsLocationFieldsPartial = { d_tag?: ReplicaStoreJsonValue | null, lat?: ReplicaStoreJsonValue | null, lng?: ReplicaStoreJsonValue | null, geohash?: ReplicaStoreJsonValue | null, point?: ReplicaStoreJsonValue | null, polygon?: ReplicaStoreJsonValue | null, accuracy?: ReplicaStoreJsonValue | null, altitude?: ReplicaStoreJsonValue | null, tag_0?: ReplicaStoreJsonValue | null, label?: ReplicaStoreJsonValue | null, area?: ReplicaStoreJsonValue | null, elevation?: ReplicaStoreJsonValue | null, soil?: ReplicaStoreJsonValue | null, climate?: ReplicaStoreJsonValue | null, gc_id?: ReplicaStoreJsonValue | null, gc_name?: ReplicaStoreJsonValue | null, gc_admin1_id?: ReplicaStoreJsonValue | null, gc_admin1_name?: ReplicaStoreJsonValue | null, gc_country_id?: ReplicaStoreJsonValue | null, gc_country_name?: ReplicaStoreJsonValue | null, }; + +export type IGcsLocationFindMany = { filter: IGcsLocationFieldsFilter | null, } | { rel: GcsLocationFindManyRel, }; + +export type IGcsLocationFindManyResolve = ReplicaSchemaResultList<GcsLocation>; + +export type IGcsLocationFindOne = IGcsLocationFindOneArgs | IGcsLocationFindOneRelArgs; + +export type IGcsLocationFindOneArgs = { on: GcsLocationQueryBindValues, }; + +export type IGcsLocationFindOneRelArgs = { rel: GcsLocationFindManyRel, }; + +export type IGcsLocationFindOneResolve = ReplicaSchemaResult<GcsLocation | null>; + +export type IGcsLocationUpdate = IGcsLocationUpdateArgs; + +export type IGcsLocationUpdateArgs = { on: GcsLocationQueryBindValues, fields: IGcsLocationFieldsPartial, }; + +export type IGcsLocationUpdateResolve = ReplicaSchemaResult<GcsLocation>; + +export type ILogErrorCreate = ILogErrorFields; + +export type ILogErrorCreateResolve = ReplicaSchemaResult<LogError>; + +export type ILogErrorDelete = ILogErrorFindOne; + +export type ILogErrorDeleteResolve = ReplicaSchemaResult<string>; + +export type ILogErrorFields = { error: string, message: string, stack_trace?: string | null, cause?: string | null, app_system: string, app_version: string, nostr_pubkey: string, data?: string | null, }; + +export type ILogErrorFieldsFilter = { id?: string, created_at?: string, updated_at?: string, error?: string, message?: string, stack_trace?: string, cause?: string, app_system?: string, app_version?: string, nostr_pubkey?: string, data?: string, }; + +export type ILogErrorFieldsPartial = { error?: ReplicaStoreJsonValue | null, message?: ReplicaStoreJsonValue | null, stack_trace?: ReplicaStoreJsonValue | null, cause?: ReplicaStoreJsonValue | null, app_system?: ReplicaStoreJsonValue | null, app_version?: ReplicaStoreJsonValue | null, nostr_pubkey?: ReplicaStoreJsonValue | null, data?: ReplicaStoreJsonValue | null, }; + +export type ILogErrorFindMany = ILogErrorFindManyArgs; + +export type ILogErrorFindManyArgs = { filter: ILogErrorFieldsFilter | null, }; + +export type ILogErrorFindManyResolve = ReplicaSchemaResultList<LogError>; + +export type ILogErrorFindOne = ILogErrorFindOneArgs; + +export type ILogErrorFindOneArgs = { on: LogErrorQueryBindValues, }; + +export type ILogErrorFindOneResolve = ReplicaSchemaResult<LogError | null>; + +export type ILogErrorUpdate = ILogErrorUpdateArgs; + +export type ILogErrorUpdateArgs = { on: LogErrorQueryBindValues, fields: ILogErrorFieldsPartial, }; + +export type ILogErrorUpdateResolve = ReplicaSchemaResult<LogError>; + +export type IMediaImageCreate = IMediaImageFields; + +export type IMediaImageCreateResolve = ReplicaSchemaResult<MediaImage>; + +export type IMediaImageDelete = IMediaImageFindOne; + +export type IMediaImageDeleteResolve = ReplicaSchemaResult<string>; + +export type IMediaImageFields = { file_path: string, mime_type: string, res_base: string, res_path: string, label?: string | null, description?: string | null, }; + +export type IMediaImageFieldsFilter = { id?: string, created_at?: string, updated_at?: string, file_path?: string, mime_type?: string, res_base?: string, res_path?: string, label?: string, description?: string, }; + +export type IMediaImageFieldsPartial = { file_path?: ReplicaStoreJsonValue | null, mime_type?: ReplicaStoreJsonValue | null, res_base?: ReplicaStoreJsonValue | null, res_path?: ReplicaStoreJsonValue | null, label?: ReplicaStoreJsonValue | null, description?: ReplicaStoreJsonValue | null, }; + +export type IMediaImageFindMany = { filter: IMediaImageFieldsFilter | null, } | { rel: MediaImageFindManyRel, }; + +export type IMediaImageFindManyResolve = ReplicaSchemaResultList<MediaImage>; + +export type IMediaImageFindOne = IMediaImageFindOneArgs | IMediaImageFindOneRelArgs; + +export type IMediaImageFindOneArgs = { on: MediaImageQueryBindValues, }; + +export type IMediaImageFindOneRelArgs = { rel: MediaImageFindManyRel, }; + +export type IMediaImageFindOneResolve = ReplicaSchemaResult<MediaImage | null>; + +export type IMediaImageUpdate = IMediaImageUpdateArgs; + +export type IMediaImageUpdateArgs = { on: MediaImageQueryBindValues, fields: IMediaImageFieldsPartial, }; + +export type IMediaImageUpdateResolve = ReplicaSchemaResult<MediaImage>; + +export type INostrEventHeadCreate = INostrEventHeadFields; + +export type INostrEventHeadCreateResolve = ReplicaSchemaResult<NostrEventHead>; + +export type INostrEventHeadDelete = INostrEventHeadFindOne; + +export type INostrEventHeadDeleteResolve = ReplicaSchemaResult<string>; + +export type INostrEventHeadFields = { key: string, kind: number, pubkey: string, d_tag: string, last_event_id: string, last_created_at: number, content_hash: string, }; + +export type INostrEventHeadFieldsFilter = { id?: string, created_at?: string, updated_at?: string, key?: string, kind?: number, pubkey?: string, d_tag?: string, last_event_id?: string, last_created_at?: number, content_hash?: string, }; + +export type INostrEventHeadFieldsPartial = { key?: ReplicaStoreJsonValue | null, kind?: ReplicaStoreJsonValue | null, pubkey?: ReplicaStoreJsonValue | null, d_tag?: ReplicaStoreJsonValue | null, last_event_id?: ReplicaStoreJsonValue | null, last_created_at?: ReplicaStoreJsonValue | null, content_hash?: ReplicaStoreJsonValue | null, }; + +export type INostrEventHeadFindMany = INostrEventHeadFindManyArgs; + +export type INostrEventHeadFindManyArgs = { filter: INostrEventHeadFieldsFilter | null, }; + +export type INostrEventHeadFindManyResolve = ReplicaSchemaResultList<NostrEventHead>; + +export type INostrEventHeadFindOne = INostrEventHeadFindOneArgs; + +export type INostrEventHeadFindOneArgs = { on: NostrEventHeadQueryBindValues, }; + +export type INostrEventHeadFindOneResolve = ReplicaSchemaResult<NostrEventHead | null>; + +export type INostrEventHeadUpdate = INostrEventHeadUpdateArgs; + +export type INostrEventHeadUpdateArgs = { on: NostrEventHeadQueryBindValues, fields: INostrEventHeadFieldsPartial, }; + +export type INostrEventHeadUpdateResolve = ReplicaSchemaResult<NostrEventHead>; + +export type INostrProfileCreate = INostrProfileFields; + +export type INostrProfileCreateResolve = ReplicaSchemaResult<NostrProfile>; + +export type INostrProfileDelete = INostrProfileFindOne; + +export type INostrProfileDeleteResolve = ReplicaSchemaResult<string>; + +export type INostrProfileFields = { public_key: string, profile_type: string, name: string, display_name?: string | null, about?: string | null, website?: string | null, picture?: string | null, banner?: string | null, nip05?: string | null, lud06?: string | null, lud16?: string | null, }; + +export type INostrProfileFieldsFilter = { id?: string, created_at?: string, updated_at?: string, public_key?: string, profile_type?: string, name?: string, display_name?: string, about?: string, website?: string, picture?: string, banner?: string, nip05?: string, lud06?: string, lud16?: string, }; + +export type INostrProfileFieldsPartial = { public_key?: ReplicaStoreJsonValue | null, profile_type?: ReplicaStoreJsonValue | null, name?: ReplicaStoreJsonValue | null, display_name?: ReplicaStoreJsonValue | null, about?: ReplicaStoreJsonValue | null, website?: ReplicaStoreJsonValue | null, picture?: ReplicaStoreJsonValue | null, banner?: ReplicaStoreJsonValue | null, nip05?: ReplicaStoreJsonValue | null, lud06?: ReplicaStoreJsonValue | null, lud16?: ReplicaStoreJsonValue | null, }; + +export type INostrProfileFindMany = { filter: INostrProfileFieldsFilter | null, } | { rel: NostrProfileFindManyRel, }; + +export type INostrProfileFindManyResolve = ReplicaSchemaResultList<NostrProfile>; + +export type INostrProfileFindOne = INostrProfileFindOneArgs | INostrProfileFindOneRelArgs; + +export type INostrProfileFindOneArgs = { on: NostrProfileQueryBindValues, }; + +export type INostrProfileFindOneRelArgs = { rel: NostrProfileFindManyRel, }; + +export type INostrProfileFindOneResolve = ReplicaSchemaResult<NostrProfile | null>; + +export type INostrProfileRelayRelation = { nostr_profile: NostrProfileQueryBindValues, nostr_relay: NostrRelayQueryBindValues, }; + +export type INostrProfileRelayResolve = ReplicaSchemaResultPass; + +export type INostrProfileUpdate = INostrProfileUpdateArgs; + +export type INostrProfileUpdateArgs = { on: NostrProfileQueryBindValues, fields: INostrProfileFieldsPartial, }; + +export type INostrProfileUpdateResolve = ReplicaSchemaResult<NostrProfile>; + +export type INostrRelayCreate = INostrRelayFields; + +export type INostrRelayCreateResolve = ReplicaSchemaResult<NostrRelay>; + +export type INostrRelayDelete = INostrRelayFindOne; + +export type INostrRelayDeleteResolve = ReplicaSchemaResult<string>; + +export type INostrRelayFields = { url: string, relay_id?: string | null, name?: string | null, description?: string | null, pubkey?: string | null, contact?: string | null, supported_nips?: string | null, software?: string | null, version?: string | null, data?: string | null, }; + +export type INostrRelayFieldsFilter = { id?: string, created_at?: string, updated_at?: string, url?: string, relay_id?: string, name?: string, description?: string, pubkey?: string, contact?: string, supported_nips?: string, software?: string, version?: string, data?: string, }; + +export type INostrRelayFieldsPartial = { url?: ReplicaStoreJsonValue | null, relay_id?: ReplicaStoreJsonValue | null, name?: ReplicaStoreJsonValue | null, description?: ReplicaStoreJsonValue | null, pubkey?: ReplicaStoreJsonValue | null, contact?: ReplicaStoreJsonValue | null, supported_nips?: ReplicaStoreJsonValue | null, software?: ReplicaStoreJsonValue | null, version?: ReplicaStoreJsonValue | null, data?: ReplicaStoreJsonValue | null, }; + +export type INostrRelayFindMany = { filter: INostrRelayFieldsFilter | null, } | { rel: NostrRelayFindManyRel, }; + +export type INostrRelayFindManyResolve = ReplicaSchemaResultList<NostrRelay>; + +export type INostrRelayFindOne = INostrRelayFindOneArgs | INostrRelayFindOneRelArgs; + +export type INostrRelayFindOneArgs = { on: NostrRelayQueryBindValues, }; + +export type INostrRelayFindOneRelArgs = { rel: NostrRelayFindManyRel, }; + +export type INostrRelayFindOneResolve = ReplicaSchemaResult<NostrRelay | null>; + +export type INostrRelayUpdate = INostrRelayUpdateArgs; + +export type INostrRelayUpdateArgs = { on: NostrRelayQueryBindValues, fields: INostrRelayFieldsPartial, }; + +export type INostrRelayUpdateResolve = ReplicaSchemaResult<NostrRelay>; + +export type IPlotCreate = IPlotFields; + +export type IPlotCreateResolve = ReplicaSchemaResult<Plot>; + +export type IPlotDelete = IPlotFindOne; + +export type IPlotDeleteResolve = ReplicaSchemaResult<string>; + +export type IPlotFields = { d_tag: string, farm_id: string, name: string, about?: string | null, location_primary?: string | null, location_city?: string | null, location_region?: string | null, location_country?: string | null, }; + +export type IPlotFieldsFilter = { id?: string, created_at?: string, updated_at?: string, d_tag?: string, farm_id?: string, name?: string, about?: string, location_primary?: string, location_city?: string, location_region?: string, location_country?: string, }; + +export type IPlotFieldsPartial = { d_tag?: ReplicaStoreJsonValue | null, farm_id?: ReplicaStoreJsonValue | null, name?: ReplicaStoreJsonValue | null, about?: ReplicaStoreJsonValue | null, location_primary?: ReplicaStoreJsonValue | null, location_city?: ReplicaStoreJsonValue | null, location_region?: ReplicaStoreJsonValue | null, location_country?: ReplicaStoreJsonValue | null, }; + +export type IPlotFindMany = IPlotFindManyArgs; + +export type IPlotFindManyArgs = { filter: IPlotFieldsFilter | null, }; + +export type IPlotFindManyResolve = ReplicaSchemaResultList<Plot>; + +export type IPlotFindOne = IPlotFindOneArgs; + +export type IPlotFindOneArgs = { on: PlotQueryBindValues, }; + +export type IPlotFindOneResolve = ReplicaSchemaResult<Plot | null>; + +export type IPlotGcsLocationCreate = IPlotGcsLocationFields; + +export type IPlotGcsLocationCreateResolve = ReplicaSchemaResult<PlotGcsLocation>; + +export type IPlotGcsLocationDelete = IPlotGcsLocationFindOne; + +export type IPlotGcsLocationDeleteResolve = ReplicaSchemaResult<string>; + +export type IPlotGcsLocationFields = { plot_id: string, gcs_location_id: string, role: string, }; + +export type IPlotGcsLocationFieldsFilter = { id?: string, created_at?: string, updated_at?: string, plot_id?: string, gcs_location_id?: string, role?: string, }; + +export type IPlotGcsLocationFieldsPartial = { plot_id?: ReplicaStoreJsonValue | null, gcs_location_id?: ReplicaStoreJsonValue | null, role?: ReplicaStoreJsonValue | null, }; + +export type IPlotGcsLocationFindMany = IPlotGcsLocationFindManyArgs; + +export type IPlotGcsLocationFindManyArgs = { filter: IPlotGcsLocationFieldsFilter | null, }; + +export type IPlotGcsLocationFindManyResolve = ReplicaSchemaResultList<PlotGcsLocation>; + +export type IPlotGcsLocationFindOne = IPlotGcsLocationFindOneArgs; + +export type IPlotGcsLocationFindOneArgs = { on: PlotGcsLocationQueryBindValues, }; + +export type IPlotGcsLocationFindOneResolve = ReplicaSchemaResult<PlotGcsLocation | null>; + +export type IPlotGcsLocationUpdate = IPlotGcsLocationUpdateArgs; + +export type IPlotGcsLocationUpdateArgs = { on: PlotGcsLocationQueryBindValues, fields: IPlotGcsLocationFieldsPartial, }; + +export type IPlotGcsLocationUpdateResolve = ReplicaSchemaResult<PlotGcsLocation>; + +export type IPlotTagCreate = IPlotTagFields; + +export type IPlotTagCreateResolve = ReplicaSchemaResult<PlotTag>; + +export type IPlotTagDelete = IPlotTagFindOne; + +export type IPlotTagDeleteResolve = ReplicaSchemaResult<string>; + +export type IPlotTagFields = { plot_id: string, tag: string, }; + +export type IPlotTagFieldsFilter = { id?: string, created_at?: string, updated_at?: string, plot_id?: string, tag?: string, }; + +export type IPlotTagFieldsPartial = { plot_id?: ReplicaStoreJsonValue | null, tag?: ReplicaStoreJsonValue | null, }; + +export type IPlotTagFindMany = IPlotTagFindManyArgs; + +export type IPlotTagFindManyArgs = { filter: IPlotTagFieldsFilter | null, }; + +export type IPlotTagFindManyResolve = ReplicaSchemaResultList<PlotTag>; + +export type IPlotTagFindOne = IPlotTagFindOneArgs; + +export type IPlotTagFindOneArgs = { on: PlotTagQueryBindValues, }; + +export type IPlotTagFindOneResolve = ReplicaSchemaResult<PlotTag | null>; + +export type IPlotTagUpdate = IPlotTagUpdateArgs; + +export type IPlotTagUpdateArgs = { on: PlotTagQueryBindValues, fields: IPlotTagFieldsPartial, }; + +export type IPlotTagUpdateResolve = ReplicaSchemaResult<PlotTag>; + +export type IPlotUpdate = IPlotUpdateArgs; + +export type IPlotUpdateArgs = { on: PlotQueryBindValues, fields: IPlotFieldsPartial, }; + +export type IPlotUpdateResolve = ReplicaSchemaResult<Plot>; + +export type ITradeProductCreate = ITradeProductFields; + +export type ITradeProductCreateResolve = ReplicaSchemaResult<TradeProduct>; + +export type ITradeProductDelete = ITradeProductFindOne; + +export type ITradeProductDeleteResolve = ReplicaSchemaResult<string>; + +export type ITradeProductFields = { key: string, category: string, title: string, summary: string, process: string, lot: string, profile: string, year: bigint, qty_amt: number, qty_amt_exact: string, qty_unit: string, qty_label?: string | null, qty_avail?: bigint | null, price_amt: number, price_amt_exact: string, price_currency: string, price_qty_amt: number, price_qty_amt_exact: string, price_qty_unit: string, listing_addr?: string | null, primary_bin_id?: string | null, verified_primary_bin_id?: string | null, notes?: string | null, }; + +export type ITradeProductFieldsFilter = { id?: string, created_at?: string, updated_at?: string, key?: string, category?: string, title?: string, summary?: string, process?: string, lot?: string, profile?: string, year?: bigint, qty_amt?: number, qty_amt_exact?: string, qty_unit?: string, qty_label?: string, qty_avail?: bigint, price_amt?: number, price_amt_exact?: string, price_currency?: string, price_qty_amt?: number, price_qty_amt_exact?: string, price_qty_unit?: string, listing_addr?: string, primary_bin_id?: string, verified_primary_bin_id?: string, notes?: string, }; + +export type ITradeProductFieldsPartial = { key?: ReplicaStoreJsonValue | null, category?: ReplicaStoreJsonValue | null, title?: ReplicaStoreJsonValue | null, summary?: ReplicaStoreJsonValue | null, process?: ReplicaStoreJsonValue | null, lot?: ReplicaStoreJsonValue | null, profile?: ReplicaStoreJsonValue | null, year?: ReplicaStoreJsonValue | null, qty_amt?: ReplicaStoreJsonValue | null, qty_amt_exact?: ReplicaStoreJsonValue | null, qty_unit?: ReplicaStoreJsonValue | null, qty_label?: ReplicaStoreJsonValue | null, qty_avail?: ReplicaStoreJsonValue | null, price_amt?: ReplicaStoreJsonValue | null, price_amt_exact?: ReplicaStoreJsonValue | null, price_currency?: ReplicaStoreJsonValue | null, price_qty_amt?: ReplicaStoreJsonValue | null, price_qty_amt_exact?: ReplicaStoreJsonValue | null, price_qty_unit?: ReplicaStoreJsonValue | null, listing_addr?: ReplicaStoreJsonValue | null, primary_bin_id?: ReplicaStoreJsonValue | null, verified_primary_bin_id?: ReplicaStoreJsonValue | null, notes?: ReplicaStoreJsonValue | null, }; + +export type ITradeProductFindMany = ITradeProductFindManyArgs; + +export type ITradeProductFindManyArgs = { filter: ITradeProductFieldsFilter | null, }; + +export type ITradeProductFindManyResolve = ReplicaSchemaResultList<TradeProduct>; + +export type ITradeProductFindOne = ITradeProductFindOneArgs; + +export type ITradeProductFindOneArgs = { on: TradeProductQueryBindValues, }; + +export type ITradeProductFindOneResolve = ReplicaSchemaResult<TradeProduct | null>; + +export type ITradeProductLocationRelation = { trade_product: TradeProductQueryBindValues, gcs_location: GcsLocationQueryBindValues, }; + +export type ITradeProductLocationResolve = ReplicaSchemaResultPass; + +export type ITradeProductMediaRelation = { trade_product: TradeProductQueryBindValues, media_image: MediaImageQueryBindValues, }; + +export type ITradeProductMediaResolve = ReplicaSchemaResultPass; + +export type ITradeProductUpdate = ITradeProductUpdateArgs; + +export type ITradeProductUpdateArgs = { on: TradeProductQueryBindValues, fields: ITradeProductFieldsPartial, }; + +export type ITradeProductUpdateResolve = ReplicaSchemaResult<TradeProduct>; + +export type LogError = { id: string, created_at: string, updated_at: string, error: string, message: string, stack_trace: string | null, cause: string | null, app_system: string, app_version: string, nostr_pubkey: string, data: string | null, }; + +export type LogErrorQueryBindValues = { id: string, } | { nostr_pubkey: string, }; + +export type MediaImage = { id: string, created_at: string, updated_at: string, file_path: string, mime_type: string, res_base: string, res_path: string, label: string | null, description: string | null, }; + +export type MediaImageFindManyRel = { on_trade_product: MediaImageTradeProductArgs, } | { off_trade_product: MediaImageTradeProductArgs, }; + +export type MediaImageQueryBindValues = { id: string, } | { file_path: string, }; + +export type MediaImageTradeProductArgs = { id: string, }; + +export type NostrEventHead = { id: string, created_at: string, updated_at: string, key: string, kind: number, pubkey: string, d_tag: string, last_event_id: string, last_created_at: number, content_hash: string, }; + +export type NostrEventHeadQueryBindValues = { id: string, } | { key: string, }; + +export type NostrProfile = { id: string, created_at: string, updated_at: string, public_key: string, profile_type: string, name: string, display_name: string | null, about: string | null, website: string | null, picture: string | null, banner: string | null, nip05: string | null, lud06: string | null, lud16: string | null, }; + +export type NostrProfileFindManyRel = { on_relay: NostrProfileRelayArgs, } | { off_relay: NostrProfileRelayArgs, }; + +export type NostrProfileQueryBindValues = { id: string, } | { public_key: string, }; + +export type NostrProfileRelayArgs = { id: string, }; + +export type NostrRelay = { id: string, created_at: string, updated_at: string, url: string, relay_id: string | null, name: string | null, description: string | null, pubkey: string | null, contact: string | null, supported_nips: string | null, software: string | null, version: string | null, data: string | null, }; + +export type NostrRelayFindManyRel = { on_profile: NostrRelayProfileArgs, } | { off_profile: NostrRelayProfileArgs, }; + +export type NostrRelayProfileArgs = { public_key: string, }; + +export type NostrRelayQueryBindValues = { id: string, } | { url: string, }; + +export type Plot = { id: string, created_at: string, updated_at: string, d_tag: string, farm_id: string, name: string, about: string | null, location_primary: string | null, location_city: string | null, location_region: string | null, location_country: string | null, }; + +export type PlotGcsLocation = { id: string, created_at: string, updated_at: string, plot_id: string, gcs_location_id: string, role: string, }; + +export type PlotGcsLocationQueryBindValues = { id: string, } | { plot_id: string, } | { gcs_location_id: string, }; + +export type PlotQueryBindValues = { id: string, } | { d_tag: string, } | { farm_id: string, }; + +export type PlotTag = { id: string, created_at: string, updated_at: string, plot_id: string, tag: string, }; + +export type PlotTagQueryBindValues = { id: string, } | { plot_id: string, } | { tag: string, }; + +export type ReplicaSchemaError<T> = { error: T, }; + +export type ReplicaSchemaResult<T> = { result: T, }; + +export type ReplicaSchemaResultList<T> = { results: Array<T>, }; + +export type ReplicaSchemaResultPass = { pass: boolean, }; + +export type ReplicaStoreJsonValue = null | boolean | number | string | Array<ReplicaStoreJsonValue> | { [key: string]: ReplicaStoreJsonValue }; + +export type TradeProduct = { id: string, created_at: string, updated_at: string, key: string, category: string, title: string, summary: string, process: string, lot: string, profile: string, year: bigint, qty_amt: number, qty_amt_exact: string | null, qty_unit: string, qty_label: string | null, qty_avail: bigint | null, price_amt: number, price_amt_exact: string | null, price_currency: string, price_qty_amt: number, price_qty_amt_exact: string | null, price_qty_unit: string, listing_addr: string | null, primary_bin_id: string | null, verified_primary_bin_id: string | null, notes: string | null, }; + +export type TradeProductQueryBindValues = { id: string, }; diff --git a/crates/replica_sync_wasm/Cargo.toml b/crates/replica_sync_wasm/Cargo.toml @@ -20,7 +20,10 @@ radroots_event = { workspace = true, default-features = false, features = [ "serde", ] } radroots_sdk_sql_wasm_runtime = { workspace = true } -radroots_replica_sync = { workspace = true, features = ["std", "legacy-ingest"] } +radroots_replica_sync = { workspace = true, features = [ + "std", + "legacy-ingest", +] } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } serde-wasm-bindgen = { workspace = true } diff --git a/crates/sdk/Cargo.toml b/crates/sdk/Cargo.toml @@ -13,12 +13,29 @@ readme = "README.md" documentation = "https://docs.rs/radroots_sdk" keywords = ["radroots", "sdk", "local-food", "nostr"] categories = ["api-bindings", "asynchronous", "network-programming"] -include = ["src/**", "tests/**", "!tests/package_boundary.rs", "examples/**", "Cargo.toml", "README.md", "LICENSE-APACHE", "LICENSE-MIT"] +include = [ + "src/**", + "tests/**", + "!tests/package_boundary.rs", + "examples/**", + "Cargo.toml", + "README.md", + "LICENSE-APACHE", + "LICENSE-MIT", +] autotests = false autoexamples = false [package.metadata.docs.rs] -features = ["memory", "sync", "nostr", "nip46", "local-signing", "geonames", "knowledge"] +features = [ + "memory", + "sync", + "nostr", + "nip46", + "local-signing", + "geonames", + "knowledge", +] [lib] name = "radroots_sdk" @@ -28,40 +45,18 @@ default = ["memory"] memory = ["radroots_storage/memory"] sqlite = ["dep:radroots_storage_sqlite"] sync = ["dep:radroots_sync", "dep:uuid"] -nostr = [ - "sync", - "dep:radroots_nostr", - "dep:radroots_transport_nostr", -] -nip46 = [ - "nostr", - "dep:radroots_nostr_connect", -] +nostr = ["sync", "dep:radroots_nostr", "dep:radroots_transport_nostr"] +nip46 = ["nostr", "dep:radroots_nostr_connect"] local-signing = [ "dep:radroots_nostr", "dep:radroots_secrets", "radroots_nostr/signing", ] -radrootsd = [ - "sync", - "dep:reqwest", - "dep:serde", - "dep:serde_json", -] +radrootsd = ["sync", "dep:reqwest", "dep:serde", "dep:serde_json"] geonames = ["dep:radroots_geonames"] -knowledge = [ - "radroots_event/knowledge", - "radroots_event_codec/knowledge", -] +knowledge = ["radroots_event/knowledge", "radroots_event_codec/knowledge"] native = ["sqlite", "sync", "local-signing"] -full = [ - "native", - "nostr", - "nip46", - "radrootsd", - "geonames", - "knowledge", -] +full = ["native", "nostr", "nip46", "radrootsd", "geonames", "knowledge"] [dependencies] radroots_core = { workspace = true, default-features = false } @@ -70,7 +65,9 @@ radroots_event_codec = { workspace = true, default-features = false, features = "json", "std", ] } -radroots_identity = { workspace = true, default-features = false, features = ["std"] } +radroots_identity = { workspace = true, default-features = false, features = [ + "std", +] } radroots_protocol = { workspace = true, default-features = false } radroots_signing = { workspace = true, default-features = false } radroots_storage = { workspace = true, default-features = false } diff --git a/crates/sdk/LICENSE-APACHE b/crates/sdk/LICENSE-APACHE @@ -186,7 +186,7 @@ APPENDIX: How to apply the Apache License to your work. same "printed page" as the copyright notice for easier identification within third-party archives. -Copyright 2026 Tyson Lupul +Copyright 2025 Tyson Lupul Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/crates/sdk/LICENSE-MIT b/crates/sdk/LICENSE-MIT @@ -1,6 +1,6 @@ The MIT License (MIT) -Copyright (c) 2026 Tyson Lupul +Copyright (c) 2025 Tyson Lupul Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/crates/sdk_ffi/src/bin/bindgen.rs b/crates/sdk_ffi/src/bin/bindgen.rs @@ -1,3 +1,6 @@ +#![cfg_attr(coverage_nightly, feature(coverage_attribute))] + +#[cfg_attr(coverage_nightly, coverage(off))] fn main() { uniffi::uniffi_bindgen_main(); } diff --git a/crates/secrets/Cargo.toml b/crates/secrets/Cargo.toml @@ -13,7 +13,18 @@ documentation = "https://docs.rs/radroots_secrets" publish = ["crates-io"] keywords = ["radroots", "secrets", "encryption", "keyring"] categories = ["cryptography", "no-std"] -include = ["src/**", "tests/**", "!tests/consumer_migration.rs", "!tests/package_boundary.rs", "!tests/security_contract.rs", "examples/**", "Cargo.toml", "README.md", "LICENSE-APACHE", "LICENSE-MIT"] +include = [ + "src/**", + "tests/**", + "!tests/consumer_migration.rs", + "!tests/package_boundary.rs", + "!tests/security_contract.rs", + "examples/**", + "Cargo.toml", + "README.md", + "LICENSE-APACHE", + "LICENSE-MIT", +] [package.metadata.docs.rs] features = ["std", "serde", "memory", "file"] diff --git a/crates/signing/Cargo.toml b/crates/signing/Cargo.toml @@ -13,7 +13,16 @@ documentation = "https://docs.rs/radroots_signing" publish = ["crates-io"] keywords = ["radroots", "signing", "nostr", "authorization"] categories = ["cryptography", "no-std"] -include = ["src/**", "tests/**", "!tests/package_boundary.rs", "examples/**", "Cargo.toml", "README.md", "LICENSE-APACHE", "LICENSE-MIT"] +include = [ + "src/**", + "tests/**", + "!tests/package_boundary.rs", + "examples/**", + "Cargo.toml", + "README.md", + "LICENSE-APACHE", + "LICENSE-MIT", +] [package.metadata.docs.rs] features = ["std", "serde"] diff --git a/crates/storage/Cargo.toml b/crates/storage/Cargo.toml @@ -13,7 +13,18 @@ publish = ["crates-io"] documentation = "https://docs.rs/radroots_storage" keywords = ["radroots", "storage", "persistence", "events"] categories = ["database", "data-structures", "no-std"] -include = ["src/**", "tests/**", "!tests/legacy_quarantine.rs", "!tests/package_boundary.rs", "!tests/workspace_consumers.rs", "examples/**", "Cargo.toml", "README.md", "LICENSE-APACHE", "LICENSE-MIT"] +include = [ + "src/**", + "tests/**", + "!tests/legacy_quarantine.rs", + "!tests/package_boundary.rs", + "!tests/workspace_consumers.rs", + "examples/**", + "Cargo.toml", + "README.md", + "LICENSE-APACHE", + "LICENSE-MIT", +] [package.metadata.docs.rs] features = ["memory", "serde"] @@ -34,7 +45,9 @@ serde = [ [dependencies] radroots_event = { workspace = true, default-features = false } -radroots_event_codec = { workspace = true, default-features = false, features = ["json"] } +radroots_event_codec = { workspace = true, default-features = false, features = [ + "json", +] } radroots_protocol = { workspace = true, default-features = false } radroots_trade = { workspace = true, default-features = false } radroots_transport = { workspace = true, default-features = false } diff --git a/crates/storage_sqlite/Cargo.toml b/crates/storage_sqlite/Cargo.toml @@ -13,7 +13,15 @@ publish = ["crates-io"] documentation = "https://docs.rs/radroots_storage_sqlite" keywords = ["radroots", "sqlite", "storage", "database"] categories = ["database"] -include = ["src/**", "tests/**", "!tests/package_boundary.rs", "Cargo.toml", "README.md", "LICENSE-APACHE", "LICENSE-MIT"] +include = [ + "src/**", + "tests/**", + "!tests/package_boundary.rs", + "Cargo.toml", + "README.md", + "LICENSE-APACHE", + "LICENSE-MIT", +] [package.metadata.docs.rs] features = [] @@ -23,11 +31,22 @@ name = "radroots_storage_sqlite" [dependencies] fs2 = { workspace = true } -radroots_event = { workspace = true, default-features = false, features = ["serde", "std"] } -radroots_event_codec = { workspace = true, default-features = false, features = ["json", "std"] } +radroots_event = { workspace = true, default-features = false, features = [ + "serde", + "std", +] } +radroots_event_codec = { workspace = true, default-features = false, features = [ + "json", + "std", +] } radroots_secrets = { workspace = true, default-features = false } -radroots_storage = { workspace = true, default-features = false, features = ["serde"] } -radroots_transport = { workspace = true, default-features = false, features = ["serde", "std"] } +radroots_storage = { workspace = true, default-features = false, features = [ + "serde", +] } +radroots_transport = { workspace = true, default-features = false, features = [ + "serde", + "std", +] } serde = { workspace = true, features = ["derive", "std"] } serde_json = { workspace = true, features = ["std"] } sha2 = { workspace = true, features = ["std"] } diff --git a/crates/sync/Cargo.toml b/crates/sync/Cargo.toml @@ -13,7 +13,15 @@ publish = ["crates-io"] documentation = "https://docs.rs/radroots_sync" keywords = ["radroots", "synchronization", "outbox", "events"] categories = ["asynchronous", "data-structures", "no-std"] -include = ["src/**", "tests/**", "!tests/package_boundary.rs", "Cargo.toml", "README.md", "LICENSE-APACHE", "LICENSE-MIT"] +include = [ + "src/**", + "tests/**", + "!tests/package_boundary.rs", + "Cargo.toml", + "README.md", + "LICENSE-APACHE", + "LICENSE-MIT", +] [package.metadata.docs.rs] features = ["serde"] diff --git a/crates/test_fixtures/Cargo.toml b/crates/test_fixtures/Cargo.toml @@ -15,9 +15,5 @@ authors = ["Tyson Lupul <tyson@radroots.org>"] unexpected_cfgs = { level = "warn", check-cfg = ['cfg(coverage_nightly)'] } [dependencies] -radroots_event = { workspace = true, features = [ - "std", - "serde", - "knowledge", -] } +radroots_event = { workspace = true, features = ["std", "serde", "knowledge"] } radroots_identity = { workspace = true } diff --git a/crates/trade/Cargo.toml b/crates/trade/Cargo.toml @@ -13,7 +13,16 @@ documentation = "https://docs.rs/radroots_trade" readme = "README.md" keywords = ["radroots", "trade", "local-food", "workflow"] categories = ["algorithms", "data-structures", "no-std"] -include = ["src/**", "tests/**", "!tests/package_boundary.rs", "examples/**", "Cargo.toml", "README.md", "LICENSE-APACHE", "LICENSE-MIT"] +include = [ + "src/**", + "tests/**", + "!tests/package_boundary.rs", + "examples/**", + "Cargo.toml", + "README.md", + "LICENSE-APACHE", + "LICENSE-MIT", +] [package.metadata.docs.rs] features = ["std", "serde", "json"] @@ -23,23 +32,14 @@ name = "radroots_trade" [features] default = ["std", "serde", "json"] -std = [ - "radroots_core/std", - "radroots_event/std", - "radroots_identity/std", -] +std = ["radroots_core/std", "radroots_event/std", "radroots_identity/std"] serde = [ "dep:serde", "radroots_core/serde", "radroots_event/serde", "radroots_identity/serde", ] -json = [ - "serde", - "dep:hex", - "dep:serde_json", - "dep:sha2", -] +json = ["serde", "dep:hex", "dep:serde_json", "dep:sha2"] [dependencies] radroots_core = { workspace = true, default-features = false } diff --git a/crates/trade_bindings/Cargo.toml b/crates/trade_bindings/Cargo.toml @@ -8,3 +8,6 @@ license.workspace = true repository.workspace = true homepage.workspace = true publish = false + +[lints] +workspace = true diff --git a/crates/trade_bindings/src/lib.rs b/crates/trade_bindings/src/lib.rs @@ -4,6 +4,8 @@ //! `radroots_trade`, `radroots_event`, and `radroots_core` owners. This crate //! intentionally does not activate code generation in a public runtime crate. +#![cfg_attr(coverage_nightly, feature(coverage_attribute))] + #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum TradeTypeDisposition { SourceTradeRoot, @@ -89,6 +91,7 @@ pub const TRADE_LARGE_INTEGER_POLICIES: &[TradeLargeIntegerPolicyEntry] = &[ ), ]; +#[cfg_attr(coverage_nightly, coverage(off))] const fn source_root(export_name: &'static str) -> TradeTypeInventoryEntry { TradeTypeInventoryEntry { export_name, @@ -96,6 +99,7 @@ const fn source_root(export_name: &'static str) -> TradeTypeInventoryEntry { } } +#[cfg_attr(coverage_nightly, coverage(off))] const fn event_import(export_name: &'static str) -> TradeTypeInventoryEntry { TradeTypeInventoryEntry { export_name, @@ -103,6 +107,7 @@ const fn event_import(export_name: &'static str) -> TradeTypeInventoryEntry { } } +#[cfg_attr(coverage_nightly, coverage(off))] const fn local_shape(export_name: &'static str) -> TradeTypeInventoryEntry { TradeTypeInventoryEntry { export_name, @@ -110,6 +115,7 @@ const fn local_shape(export_name: &'static str) -> TradeTypeInventoryEntry { } } +#[cfg_attr(coverage_nightly, coverage(off))] const fn json_number_safe_count( type_name: &'static str, field_name: &'static str, @@ -121,9 +127,68 @@ const fn json_number_safe_count( } } +/// Validates that the checked-in trade binding inventory is internally coherent. +#[must_use] +pub fn inventory_is_valid() -> bool { + inventory_entries_are_valid(TRADE_TYPE_INVENTORY, TRADE_LARGE_INTEGER_POLICIES) +} + +fn inventory_entries_are_valid( + types: &[TradeTypeInventoryEntry], + policies: &[TradeLargeIntegerPolicyEntry], +) -> bool { + !types.is_empty() + && types.iter().enumerate().all(|(index, entry)| { + !entry.export_name.is_empty() + && !types[..index] + .iter() + .any(|prior| prior.export_name == entry.export_name) + }) + && policies.iter().all(|policy| { + !policy.field_name.is_empty() + && types + .iter() + .any(|entry| entry.export_name == policy.type_name) + }) +} + #[cfg(test)] mod tests { - use super::{TRADE_LARGE_INTEGER_POLICIES, TRADE_TYPE_INVENTORY, TradeTypeDisposition}; + use super::{ + TRADE_LARGE_INTEGER_POLICIES, TRADE_TYPE_INVENTORY, TradeLargeIntegerPolicy, + TradeLargeIntegerPolicyEntry, TradeTypeDisposition, TradeTypeInventoryEntry, + inventory_entries_are_valid, inventory_is_valid, + }; + + #[test] + fn checked_in_inventory_is_coherent_and_invalid_shapes_fail_closed() { + assert!(inventory_is_valid()); + assert!(!inventory_entries_are_valid(&[], &[])); + + let valid = TradeTypeInventoryEntry { + export_name: "Valid", + disposition: TradeTypeDisposition::SourceTradeRoot, + }; + let empty = TradeTypeInventoryEntry { + export_name: "", + disposition: TradeTypeDisposition::SourceTradeRoot, + }; + assert!(!inventory_entries_are_valid(&[empty], &[])); + assert!(!inventory_entries_are_valid(&[valid, valid], &[])); + + let empty_field = TradeLargeIntegerPolicyEntry { + type_name: "Valid", + field_name: "", + policy: TradeLargeIntegerPolicy::JsonNumberSafeCount, + }; + let unknown_type = TradeLargeIntegerPolicyEntry { + type_name: "Missing", + field_name: "count", + policy: TradeLargeIntegerPolicy::JsonNumberSafeCount, + }; + assert!(!inventory_entries_are_valid(&[valid], &[empty_field])); + assert!(!inventory_entries_are_valid(&[valid], &[unknown_type])); + } #[test] fn trade_type_inventory_is_deterministic() { diff --git a/crates/trade_bindings/tests/fixtures/generated_types.ts b/crates/trade_bindings/tests/fixtures/generated_types.ts @@ -0,0 +1,74 @@ +// @generated by cargo xtask generate ts +// Do not edit by hand. +import type { Currency, Decimal, Discount, Money, Quantity, Unit } from "@radroots/core-bindings"; +import type { RadrootsFarmRef, RadrootsOperationalListing, RadrootsOperationalListingAvailability, RadrootsOperationalListingBin, RadrootsOperationalListingDeliveryMethod, RadrootsOperationalListingImage, RadrootsOperationalListingProduct, RadrootsOperationalListingPublicLocation, RadrootsPlotRef, RadrootsResourceAreaRef } from "@radroots/event-bindings"; + +export type RadrootsOperationalListingSubtotal = { price_amount: Money, price_currency: Currency, quantity_amount: Decimal, quantity_unit: Unit, }; + +export type RadrootsOperationalListingTotal = { price_amount: Money, price_currency: Currency, quantity_amount: Decimal, quantity_unit: Unit, }; + +export type RadrootsOperationalListingTradeProjection = { listing_id: string, listing_addr: string, seller_pubkey: string, title: string, description: string, product_type: string, primary_bin_id: string, bin_quantity: Quantity, unit: Unit, unit_price: Money, inventory_available: Decimal, availability: RadrootsOperationalListingAvailability, location: RadrootsOperationalListingPublicLocation, delivery_method: RadrootsOperationalListingDeliveryMethod, listing: RadrootsOperationalListing, }; + +export type RadrootsTradeAgreementClaimV1 = { claim_mutation_id: string, proposal_mutation_id: string, candidate_id: string, candidate_author_pubkey: string, accepted_by_pubkey: string, reservation_commitment: string, }; + +export type RadrootsTradeAgreementStateV1 = "none" | "agreed" | "contested" | "cancelled"; + +export type RadrootsTradeAttestationRecordV1 = { event_id: string, claim_mutation_id: string, result: RadrootsTradeAttestationResultV1, }; + +export type RadrootsTradeAttestationResultV1 = "Valid" | "Invalid"; + +export type RadrootsTradeAttestationStateV1 = "none" | "present_valid" | "present_invalid" | "conflicting"; + +export type RadrootsTradeConflictStateV1 = "none" | "concurrent_candidates" | "double_acceptance" | "decision_conflict" | "cancellation_conflict" | "invalid_causal_chain" | "inventory_authority_conflict"; + +export type RadrootsTradeEvidenceStateV1 = "complete" | "missing" | "query_partial" | "unsupported_version"; + +export type RadrootsTradeFacetCount = { key: string, count: number, }; + +export type RadrootsTradeFulfillmentStateV1 = "not_started"; + +export type RadrootsTradeListingBackofficeOverlay = { listing_addr: string, review_queue?: RadrootsTradeReviewQueueEntry | null, moderation_flags: Array<RadrootsTradeModerationFlag>, }; + +export type RadrootsTradeListingBackofficeQuery = { listing: RadrootsTradeListingQuery, requires_review?: boolean | null, has_open_moderation_flags?: boolean | null, }; + +export type RadrootsTradeListingBackofficeView = { listing: RadrootsTradeListingProjection, marketplace?: RadrootsTradeMarketplaceListingSummary | null, overlay?: RadrootsTradeListingBackofficeOverlay | null, requires_review: boolean, open_moderation_flag_count: number, }; + +export type RadrootsTradeListingBinProjection = { bin: RadrootsOperationalListingBin, one_bin_total: RadrootsOperationalListingTotal, }; + +export type RadrootsTradeListingFacets = { seller_pubkeys: Array<RadrootsTradeFacetCount>, farm_pubkeys: Array<RadrootsTradeFacetCount>, farm_ids: Array<RadrootsTradeFacetCount>, product_keys: Array<RadrootsTradeFacetCount>, product_categories: Array<RadrootsTradeFacetCount>, listing_statuses: Array<RadrootsTradeFacetCount>, }; + +export type RadrootsTradeListingMarketStatus = "unknown" | "window" | "active" | "sold" | { other: { value: string, }, }; + +export type RadrootsTradeListingProjection = { listing_addr: string, seller_pubkey: string, listing_id: string, farm: RadrootsFarmRef, product: RadrootsOperationalListingProduct, primary_bin_id: string, bins: Array<RadrootsTradeListingBinProjection>, resource_area?: RadrootsResourceAreaRef | null, plot?: RadrootsPlotRef | null, discounts?: Array<Discount> | null, inventory_available?: Decimal | null, availability?: RadrootsOperationalListingAvailability | null, delivery_method?: RadrootsOperationalListingDeliveryMethod | null, location?: RadrootsOperationalListingPublicLocation | null, images?: Array<RadrootsOperationalListingImage> | null, trade_count: number, open_trade_count: number, terminal_trade_count: number, }; + +export type RadrootsTradeListingQuery = { seller_pubkey?: string | null, farm_pubkey?: string | null, farm_id?: string | null, product_key?: string | null, product_category?: string | null, listing_status?: RadrootsTradeListingMarketStatus | null, }; + +export type RadrootsTradeListingSort = { field: RadrootsTradeListingSortField, direction: RadrootsTradeSortDirection, }; + +export type RadrootsTradeListingSortField = "listing_addr" | "product_title" | "product_category" | "seller_pubkey" | "inventory_available" | "open_trade_count" | "total_trade_count"; + +export type RadrootsTradeMarketplaceListingSummary = { listing_addr: string, seller_pubkey: string, farm_pubkey: string, farm_id: string, product_key: string, product_title: string, product_category: string, product_summary?: string | null, listing_status: RadrootsTradeListingMarketStatus, location_primary?: string | null, inventory_available?: Decimal | null, primary_bin_id: string, primary_bin_label?: string | null, primary_bin_total: RadrootsOperationalListingTotal, trade_count: number, open_trade_count: number, terminal_trade_count: number, }; + +export type RadrootsTradeModerationFlag = { code: string, severity: RadrootsTradeModerationSeverity, status: RadrootsTradeModerationStatus, source?: string | null, reason?: string | null, }; + +export type RadrootsTradeModerationSeverity = "notice" | "warning" | "block"; + +export type RadrootsTradeModerationStatus = "open" | "snoozed" | "resolved"; + +export type RadrootsTradeNegotiationStateV1 = "none" | "open" | "closed_declined" | "closed_expired"; + +export type RadrootsTradePaymentStateV1 = "not_tracked"; + +export type RadrootsTradePrivateTermsStateV1 = "not_required" | "available_verified" | "missing" | "undecryptable" | "commitment_mismatch"; + +export type RadrootsTradeProjectionV1 = { reducer_contract_id: string, reducer_version: number, trade_id: string, root_mutation_id?: string | null, buyer_pubkey?: string | null, seller_pubkey?: string | null, farm_id?: string | null, negotiation_state: RadrootsTradeNegotiationStateV1, agreement_state: RadrootsTradeAgreementStateV1, evidence_state: RadrootsTradeEvidenceStateV1, conflict_state: RadrootsTradeConflictStateV1, private_terms_state: RadrootsTradePrivateTermsStateV1, attestation_state: RadrootsTradeAttestationStateV1, fulfillment_state: RadrootsTradeFulfillmentStateV1, payment_state: RadrootsTradePaymentStateV1, candidate_heads: Array<string>, agreement_claims: Array<RadrootsTradeAgreementClaimV1>, active_agreement_claim_ids: Array<string>, contested_claim_ids: Array<string>, cancelled_claim_ids: Array<string>, declined_candidate_ids: Array<string>, missing_parent_ids: Array<string>, missing_proposal_ids: Array<string>, unsupported_mutation_ids: Array<string>, issues: Array<RadrootsTradeReducerIssueV1>, attestations: Array<RadrootsTradeAttestationRecordV1>, projection_digest: string, }; + +export type RadrootsTradeReducerIssueV1 = "MissingRootProposal" | "MultipleRootProposals" | "MissingMutationId" | { TradeIdentityMismatch: { mutation_id: string, }, } | { UnsupportedSchema: { mutation_id: string, schema_version: number, }, } | { InvalidMutation: { mutation_id?: string | null, reason: string, }, } | { MissingParent: { mutation_id: string, parent_mutation_id: string, }, } | { MissingProposal: { decision_mutation_id: string, proposal_mutation_id: string, }, } | { CandidateIdMismatch: { decision_mutation_id: string, proposal_mutation_id: string, }, } | { DecisionAuthorMismatch: { decision_mutation_id: string, }, } | { DecisionParentMissing: { decision_mutation_id: string, proposal_mutation_id: string, }, } | { MissingSellerReservation: { decision_mutation_id: string, }, } | { ReservationCandidateMismatch: { decision_mutation_id: string, }, } | { ReservationAuthorityMismatch: { decision_mutation_id: string, }, } | { ReservationLineMismatch: { decision_mutation_id: string, }, } | { DecisionConflict: { proposal_mutation_id: string, }, } | { DoubleAcceptance: { proposal_mutation_id: string, }, } | { CancellationConflict: { cancellation_mutation_id: string, }, } | { InvalidCausalChain: { mutation_id: string, }, } | { PrivateTermsUnavailable: { candidate_id: string, }, } | { ProjectionDigestUnavailable: { reason: string, }, }; + +export type RadrootsTradeReviewPriority = "low" | "normal" | "high" | "critical"; + +export type RadrootsTradeReviewQueueEntry = { queue: string, priority: RadrootsTradeReviewPriority, status: RadrootsTradeReviewStatus, assigned_operator?: string | null, reason?: string | null, }; + +export type RadrootsTradeReviewStatus = "queued" | "in_progress" | "blocked" | "resolved"; + +export type RadrootsTradeSortDirection = "asc" | "desc"; diff --git a/crates/transport/Cargo.toml b/crates/transport/Cargo.toml @@ -13,7 +13,17 @@ readme = "README.md" documentation = "https://docs.rs/radroots_transport" keywords = ["radroots", "transport", "async", "networking"] categories = ["asynchronous", "network-programming", "no-std"] -include = ["src/**", "tests/**", "!tests/package_boundary.rs", "!tests/source_boundary.rs", "examples/**", "Cargo.toml", "README.md", "LICENSE-APACHE", "LICENSE-MIT"] +include = [ + "src/**", + "tests/**", + "!tests/package_boundary.rs", + "!tests/source_boundary.rs", + "examples/**", + "Cargo.toml", + "README.md", + "LICENSE-APACHE", + "LICENSE-MIT", +] [package.metadata.docs.rs] features = ["std", "serde"] @@ -23,11 +33,7 @@ name = "radroots_transport" [features] default = ["std", "serde"] -std = [ - "radroots_event/std", - "radroots_identity/std", - "radroots_protocol/std", -] +std = ["radroots_event/std", "radroots_identity/std", "radroots_protocol/std"] serde = [ "dep:serde", "radroots_event/serde", diff --git a/crates/transport_nostr/Cargo.toml b/crates/transport_nostr/Cargo.toml @@ -13,7 +13,19 @@ readme = "README.md" documentation = "https://docs.rs/radroots_transport_nostr" keywords = ["radroots", "nostr", "transport", "websocket"] categories = ["asynchronous", "network-programming"] -include = ["src/**", "tests/**", "!tests/legacy_quarantine.rs", "!tests/network_hardening.rs", "!tests/package_boundary.rs", "!tests/workspace_consumers.rs", "examples/**", "Cargo.toml", "README.md", "LICENSE-APACHE", "LICENSE-MIT"] +include = [ + "src/**", + "tests/**", + "!tests/legacy_quarantine.rs", + "!tests/network_hardening.rs", + "!tests/package_boundary.rs", + "!tests/workspace_consumers.rs", + "examples/**", + "Cargo.toml", + "README.md", + "LICENSE-APACHE", + "LICENSE-MIT", +] [package.metadata.docs.rs] features = [] @@ -22,8 +34,12 @@ features = [] name = "radroots_transport_nostr" [dependencies] -radroots_event_codec = { workspace = true, default-features = false, features = ["json"] } -radroots_nostr = { workspace = true, default-features = false, features = ["events"] } +radroots_event_codec = { workspace = true, default-features = false, features = [ + "json", +] } +radroots_nostr = { workspace = true, default-features = false, features = [ + "events", +] } radroots_protocol = { workspace = true, default-features = false } radroots_transport = { workspace = true, default-features = false } async-wsocket = { workspace = true } diff --git a/docs/implementation/deviations.toml b/docs/implementation/deviations.toml @@ -132,7 +132,21 @@ id = "RCRV1-DEV-009" date = "2026-08-02" status = "closed" approval = "All code-review recommendations and the full multi-RCLD implementation sequence were explicitly approved by the user." -affected_steps = ["170", "179", "189", "196", "201", "213", "226", "235", "263", "269", "288", "292", "313"] +affected_steps = [ + "170", + "179", + "189", + "196", + "201", + "213", + "226", + "235", + "263", + "269", + "288", + "292", + "313", +] spec_anchors = [ "docs/specs/radroots_crates_release_v1.md#13-radroots_storage", "docs/specs/radroots_crates_release_v1.md#14-radroots_storage_sqlite", @@ -149,7 +163,7 @@ verification = [ "Package manifests carry machine-readable publish-frozen metadata naming the final replacement, deviation, Step 313 removal gate, and prohibition on new consumers.", "A storage-owned quarantine test requires the four packages to remain private and absent from the approved publication inventory.", "Workspace checks and tests prove current migration consumers remain buildable while publication stays frozen.", - "Step 313 performs the all-first-party forbidden-name search and final package deletion." + "Step 313 performs the all-first-party forbidden-name search and final package deletion.", ] unresolved_risk = "None; all four predecessor packages and their active dependency edges are absent." normative_architecture_change = false @@ -222,7 +236,21 @@ id = "RCRV1-DEV-004" date = "2026-07-28" status = "active" approval = "Explicit user coverage-policy update dated 2026-07-28 17:15 UTC." -affected_steps = ["098", "155", "225", "260", "268", "294", "298", "299", "301", "302", "303", "304", "314"] +affected_steps = [ + "098", + "155", + "225", + "260", + "268", + "294", + "298", + "299", + "301", + "302", + "303", + "304", + "314", +] spec_anchors = [ "docs/specs/radroots_crates_release_v1.md#245-coverage-policy", "docs/specs/radroots_crates_release_v1.toml#quality_policy.coverage", diff --git a/fuzz/event_codec/Cargo.toml b/fuzz/event_codec/Cargo.toml @@ -10,12 +10,21 @@ cargo-fuzz = true [dependencies] libfuzzer-sys = "0.4" -radroots_blossom = { path = "../../crates/blossom", default-features = false, features = ["std"] } -radroots_event_codec = { path = "../../crates/event_codec", default-features = false, features = ["manifests", "std"] } +radroots_blossom = { path = "../../crates/blossom", default-features = false, features = [ + "std", +] } +radroots_event_codec = { path = "../../crates/event_codec", default-features = false, features = [ + "manifests", + "std", +] } radroots_nostr_connect = { path = "../../crates/nostr_connect" } -radroots_protocol = { path = "../../crates/protocol", default-features = false, features = ["std"] } +radroots_protocol = { path = "../../crates/protocol", default-features = false, features = [ + "std", +] } radroots_secrets = { path = "../../crates/secrets", default-features = false } -radroots_storage = { path = "../../crates/storage", default-features = false, features = ["serde"] } +radroots_storage = { path = "../../crates/storage", default-features = false, features = [ + "serde", +] } serde_json = "1" [[bin]] diff --git a/tools/sdk_xtask_import/Cargo.toml.imported b/tools/sdk_xtask_import/Cargo.toml.imported @@ -1,33 +0,0 @@ -[package] -name = "radroots_sdk_xtask" -description = "Workspace automation for Radroots SDK crates" -version.workspace = true -edition.workspace = true -rust-version.workspace = true -license.workspace = true -repository.workspace = true -homepage.workspace = true -publish = false - -[[bin]] -name = "radroots_sdk_xtask" -path = "src/main.rs" - -[dependencies] -dto_bindgen_backend_ts = { workspace = true } -dto_bindgen_core = { workspace = true } -radroots_core_bindings = { path = "../../crates/core_bindings", version = "=0.1.0-alpha" } -radroots_event_bindings = { path = "../../crates/event_bindings", version = "=0.1.0-alpha" } -radroots_identity = { workspace = true } -radroots_identity_bindings = { path = "../../crates/identity_bindings", version = "=0.1.0-alpha" } -radroots_protocol = { workspace = true } -radroots_replica_schema_bindings = { path = "../../crates/replica_schema_bindings", version = "=0.1.0-alpha" } -flate2 = "1" -serde_json = "1" -serde = { workspace = true, features = ["derive"] } -semver = "1" -sha2 = { workspace = true } -syn = { version = "2", features = ["full", "visit"] } -tar = "0.4" -tempfile = { workspace = true } -toml = "0.8" diff --git a/tools/sdk_xtask_import/src/api_qualification.rs b/tools/sdk_xtask_import/src/api_qualification.rs @@ -1,139 +0,0 @@ -use std::{collections::BTreeSet, fs, path::Path, process::Command}; - -use serde::Deserialize; - -#[derive(Debug, Deserialize)] -struct Contract { - schema_version: u16, - baseline_revision: String, - package_version: String, - tool: String, - minimum_tool_version: String, - policy: String, - feature_policy: String, - packages: Vec<String>, -} - -pub fn run(root: &Path) -> Result<(), String> { - let contract = load(root)?; - validate(&contract)?; - verify_tool(&contract)?; - verify_revision(root, &contract.baseline_revision)?; - for package in &contract.packages { - let args = invocation(package); - eprintln!("cargo {}", args.join(" ")); - let output = Command::new("cargo") - .args(&args) - .current_dir(root) - .output() - .map_err(|error| format!("failed to start cargo-public-api: {error}"))?; - if !output.status.success() { - return Err(format!( - "public API extraction failed for {package}: {}", - String::from_utf8_lossy(&output.stderr).trim() - )); - } - let actual = String::from_utf8(output.stdout) - .map_err(|error| format!("public API output for {package} was not UTF-8: {error}"))?; - let snapshot_path = root.join(format!( - "docs/api/{package}-{}.txt", - contract.package_version - )); - let expected = fs::read_to_string(&snapshot_path) - .map_err(|error| format!("read {}: {error}", snapshot_path.display()))?; - if actual != expected { - return Err(format!( - "reviewed public API snapshot drifted for {package}: {}", - snapshot_path.display() - )); - } - } - Ok(()) -} - -fn load(root: &Path) -> Result<Contract, String> { - let path = root.join("contracts/releases/api_semver.toml"); - let raw = fs::read_to_string(&path) - .map_err(|error| format!("failed to read {}: {error}", path.display()))?; - toml::from_str(&raw).map_err(|error| format!("failed to parse {}: {error}", path.display())) -} - -fn validate(contract: &Contract) -> Result<(), String> { - let unique = contract.packages.iter().collect::<BTreeSet<_>>(); - if contract.schema_version != 2 - || contract.package_version != "0.1.0-alpha" - || contract.tool != "cargo-public-api" - || contract.policy != "reviewed-breaking-snapshot" - || contract.feature_policy != "all" - || contract.baseline_revision.len() < 7 - || unique.len() != 2 - || unique.len() != contract.packages.len() - { - return Err("invalid SDK public API qualification contract".to_owned()); - } - Ok(()) -} - -fn verify_tool(contract: &Contract) -> Result<(), String> { - let output = Command::new("cargo") - .args(["public-api", "--version"]) - .output() - .map_err(|error| format!("failed to start cargo-public-api: {error}"))?; - if !output.status.success() { - return Err("cargo-public-api is required".to_owned()); - } - let stdout = String::from_utf8_lossy(&output.stdout); - let installed = stdout - .split_whitespace() - .find_map(|value| semver::Version::parse(value).ok()) - .ok_or_else(|| format!("could not parse cargo-public-api version: {stdout}"))?; - let minimum = semver::Version::parse(&contract.minimum_tool_version) - .map_err(|error| format!("invalid minimum tool version: {error}"))?; - if installed < minimum { - return Err(format!( - "cargo-public-api {} or newer is required, found {installed}", - contract.minimum_tool_version - )); - } - Ok(()) -} - -fn verify_revision(root: &Path, revision: &str) -> Result<(), String> { - let status = Command::new("git") - .args(["cat-file", "-e", &format!("{revision}^{{commit}}")]) - .current_dir(root) - .status() - .map_err(|error| format!("failed to inspect API baseline revision: {error}"))?; - if status.success() { - Ok(()) - } else { - Err(format!("API baseline revision {revision} is unavailable")) - } -} - -fn invocation(package: &str) -> Vec<String> { - vec![ - "public-api".to_owned(), - "-p".to_owned(), - package.to_owned(), - "--all-features".to_owned(), - "-sss".to_owned(), - "--color".to_owned(), - "never".to_owned(), - ] -} - -#[cfg(test)] -mod tests { - use super::{invocation, load, validate}; - - #[test] - fn current_contract_covers_both_front_doors() { - let root = crate::fs::workspace_root().expect("workspace root"); - let contract = load(&root).expect("contract"); - validate(&contract).expect("valid contract"); - let invocation = invocation("radroots"); - assert!(invocation.contains(&"--all-features".to_owned())); - assert!(invocation.contains(&"-sss".to_owned())); - } -} diff --git a/tools/sdk_xtask_import/src/architecture.rs b/tools/sdk_xtask_import/src/architecture.rs @@ -1,1511 +0,0 @@ -use std::{ - collections::{BTreeMap, BTreeSet}, - fs, - path::{Component, Path}, -}; - -use serde::Deserialize; - -mod api_leakage; -mod dependency_boundary; - -const DEVIATIONS_RELATIVE: &str = "docs/implementation/deviations.toml"; -const ARCHITECTURE_RELATIVE: &str = "docs/specs/radroots_crates_release_v1.toml"; -const ARCHITECTURE_ID: &str = "radroots.crates.release.v1"; -const PUBLIC_HOMEPAGE: &str = "https://radroots.org"; -const PUBLIC_README: &str = "README.md"; -const PUBLIC_AUTHORS: &[&str] = &["Tyson Lupul <tyson@radroots.org>"]; - -#[derive(Debug, Deserialize)] -#[serde(deny_unknown_fields)] -struct DeviationLedger { - schema_version: u16, - architecture_id: String, - deviation: Vec<DeviationRecord>, -} - -#[derive(Debug, Deserialize)] -#[serde(deny_unknown_fields)] -struct DeviationRecord { - id: String, - date: String, - status: String, - approval: String, - affected_steps: Vec<String>, - spec_anchors: Vec<String>, - source_evidence: Vec<String>, - replacement_action: String, - verification: Vec<String>, - unresolved_risk: String, - normative_architecture_change: bool, - adr_required: bool, - #[serde(default)] - closure_evidence: Vec<String>, -} - -#[derive(Debug, Deserialize)] -struct ArchitectureIdentity { - spec_id: String, - edition: String, - resolver: String, - rust_version: String, - license: String, - canonical_repositories: Vec<String>, - repositories: BTreeMap<String, ArchitectureRepository>, - package: Vec<ArchitecturePackage>, -} - -#[derive(Debug, Deserialize)] -struct ArchitectureRepository { - url: String, - version: String, - packages: Vec<String>, -} - -#[derive(Debug, Deserialize)] -struct ArchitecturePackage { - name: String, -} - -#[derive(Debug, Deserialize)] -struct WorkspaceManifest { - workspace: WorkspaceMembers, -} - -#[derive(Debug, Deserialize)] -struct WorkspaceMembershipManifest { - workspace: WorkspaceMembership, -} - -#[derive(Debug, Deserialize)] -struct WorkspaceMembership { - members: Vec<String>, -} - -#[derive(Debug, Deserialize)] -struct WorkspaceMembers { - members: Vec<String>, - resolver: String, - package: WorkspacePackage, - metadata: WorkspaceMetadata, - lints: WorkspaceLints, -} - -#[derive(Debug, Deserialize)] -struct WorkspaceLints { - rust: WorkspaceRustLints, - rustdoc: WorkspaceRustdocLints, - clippy: WorkspaceClippyLints, -} - -#[derive(Debug, Deserialize)] -struct WorkspaceRustLints { - unsafe_code: String, -} - -#[derive(Debug, Deserialize)] -struct WorkspaceRustdocLints { - broken_intra_doc_links: String, -} - -#[derive(Debug, Deserialize)] -struct WorkspaceClippyLints { - dbg_macro: String, - todo: String, - unimplemented: String, -} - -#[derive(Debug, Deserialize)] -struct WorkspaceMetadata { - radroots: RadrootsWorkspaceMetadata, -} - -#[derive(Debug, Deserialize)] -struct RadrootsWorkspaceMetadata { - #[serde(rename = "public-package")] - public_package: PublicPackageMetadata, -} - -#[derive(Debug, Deserialize)] -struct PublicPackageMetadata { - version: String, - authors: Vec<String>, - readme: String, -} - -#[derive(Debug, Deserialize)] -struct WorkspacePackage { - version: String, - edition: String, - #[serde(rename = "rust-version")] - rust_version: String, - license: String, - repository: String, - homepage: String, - readme: String, - authors: Vec<String>, -} - -pub fn validate(workspace_root: &Path) -> Result<(), String> { - validate_workspace_members(workspace_root)?; - let architecture_path = workspace_root.join(ARCHITECTURE_RELATIVE); - let architecture_raw = fs::read_to_string(&architecture_path) - .map_err(|error| format!("read {}: {error}", architecture_path.display()))?; - let architecture = toml::from_str::<ArchitectureIdentity>(&architecture_raw) - .map_err(|error| format!("parse {}: {error}", architecture_path.display()))?; - - let architecture_packages = architecture - .package - .iter() - .map(|package| package.name.clone()) - .collect::<BTreeSet<_>>(); - api_leakage::validate_policy_catalog(workspace_root, &architecture_packages)?; - dependency_boundary::validate_policy_catalog(workspace_root, &architecture_packages)?; - validate_workspace_toolchain(workspace_root, &architecture)?; - validate_public_package_metadata(workspace_root, &architecture)?; - validate_no_production_sibling_paths(workspace_root)?; - validate_public_dependency_versions(workspace_root, &architecture)?; - - let ledger_path = workspace_root.join(DEVIATIONS_RELATIVE); - let ledger_raw = fs::read_to_string(&ledger_path) - .map_err(|error| format!("read {}: {error}", ledger_path.display()))?; - validate_ledger(workspace_root, &architecture.spec_id, &ledger_raw) -} - -pub fn validate_dependency_boundaries(workspace_root: &Path) -> Result<(), String> { - validate(workspace_root)?; - dependency_boundary::validate_resolved_boundaries(workspace_root) -} - -pub fn validate_api_boundaries(workspace_root: &Path) -> Result<(), String> { - validate(workspace_root)?; - api_leakage::validate_public_api(workspace_root) -} - -pub fn validate_ci(workspace_root: &Path) -> Result<(), String> { - validate(workspace_root)?; - dependency_boundary::validate_resolved_boundaries(workspace_root)?; - api_leakage::validate_public_api(workspace_root) -} - -fn validate_workspace_toolchain( - workspace_root: &Path, - architecture: &ArchitectureIdentity, -) -> Result<(), String> { - let manifest_path = workspace_root.join("Cargo.toml"); - let manifest_raw = fs::read_to_string(&manifest_path) - .map_err(|error| format!("read {}: {error}", manifest_path.display()))?; - let manifest = toml::from_str::<WorkspaceManifest>(&manifest_raw) - .map_err(|error| format!("parse {}: {error}", manifest_path.display()))?; - if manifest.workspace.resolver != architecture.resolver || architecture.resolver != "3" { - return Err(format!( - "workspace resolver {} must match architecture resolver {}", - manifest.workspace.resolver, architecture.resolver - )); - } - if manifest.workspace.package.rust_version != architecture.rust_version - || architecture.rust_version != "1.97.1" - { - return Err(format!( - "workspace rust-version {} must match architecture rust_version {}", - manifest.workspace.package.rust_version, architecture.rust_version - )); - } - let workspace_package = &manifest.workspace.package; - let public_metadata = &manifest.workspace.metadata.radroots.public_package; - let repository = architecture - .repositories - .values() - .find(|repository| repository.url == workspace_package.repository) - .ok_or_else(|| "workspace repository has no architecture allocation".to_owned())?; - if workspace_package.version != repository.version - || public_metadata.version != repository.version - { - return Err(format!( - "workspace package version {} and public package version source {} must match frozen repository version {}", - workspace_package.version, public_metadata.version, repository.version - )); - } - for member in &manifest.workspace.members { - let member_path = workspace_root.join(member).join("Cargo.toml"); - let member_raw = fs::read_to_string(&member_path) - .map_err(|error| format!("read {}: {error}", member_path.display()))?; - let member_manifest = member_raw - .parse::<toml::Value>() - .map_err(|error| format!("parse {}: {error}", member_path.display()))?; - let package = member_manifest - .get("package") - .and_then(toml::Value::as_table) - .ok_or_else(|| format!("{} is missing [package]", member_path.display()))?; - let package_name = package - .get("name") - .and_then(toml::Value::as_str) - .ok_or_else(|| format!("{} is missing package.name", member_path.display()))?; - let package_version = match package.get("version") { - Some(toml::Value::String(version)) => version.as_str(), - Some(toml::Value::Table(source)) - if source.get("workspace").and_then(toml::Value::as_bool) == Some(true) => - { - workspace_package.version.as_str() - } - _ => { - return Err(format!( - "workspace package {package_name} must declare version 0.1.0-alpha or inherit it from workspace.package" - )); - } - }; - if package_version != repository.version { - return Err(format!( - "workspace package {package_name} version {package_version} must match frozen repository version {}", - repository.version - )); - } - } - if public_metadata.authors != PUBLIC_AUTHORS { - return Err("public package authors source must match the workspace convention".to_owned()); - } - if public_metadata.readme != PUBLIC_README { - return Err(format!( - "public package readme source must be {PUBLIC_README}" - )); - } - let lints = &manifest.workspace.lints; - if lints.rust.unsafe_code != "forbid" - || lints.rustdoc.broken_intra_doc_links != "deny" - || lints.clippy.dbg_macro != "deny" - || lints.clippy.todo != "deny" - || lints.clippy.unimplemented != "deny" - { - return Err( - "workspace lints must forbid unsafe code and deny the approved rustdoc/Clippy baseline" - .to_owned(), - ); - } - if workspace_package.edition != architecture.edition || architecture.edition != "2024" { - return Err(format!( - "workspace edition {} must match architecture edition {}", - workspace_package.edition, architecture.edition - )); - } - if workspace_package.license != architecture.license { - return Err(format!( - "workspace license {} must match architecture license {}", - workspace_package.license, architecture.license - )); - } - if !architecture - .canonical_repositories - .contains(&workspace_package.repository) - { - return Err(format!( - "workspace repository {} is not canonical", - workspace_package.repository - )); - } - if workspace_package.homepage != PUBLIC_HOMEPAGE { - return Err(format!( - "workspace homepage {} must be {PUBLIC_HOMEPAGE}", - workspace_package.homepage - )); - } - if workspace_package.authors != PUBLIC_AUTHORS { - return Err("workspace authors must match the public package convention".to_owned()); - } - if !workspace_root.join(&workspace_package.readme).is_file() { - return Err(format!( - "workspace readme {} does not exist", - workspace_package.readme - )); - } - for license in ["LICENSE-MIT", "LICENSE-APACHE"] { - if !workspace_root.join(license).is_file() { - return Err(format!("workspace is missing {license}")); - } - } - let toolchain_path = workspace_root.join("rust-toolchain.toml"); - let toolchain_raw = fs::read_to_string(&toolchain_path) - .map_err(|error| format!("read {}: {error}", toolchain_path.display()))?; - let toolchain = toolchain_raw - .parse::<toml::Value>() - .map_err(|error| format!("parse {}: {error}", toolchain_path.display()))?; - let channel = toolchain - .get("toolchain") - .and_then(|value| value.get("channel")) - .and_then(toml::Value::as_str); - if channel != Some(architecture.rust_version.as_str()) { - return Err(format!( - "rust-toolchain.toml channel must match architecture rust_version {}", - architecture.rust_version - )); - } - Ok(()) -} - -fn validate_public_package_metadata( - workspace_root: &Path, - architecture: &ArchitectureIdentity, -) -> Result<(), String> { - let workspace_raw = fs::read_to_string(workspace_root.join("Cargo.toml")) - .map_err(|error| format!("read workspace Cargo.toml: {error}"))?; - let workspace = toml::from_str::<WorkspaceManifest>(&workspace_raw) - .map_err(|error| format!("parse workspace Cargo.toml: {error}"))?; - let repository = architecture - .repositories - .values() - .find(|repository| repository.url == workspace.workspace.package.repository) - .ok_or_else(|| "workspace repository has no architecture allocation".to_owned())?; - let local_packages = repository.packages.iter().cloned().collect::<BTreeSet<_>>(); - let all_packages = architecture - .package - .iter() - .map(|package| package.name.as_str()) - .collect::<BTreeSet<_>>(); - let mut found_local_packages = BTreeSet::new(); - - for member in &workspace.workspace.members { - let manifest_path = workspace_root.join(member).join("Cargo.toml"); - let raw = fs::read_to_string(&manifest_path) - .map_err(|error| format!("read {}: {error}", manifest_path.display()))?; - let manifest = raw - .parse::<toml::Value>() - .map_err(|error| format!("parse {}: {error}", manifest_path.display()))?; - let package = manifest - .get("package") - .and_then(toml::Value::as_table) - .ok_or_else(|| format!("{} is missing [package]", manifest_path.display()))?; - let name = package - .get("name") - .and_then(toml::Value::as_str) - .ok_or_else(|| format!("{} is missing package.name", manifest_path.display()))?; - if !all_packages.contains(name) { - continue; - } - if !local_packages.contains(name) { - return Err(format!( - "public package {name} belongs to a different canonical repository" - )); - } - found_local_packages.insert(name.to_owned()); - validate_public_manifest_field( - package, - "version", - &workspace.workspace.package.version, - &repository.version, - name, - )?; - validate_public_manifest_field( - package, - "edition", - &workspace.workspace.package.edition, - &architecture.edition, - name, - )?; - validate_public_manifest_field( - package, - "rust-version", - &workspace.workspace.package.rust_version, - &architecture.rust_version, - name, - )?; - validate_public_manifest_field( - package, - "license", - &workspace.workspace.package.license, - &architecture.license, - name, - )?; - validate_public_manifest_field( - package, - "repository", - &workspace.workspace.package.repository, - &repository.url, - name, - )?; - validate_public_manifest_field( - package, - "homepage", - &workspace.workspace.package.homepage, - PUBLIC_HOMEPAGE, - name, - )?; - let authors = resolve_public_authors(package, &workspace.workspace.package.authors) - .ok_or_else(|| format!("public package {name} must declare authors"))?; - if authors != PUBLIC_AUTHORS { - return Err(format!( - "public package {name} authors must match the workspace convention" - )); - } - let readme = package.get("readme").and_then(toml::Value::as_str); - if readme != Some(PUBLIC_README) - || !workspace_root.join(member).join(PUBLIC_README).is_file() - { - return Err(format!( - "public package {name} must use an existing crate-local {PUBLIC_README}" - )); - } - let publish_registries = package - .get("publish") - .and_then(toml::Value::as_array) - .ok_or_else(|| format!("public package {name} must set publish = [\"crates-io\"]"))?; - if publish_registries.len() != 1 || publish_registries[0].as_str() != Some("crates-io") { - return Err(format!( - "public package {name} must set publish = [\"crates-io\"]" - )); - } - let inherits_lints = manifest - .get("lints") - .and_then(toml::Value::as_table) - .is_some_and(|lints| { - lints.len() == 1 - && lints.get("workspace").and_then(toml::Value::as_bool) == Some(true) - }); - if !inherits_lints { - return Err(format!( - "public package {name} must inherit the workspace lint policy" - )); - } - } - if found_local_packages != local_packages { - let missing = local_packages - .difference(&found_local_packages) - .cloned() - .collect::<Vec<_>>() - .join(", "); - let extra = found_local_packages - .difference(&local_packages) - .cloned() - .collect::<Vec<_>>() - .join(", "); - return Err(format!( - "workspace public package inventory is missing: {missing}; workspace public package inventory has unallocated packages: {extra}" - )); - } - Ok(()) -} - -fn validate_public_dependency_versions( - workspace_root: &Path, - architecture: &ArchitectureIdentity, -) -> Result<(), String> { - let workspace_path = workspace_root.join("Cargo.toml"); - let workspace_raw = fs::read_to_string(&workspace_path) - .map_err(|error| format!("read {}: {error}", workspace_path.display()))?; - let workspace = toml::from_str::<WorkspaceManifest>(&workspace_raw) - .map_err(|error| format!("parse {}: {error}", workspace_path.display()))?; - let workspace_value = workspace_raw - .parse::<toml::Value>() - .map_err(|error| format!("parse {}: {error}", workspace_path.display()))?; - let workspace_dependencies = workspace_value - .get("workspace") - .and_then(toml::Value::as_table) - .and_then(|workspace| workspace.get("dependencies")) - .and_then(toml::Value::as_table); - let repository = architecture - .repositories - .values() - .find(|repository| repository.url == workspace.workspace.package.repository) - .ok_or_else(|| "workspace repository has no architecture allocation".to_owned())?; - let local_packages = repository - .packages - .iter() - .map(String::as_str) - .collect::<BTreeSet<_>>(); - let public_packages = architecture - .package - .iter() - .map(|package| package.name.as_str()) - .collect::<BTreeSet<_>>(); - let package_versions = architecture - .repositories - .values() - .flat_map(|repository| { - repository - .packages - .iter() - .map(|package| (package.as_str(), repository.version.as_str())) - }) - .collect::<BTreeMap<_, _>>(); - - for member in &workspace.workspace.members { - let manifest_path = workspace_root.join(member).join("Cargo.toml"); - let raw = fs::read_to_string(&manifest_path) - .map_err(|error| format!("read {}: {error}", manifest_path.display()))?; - let manifest = raw - .parse::<toml::Value>() - .map_err(|error| format!("parse {}: {error}", manifest_path.display()))?; - let package_name = manifest - .get("package") - .and_then(toml::Value::as_table) - .and_then(|package| package.get("name")) - .and_then(toml::Value::as_str) - .ok_or_else(|| format!("{} is missing package.name", manifest_path.display()))?; - if !local_packages.contains(package_name) { - continue; - } - let policy = PublicDependencyPolicy { - workspace_root, - workspace_dependencies, - public_packages: &public_packages, - local_packages: &local_packages, - package_versions: &package_versions, - }; - validate_public_dependency_sections(member, package_name, &manifest, &policy)?; - } - Ok(()) -} - -struct PublicDependencyPolicy<'a> { - workspace_root: &'a Path, - workspace_dependencies: Option<&'a toml::value::Table>, - public_packages: &'a BTreeSet<&'a str>, - local_packages: &'a BTreeSet<&'a str>, - package_versions: &'a BTreeMap<&'a str, &'a str>, -} - -fn validate_public_dependency_sections( - member: &str, - owner: &str, - manifest: &toml::Value, - policy: &PublicDependencyPolicy<'_>, -) -> Result<(), String> { - let manifest_table = manifest - .as_table() - .ok_or_else(|| format!("{member}/Cargo.toml must be a TOML table"))?; - for section in ["dependencies", "dev-dependencies", "build-dependencies"] { - if let Some(dependencies) = manifest_table.get(section).and_then(toml::Value::as_table) { - validate_public_dependency_table(member, owner, section, dependencies, policy)?; - } - } - if let Some(targets) = manifest_table.get("target").and_then(toml::Value::as_table) { - for (target, target_value) in targets { - let Some(target_table) = target_value.as_table() else { - continue; - }; - for section in ["dependencies", "dev-dependencies", "build-dependencies"] { - if let Some(dependencies) = - target_table.get(section).and_then(toml::Value::as_table) - { - validate_public_dependency_table( - member, - owner, - &format!("target.{target}.{section}"), - dependencies, - policy, - )?; - } - } - } - } - Ok(()) -} - -fn validate_public_dependency_table( - member: &str, - owner: &str, - section: &str, - dependencies: &toml::value::Table, - policy: &PublicDependencyPolicy<'_>, -) -> Result<(), String> { - for (dependency_key, declaration) in dependencies { - let inherits_workspace = declaration - .as_table() - .and_then(|table| table.get("workspace")) - .and_then(toml::Value::as_bool) - == Some(true); - let resolved = if inherits_workspace { - policy - .workspace_dependencies - .and_then(|dependencies| dependencies.get(dependency_key)) - .ok_or_else(|| { - format!( - "public package {owner} {section}.{dependency_key} inherits a missing workspace dependency" - ) - })? - } else { - declaration - }; - let resolved_table = resolved.as_table(); - let dependency_path = resolved_table - .and_then(|table| table.get("path")) - .and_then(toml::Value::as_str); - let declared_package = resolved_table - .and_then(|table| table.get("package")) - .and_then(toml::Value::as_str); - let path_base = if inherits_workspace { - policy.workspace_root.to_path_buf() - } else { - policy.workspace_root.join(member) - }; - let path_package = if declared_package.is_none() { - dependency_path - .map(|path| dependency_package_name(&path_base.join(path))) - .transpose()? - } else { - None - }; - let dependency_name = declared_package - .or(path_package.as_deref()) - .unwrap_or(dependency_key.as_str()); - if !policy.public_packages.contains(dependency_name) { - continue; - } - let version = match resolved { - toml::Value::String(version) => Some(version.as_str()), - toml::Value::Table(table) => table.get("version").and_then(toml::Value::as_str), - _ => None, - }; - let governed_version = policy - .package_versions - .get(dependency_name) - .ok_or_else(|| { - format!("public dependency {dependency_name} has no repository version authority") - })?; - let exact_version = format!("={governed_version}"); - if policy.local_packages.contains(dependency_name) - && (dependency_path.is_none() || version != Some(exact_version.as_str())) - { - return Err(format!( - "public package {owner} {section}.{dependency_key} dependency on {dependency_name} must declare path and exact version {exact_version}" - )); - } - if !policy.local_packages.contains(dependency_name) - && (dependency_path.is_some() || version != Some(exact_version.as_str())) - { - return Err(format!( - "public package {owner} {section}.{dependency_key} cross-repository dependency on {dependency_name} must declare exact registry version {exact_version} without a path" - )); - } - } - Ok(()) -} - -fn validate_no_production_sibling_paths(workspace_root: &Path) -> Result<(), String> { - let canonical_root = fs::canonicalize(workspace_root) - .map_err(|error| format!("canonicalize {}: {error}", workspace_root.display()))?; - let workspace_path = workspace_root.join("Cargo.toml"); - let workspace_raw = fs::read_to_string(&workspace_path) - .map_err(|error| format!("read {}: {error}", workspace_path.display()))?; - let workspace = toml::from_str::<WorkspaceManifest>(&workspace_raw) - .map_err(|error| format!("parse {}: {error}", workspace_path.display()))?; - let workspace_value = workspace_raw - .parse::<toml::Value>() - .map_err(|error| format!("parse {}: {error}", workspace_path.display()))?; - if let Some(dependencies) = workspace_value - .get("workspace") - .and_then(toml::Value::as_table) - .and_then(|workspace| workspace.get("dependencies")) - .and_then(toml::Value::as_table) - { - validate_dependency_path_table( - &canonical_root, - &canonical_root, - "workspace", - "workspace.dependencies", - dependencies, - )?; - } - for member in &workspace.workspace.members { - let manifest_path = workspace_root.join(member).join("Cargo.toml"); - let raw = fs::read_to_string(&manifest_path) - .map_err(|error| format!("read {}: {error}", manifest_path.display()))?; - let manifest = raw - .parse::<toml::Value>() - .map_err(|error| format!("parse {}: {error}", manifest_path.display()))?; - validate_manifest_dependency_paths( - &canonical_root, - &canonical_root.join(member), - member, - &manifest, - )?; - } - Ok(()) -} - -fn validate_manifest_dependency_paths( - workspace_root: &Path, - package_root: &Path, - owner: &str, - manifest: &toml::Value, -) -> Result<(), String> { - let Some(manifest_table) = manifest.as_table() else { - return Err(format!("{owner}/Cargo.toml must be a TOML table")); - }; - for section in ["dependencies", "dev-dependencies", "build-dependencies"] { - if let Some(dependencies) = manifest_table.get(section).and_then(toml::Value::as_table) { - validate_dependency_path_table( - workspace_root, - package_root, - owner, - section, - dependencies, - )?; - } - } - if let Some(targets) = manifest_table.get("target").and_then(toml::Value::as_table) { - for (target, target_value) in targets { - let Some(target_table) = target_value.as_table() else { - continue; - }; - for section in ["dependencies", "dev-dependencies", "build-dependencies"] { - if let Some(dependencies) = - target_table.get(section).and_then(toml::Value::as_table) - { - validate_dependency_path_table( - workspace_root, - package_root, - owner, - &format!("target.{target}.{section}"), - dependencies, - )?; - } - } - } - } - Ok(()) -} - -fn validate_dependency_path_table( - workspace_root: &Path, - path_base: &Path, - owner: &str, - section: &str, - dependencies: &toml::value::Table, -) -> Result<(), String> { - for (dependency, declaration) in dependencies { - let Some(path) = declaration - .as_table() - .and_then(|table| table.get("path")) - .and_then(toml::Value::as_str) - else { - continue; - }; - let resolved = fs::canonicalize(path_base.join(path)).map_err(|error| { - format!( - "resolve production dependency {owner} {section}.{dependency} path {path}: {error}" - ) - })?; - if !resolved.starts_with(workspace_root) { - return Err(format!( - "production dependency {owner} {section}.{dependency} path {path} escapes the repository; use a registry version or an external local-development override" - )); - } - } - Ok(()) -} - -fn dependency_package_name(package_root: &Path) -> Result<String, String> { - let manifest_path = package_root.join("Cargo.toml"); - let raw = fs::read_to_string(&manifest_path).map_err(|error| { - format!( - "read dependency manifest {}: {error}", - manifest_path.display() - ) - })?; - let manifest = raw.parse::<toml::Value>().map_err(|error| { - format!( - "parse dependency manifest {}: {error}", - manifest_path.display() - ) - })?; - manifest - .get("package") - .and_then(toml::Value::as_table) - .and_then(|package| package.get("name")) - .and_then(toml::Value::as_str) - .map(str::to_owned) - .ok_or_else(|| format!("{} is missing package.name", manifest_path.display())) -} - -fn validate_public_manifest_field( - package: &toml::value::Table, - key: &str, - workspace_value: &str, - expected: &str, - package_name: &str, -) -> Result<(), String> { - let value = resolve_public_string(package, key, workspace_value) - .ok_or_else(|| format!("public package {package_name} must declare {key}"))?; - if value != expected { - return Err(format!( - "public package {package_name} {key} {value} must be {expected}" - )); - } - Ok(()) -} - -fn resolve_public_string<'a>( - package: &'a toml::value::Table, - key: &str, - workspace_value: &'a str, -) -> Option<&'a str> { - match package.get(key)? { - toml::Value::String(value) => Some(value), - toml::Value::Table(value) - if value.get("workspace").and_then(toml::Value::as_bool) == Some(true) => - { - Some(workspace_value) - } - _ => None, - } -} - -fn resolve_public_authors<'a>( - package: &'a toml::value::Table, - workspace_authors: &'a [String], -) -> Option<Vec<&'a str>> { - match package.get("authors")? { - toml::Value::Array(values) => values.iter().map(toml::Value::as_str).collect(), - toml::Value::Table(value) - if value.get("workspace").and_then(toml::Value::as_bool) == Some(true) => - { - Some(workspace_authors.iter().map(String::as_str).collect()) - } - _ => None, - } -} - -fn validate_workspace_members(workspace_root: &Path) -> Result<(), String> { - let manifest_path = workspace_root.join("Cargo.toml"); - let manifest_raw = fs::read_to_string(&manifest_path) - .map_err(|error| format!("read {}: {error}", manifest_path.display()))?; - let manifest = toml::from_str::<WorkspaceMembershipManifest>(&manifest_raw) - .map_err(|error| format!("parse {}: {error}", manifest_path.display()))?; - let manifest_value = manifest_raw - .parse::<toml::Value>() - .map_err(|error| format!("parse {}: {error}", manifest_path.display()))?; - validate_manifest_dependency_keys(&manifest_value, "Cargo.toml")?; - let declared = manifest - .workspace - .members - .into_iter() - .collect::<BTreeSet<_>>(); - let mut discovered = BTreeSet::new(); - for root in ["crates", "tools"] { - let directory = workspace_root.join(root); - for entry in fs::read_dir(&directory) - .map_err(|error| format!("read {}: {error}", directory.display()))? - { - let entry = - entry.map_err(|error| format!("read {} entry: {error}", directory.display()))?; - if entry - .file_type() - .map_err(|error| format!("read {} type: {error}", entry.path().display()))? - .is_dir() - && entry.path().join("Cargo.toml").is_file() - { - let directory_name = entry.file_name().to_string_lossy().into_owned(); - require_lower_snake_case( - &directory_name, - &format!("{root}/{directory_name} crate directory"), - )?; - let package_manifest_path = entry.path().join("Cargo.toml"); - let package_manifest_raw = - fs::read_to_string(&package_manifest_path).map_err(|error| { - format!("read {}: {error}", package_manifest_path.display()) - })?; - let package_manifest = - package_manifest_raw - .parse::<toml::Value>() - .map_err(|error| { - format!("parse {}: {error}", package_manifest_path.display()) - })?; - let package_name = package_manifest - .get("package") - .and_then(toml::Value::as_table) - .and_then(|package| package.get("name")) - .and_then(toml::Value::as_str) - .ok_or_else(|| { - format!( - "{} is missing package.name", - package_manifest_path.display() - ) - })?; - require_lower_snake_case( - package_name, - &format!("{} package.name", package_manifest_path.display()), - )?; - validate_manifest_dependency_keys( - &package_manifest, - &package_manifest_path.display().to_string(), - )?; - discovered.insert(format!("{root}/{directory_name}")); - } - } - } - if declared != discovered { - let missing = discovered - .difference(&declared) - .cloned() - .collect::<Vec<_>>() - .join(", "); - let unknown = declared - .difference(&discovered) - .cloned() - .collect::<Vec<_>>() - .join(", "); - return Err(format!( - "workspace membership is missing local package roots: {missing}; workspace membership has unknown package roots: {unknown}" - )); - } - Ok(()) -} - -fn validate_manifest_dependency_keys(manifest: &toml::Value, label: &str) -> Result<(), String> { - let Some(manifest) = manifest.as_table() else { - return Err(format!("{label} must be a TOML table")); - }; - for section in ["dependencies", "dev-dependencies", "build-dependencies"] { - if let Some(dependencies) = manifest.get(section).and_then(toml::Value::as_table) { - validate_dependency_keys(dependencies, label, section)?; - } - } - if let Some(workspace_dependencies) = manifest - .get("workspace") - .and_then(toml::Value::as_table) - .and_then(|workspace| workspace.get("dependencies")) - .and_then(toml::Value::as_table) - { - validate_dependency_keys(workspace_dependencies, label, "workspace.dependencies")?; - } - if let Some(targets) = manifest.get("target").and_then(toml::Value::as_table) { - for (target, target_value) in targets { - let Some(target_table) = target_value.as_table() else { - continue; - }; - for section in ["dependencies", "dev-dependencies", "build-dependencies"] { - if let Some(dependencies) = - target_table.get(section).and_then(toml::Value::as_table) - { - validate_dependency_keys( - dependencies, - label, - &format!("target.{target}.{section}"), - )?; - } - } - } - } - Ok(()) -} - -fn validate_dependency_keys( - dependencies: &toml::value::Table, - label: &str, - section: &str, -) -> Result<(), String> { - for key in dependencies - .keys() - .filter(|key| key.starts_with("radroots")) - { - require_lower_snake_case(key, &format!("{label} {section} dependency key"))?; - } - Ok(()) -} - -fn require_lower_snake_case(value: &str, label: &str) -> Result<(), String> { - if is_lower_snake_case(value) { - Ok(()) - } else { - Err(format!("{label} `{value}` must use lowercase snake case")) - } -} - -fn is_lower_snake_case(value: &str) -> bool { - !value.is_empty() - && value.split('_').all(|segment| { - segment - .as_bytes() - .first() - .is_some_and(u8::is_ascii_lowercase) - && segment - .bytes() - .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit()) - }) -} - -fn validate_ledger( - workspace_root: &Path, - expected_architecture_id: &str, - raw: &str, -) -> Result<(), String> { - let ledger = toml::from_str::<DeviationLedger>(raw) - .map_err(|error| format!("parse {DEVIATIONS_RELATIVE}: {error}"))?; - if ledger.schema_version != 1 { - return Err("deviation ledger schema_version must be 1".to_owned()); - } - if ledger.architecture_id != expected_architecture_id - || ledger.architecture_id != ARCHITECTURE_ID - { - return Err(format!( - "deviation ledger architecture_id {} must match {}", - ledger.architecture_id, expected_architecture_id - )); - } - - let mut ids = BTreeSet::new(); - for record in &ledger.deviation { - validate_record(workspace_root, record)?; - if !ids.insert(record.id.as_str()) { - return Err(format!("duplicate deviation id {}", record.id)); - } - } - Ok(()) -} - -fn validate_record(workspace_root: &Path, record: &DeviationRecord) -> Result<(), String> { - if !is_deviation_id(&record.id) { - return Err(format!("deviation id {} must use RCRV1-DEV-NNN", record.id)); - } - if !is_iso_date(&record.date) { - return Err(format!("deviation {} date must use YYYY-MM-DD", record.id)); - } - if !matches!(record.status.as_str(), "active" | "closed" | "superseded") { - return Err(format!( - "deviation {} status must be active, closed, or superseded", - record.id - )); - } - require_text(&record.id, "approval", &record.approval)?; - require_text(&record.id, "replacement_action", &record.replacement_action)?; - require_text(&record.id, "unresolved_risk", &record.unresolved_risk)?; - require_nonempty_list(&record.id, "affected_steps", &record.affected_steps)?; - require_nonempty_list(&record.id, "spec_anchors", &record.spec_anchors)?; - require_nonempty_list(&record.id, "source_evidence", &record.source_evidence)?; - require_nonempty_list(&record.id, "verification", &record.verification)?; - - for step in &record.affected_steps { - let valid = step.len() == 3 - && step.bytes().all(|byte| byte.is_ascii_digit()) - && step - .parse::<u16>() - .is_ok_and(|value| (1..=315).contains(&value)); - if !valid { - return Err(format!( - "deviation {} affected step {} must be in 001..315", - record.id, step - )); - } - } - for anchor in &record.spec_anchors { - validate_spec_anchor(workspace_root, &record.id, anchor)?; - } - if record.status == "active" && !record.closure_evidence.is_empty() { - return Err(format!( - "active deviation {} must not carry closure_evidence", - record.id - )); - } - if record.status != "active" { - require_nonempty_list(&record.id, "closure_evidence", &record.closure_evidence)?; - } - - let _ = (record.normative_architecture_change, record.adr_required); - Ok(()) -} - -fn validate_spec_anchor( - workspace_root: &Path, - deviation_id: &str, - anchor: &str, -) -> Result<(), String> { - let (relative, fragment) = anchor - .split_once('#') - .map_or((anchor, None), |(path, fragment)| (path, Some(fragment))); - if relative.trim().is_empty() || fragment.is_some_and(|value| value.trim().is_empty()) { - return Err(format!( - "deviation {deviation_id} has invalid spec anchor {anchor}" - )); - } - let path = Path::new(relative); - if path.components().any(|component| { - matches!( - component, - Component::ParentDir | Component::RootDir | Component::Prefix(_) - ) - }) { - return Err(format!( - "deviation {deviation_id} spec anchor must be repository-relative: {anchor}" - )); - } - if !relative.starts_with("docs/specs/") || !workspace_root.join(path).is_file() { - return Err(format!( - "deviation {deviation_id} spec anchor does not resolve to a local spec: {anchor}" - )); - } - Ok(()) -} - -fn require_text(deviation_id: &str, field: &str, value: &str) -> Result<(), String> { - if value.trim().is_empty() { - return Err(format!( - "deviation {deviation_id} field {field} must not be empty" - )); - } - Ok(()) -} - -fn require_nonempty_list(deviation_id: &str, field: &str, values: &[String]) -> Result<(), String> { - if values.is_empty() || values.iter().any(|value| value.trim().is_empty()) { - return Err(format!( - "deviation {deviation_id} field {field} must contain non-empty values" - )); - } - Ok(()) -} - -fn is_deviation_id(value: &str) -> bool { - value - .strip_prefix("RCRV1-DEV-") - .is_some_and(|suffix| suffix.len() == 3 && suffix.bytes().all(|byte| byte.is_ascii_digit())) -} - -fn is_iso_date(value: &str) -> bool { - value.len() == 10 - && value.as_bytes()[4] == b'-' - && value.as_bytes()[7] == b'-' - && value - .bytes() - .enumerate() - .all(|(index, byte)| matches!(index, 4 | 7) || byte.is_ascii_digit()) -} - -#[cfg(test)] -mod tests { - use std::{ - collections::BTreeMap, - fs, - path::PathBuf, - time::{SystemTime, UNIX_EPOCH}, - }; - - use super::{ - ArchitectureIdentity, ArchitecturePackage, ArchitectureRepository, validate_ledger, - validate_no_production_sibling_paths, validate_public_dependency_versions, - validate_public_package_metadata, validate_workspace_members, validate_workspace_toolchain, - }; - - fn test_root(label: &str) -> PathBuf { - let nonce = SystemTime::now() - .duration_since(UNIX_EPOCH) - .expect("clock") - .as_nanos(); - let root = std::env::temp_dir().join(format!("radroots_architecture_{label}_{nonce}")); - fs::create_dir_all(root.join("docs/specs")).expect("create spec root"); - fs::write( - root.join("docs/specs/radroots_crates_release_v1.md"), - "# Architecture\n", - ) - .expect("write spec"); - root - } - - fn complete_ledger() -> &'static str { - r#"schema_version = 1 -architecture_id = "radroots.crates.release.v1" - -[[deviation]] -id = "RCRV1-DEV-001" -date = "2026-07-27" -status = "active" -approval = "Explicit user correction dated 2026-07-27." -affected_steps = ["015", "016"] -spec_anchors = ["docs/specs/radroots_crates_release_v1.md#repository-topology"] -source_evidence = ["The approved architecture assigns packages to the existing lib and sdk repositories."] -replacement_action = "Keep both standalone repositories and verify them independently." -verification = ["Repository-local architecture validation passes."] -unresolved_risk = "Remote publication remains separately authorized." -normative_architecture_change = false -adr_required = false -"# - } - - fn architecture() -> ArchitectureIdentity { - ArchitectureIdentity { - spec_id: "radroots.crates.release.v1".to_string(), - edition: "2024".to_string(), - resolver: "3".to_string(), - rust_version: "1.97.1".to_string(), - license: "MIT OR Apache-2.0".to_string(), - canonical_repositories: vec!["https://github.com/radrootslabs/sdk".to_string()], - repositories: BTreeMap::from([( - "sdk".to_string(), - ArchitectureRepository { - url: "https://github.com/radrootslabs/sdk".to_string(), - version: "0.1.0-alpha".to_string(), - packages: vec!["radroots".to_string()], - }, - )]), - package: vec![ArchitecturePackage { - name: "radroots".to_string(), - }], - } - } - - fn complete_workspace_manifest(members: &str) -> String { - format!( - "[workspace]\nmembers = [{members}]\nresolver = \"3\"\n\n[workspace.package]\nversion = \"0.1.0-alpha\"\nedition = \"2024\"\nrust-version = \"1.97.1\"\nlicense = \"MIT OR Apache-2.0\"\nrepository = \"https://github.com/radrootslabs/sdk\"\nhomepage = \"https://radroots.org\"\nreadme = \"README\"\nauthors = [\"Tyson Lupul <tyson@radroots.org>\"]\n\n[workspace.metadata.radroots.public-package]\nversion = \"0.1.0-alpha\"\nauthors = [\"Tyson Lupul <tyson@radroots.org>\"]\nreadme = \"README.md\"\n\n[workspace.lints.rust]\nunsafe_code = \"forbid\"\n\n[workspace.lints.rustdoc]\nbroken_intra_doc_links = \"deny\"\n\n[workspace.lints.clippy]\ndbg_macro = \"deny\"\ntodo = \"deny\"\nunimplemented = \"deny\"\n" - ) - } - - #[test] - fn accepts_complete_active_deviation() { - let root = test_root("complete"); - validate_ledger(&root, "radroots.crates.release.v1", complete_ledger()) - .expect("complete deviation"); - let _ = fs::remove_dir_all(root); - } - - #[test] - fn rejects_incomplete_active_deviation() { - let root = test_root("incomplete"); - let incomplete = complete_ledger().replace( - "spec_anchors = [\"docs/specs/radroots_crates_release_v1.md#repository-topology\"]", - "spec_anchors = []", - ); - let error = validate_ledger(&root, "radroots.crates.release.v1", &incomplete) - .expect_err("missing anchor must fail"); - assert!(error.contains("field spec_anchors must contain non-empty values")); - let _ = fs::remove_dir_all(root); - } - - #[test] - fn workspace_membership_requires_every_local_package_root() { - let root = test_root("workspace_members"); - for path in ["crates/a", "tools/xtask"] { - fs::create_dir_all(root.join(path)).expect("create package root"); - fs::write( - root.join(path).join("Cargo.toml"), - "[package]\nname = \"fixture\"\n", - ) - .expect("write package manifest"); - } - fs::write( - root.join("Cargo.toml"), - "[workspace]\nmembers = [\"crates/a\", \"tools/xtask\"]\nresolver = \"3\"\n\n[workspace.package]\nrust-version = \"1.97.1\"\n", - ) - .expect("write complete workspace"); - validate_workspace_members(&root).expect("complete membership"); - - fs::write( - root.join("Cargo.toml"), - "[workspace]\nmembers = [\"tools/xtask\"]\nresolver = \"3\"\n\n[workspace.package]\nrust-version = \"1.97.1\"\n", - ) - .expect("write incomplete workspace"); - let error = validate_workspace_members(&root).expect_err("missing member must fail"); - assert!(error.contains("missing local package roots: crates/a")); - let _ = fs::remove_dir_all(root); - } - - #[test] - fn workspace_identity_rejects_kebab_case_crate_names_and_dependency_keys() { - let root = test_root("workspace_snake_case_identity"); - fs::create_dir_all(root.join("crates/radroots_probe")).expect("create package root"); - fs::create_dir_all(root.join("tools/xtask")).expect("create xtask root"); - fs::write( - root.join("Cargo.toml"), - "[workspace]\nmembers = [\"crates/radroots_probe\", \"tools/xtask\"]\nresolver = \"3\"\n", - ) - .expect("write workspace manifest"); - fs::write( - root.join("crates/radroots_probe/Cargo.toml"), - "[package]\nname = \"radroots-probe\"\n", - ) - .expect("write kebab package manifest"); - fs::write( - root.join("tools/xtask/Cargo.toml"), - "[package]\nname = \"xtask\"\n", - ) - .expect("write xtask manifest"); - - let package_error = validate_workspace_members(&root) - .expect_err("kebab-case Cargo package identity must fail"); - assert!(package_error.contains("package.name")); - assert!(package_error.contains("lowercase snake case")); - - fs::write( - root.join("crates/radroots_probe/Cargo.toml"), - "[package]\nname = \"radroots_probe\"\n", - ) - .expect("write snake package manifest"); - fs::write( - root.join("Cargo.toml"), - "[workspace]\nmembers = [\"crates/radroots_probe\", \"tools/xtask\"]\nresolver = \"3\"\n\n[workspace.dependencies]\nradroots-probe = \"0.1.0-alpha\"\n", - ) - .expect("write kebab dependency key"); - - let dependency_error = validate_workspace_members(&root) - .expect_err("kebab-case Radroots dependency key must fail"); - assert!(dependency_error.contains("workspace.dependencies dependency key")); - assert!(dependency_error.contains("lowercase snake case")); - let _ = fs::remove_dir_all(root); - } - - #[test] - fn workspace_toolchain_requires_exact_resolver_and_rust_version() { - let root = test_root("workspace_toolchain"); - fs::write(root.join("Cargo.toml"), complete_workspace_manifest("")) - .expect("write workspace manifest"); - for path in ["README", "LICENSE-MIT", "LICENSE-APACHE"] { - fs::write(root.join(path), "fixture\n").expect("write workspace metadata file"); - } - fs::write( - root.join("rust-toolchain.toml"), - "[toolchain]\nchannel = \"1.97.1\"\n", - ) - .expect("write toolchain"); - let architecture = architecture(); - validate_workspace_toolchain(&root, &architecture).expect("exact toolchain policy"); - - fs::write( - root.join("rust-toolchain.toml"), - "[toolchain]\nchannel = \"1.97.0\"\n", - ) - .expect("write mismatched toolchain"); - let error = validate_workspace_toolchain(&root, &architecture) - .expect_err("mismatched toolchain must fail"); - assert!(error.contains("channel must match")); - - fs::write( - root.join("rust-toolchain.toml"), - "[toolchain]\nchannel = \"1.97.1\"\n", - ) - .expect("restore toolchain"); - let manifest = complete_workspace_manifest("").replacen( - "[workspace.package]\nversion = \"0.1.0-alpha\"", - "[workspace.package]\nversion = \"0.1.0\"", - 1, - ); - fs::write(root.join("Cargo.toml"), manifest).expect("write mismatched version cohort"); - let version_error = validate_workspace_toolchain(&root, &architecture) - .expect_err("mismatched version cohort must fail"); - assert!(version_error.contains("must match frozen repository version")); - let _ = fs::remove_dir_all(root); - } - - #[test] - fn public_package_metadata_requires_canonical_inheritance() { - let root = test_root("public_package_metadata"); - fs::create_dir_all(root.join("crates/radroots")).expect("create public package"); - fs::write( - root.join("Cargo.toml"), - complete_workspace_manifest("\"crates/radroots\""), - ) - .expect("write workspace manifest"); - let manifest = "[package]\nname = \"radroots\"\nversion.workspace = true\nedition.workspace = true\nrust-version.workspace = true\nlicense.workspace = true\nrepository.workspace = true\nhomepage.workspace = true\nauthors.workspace = true\nreadme = \"README.md\"\npublish = [\"crates-io\"]\n\n[lints]\nworkspace = true\n"; - fs::write(root.join("crates/radroots/Cargo.toml"), manifest) - .expect("write public manifest"); - fs::write(root.join("crates/radroots/README.md"), "fixture\n") - .expect("write public readme"); - validate_public_package_metadata(&root, &architecture()).expect("canonical metadata"); - - fs::write( - root.join("crates/radroots/Cargo.toml"), - manifest.replace("[lints]\nworkspace = true\n", ""), - ) - .expect("write incomplete public manifest"); - let error = validate_public_package_metadata(&root, &architecture()) - .expect_err("missing lint inheritance must fail"); - assert!(error.contains("must inherit the workspace lint policy")); - let _ = fs::remove_dir_all(root); - } - - #[test] - fn public_package_inventory_requires_every_local_package() { - let root = test_root("public_package_inventory"); - fs::write(root.join("Cargo.toml"), complete_workspace_manifest("")) - .expect("write workspace manifest"); - let error = validate_public_package_metadata(&root, &architecture()) - .expect_err("missing local public package must fail"); - assert!(error.contains("workspace public package inventory is missing: radroots")); - let _ = fs::remove_dir_all(root); - } - - #[test] - fn public_dependencies_require_path_and_exact_version() { - let root = test_root("public_dependency_version"); - fs::create_dir_all(root.join("crates/radroots")).expect("create public package"); - fs::create_dir_all(root.join("crates/dependency")).expect("create dependency package"); - fs::write( - root.join("Cargo.toml"), - format!( - "{}\n[workspace.dependencies]\nradroots_core = {{ package = \"radroots_core\", path = \"crates/dependency\", version = \"=0.1.0-alpha\" }}\n", - complete_workspace_manifest("\"crates/radroots\"") - ), - ) - .expect("write workspace manifest"); - fs::write( - root.join("crates/radroots/Cargo.toml"), - "[package]\nname = \"radroots\"\nversion = \"0.1.0-alpha\"\n\n[dependencies]\nradroots_core = { workspace = true }\n", - ) - .expect("write public manifest"); - fs::write( - root.join("crates/dependency/Cargo.toml"), - "[package]\nname = \"radroots_core\"\nversion = \"0.1.0-alpha\"\n", - ) - .expect("write dependency manifest"); - let mut architecture = architecture(); - architecture.package.push(ArchitecturePackage { - name: "radroots_core".to_owned(), - }); - architecture - .repositories - .get_mut("sdk") - .expect("sdk repository") - .packages - .push("radroots_core".to_owned()); - validate_public_dependency_versions(&root, &architecture) - .expect("path plus exact version public dependency"); - - let workspace_path = root.join("Cargo.toml"); - let workspace = fs::read_to_string(&workspace_path).expect("read workspace manifest"); - fs::write( - &workspace_path, - workspace.replace(", version = \"=0.1.0-alpha\"", ""), - ) - .expect("write path-only dependency"); - let error = validate_public_dependency_versions(&root, &architecture) - .expect_err("path-only public dependency must fail"); - assert!(error.contains("must declare path and exact version =0.1.0-alpha")); - let _ = fs::remove_dir_all(root); - } - - #[test] - fn production_dependencies_reject_sibling_paths() { - let root = test_root("production_sibling_path"); - let sibling = root.with_extension("sibling"); - fs::create_dir_all(root.join("crates/radroots")).expect("create public package"); - fs::create_dir_all(root.join("crates/dependency")).expect("create local dependency"); - fs::create_dir_all(&sibling).expect("create sibling dependency"); - fs::write( - root.join("Cargo.toml"), - complete_workspace_manifest("\"crates/radroots\""), - ) - .expect("write workspace manifest"); - let sibling_path = sibling.to_string_lossy(); - fs::write( - root.join("crates/radroots/Cargo.toml"), - format!( - "[package]\nname = \"radroots\"\nversion = \"0.1.0-alpha\"\n\n[dependencies]\nprobe = {{ path = \"{sibling_path}\" }}\n" - ), - ) - .expect("write sibling dependency"); - let error = validate_no_production_sibling_paths(&root) - .expect_err("sibling production path must fail"); - assert!(error.contains("escapes the repository")); - - fs::write( - root.join("crates/radroots/Cargo.toml"), - "[package]\nname = \"radroots\"\nversion = \"0.1.0-alpha\"\n\n[dependencies]\nprobe = { path = \"../dependency\" }\n", - ) - .expect("write local dependency"); - fs::create_dir_all(root.join(".cargo")).expect("create cargo config directory"); - fs::write( - root.join(".cargo/config.toml"), - format!("[patch.crates-io]\nprobe = {{ path = \"{sibling_path}\" }}\n"), - ) - .expect("write development override"); - validate_no_production_sibling_paths(&root).expect("in-repository path"); - let _ = fs::remove_dir_all(root); - let _ = fs::remove_dir_all(sibling); - } -} diff --git a/tools/sdk_xtask_import/src/architecture/api_leakage.rs b/tools/sdk_xtask_import/src/architecture/api_leakage.rs @@ -1,1433 +0,0 @@ -use std::{ - collections::{BTreeMap, BTreeSet}, - fs, - path::{Component, Path, PathBuf}, - process::Command, -}; - -use serde::Deserialize; -use syn::{ - Expr, ExprLit, Fields, ForeignItem, ImplItem, Item, Lit, Meta, Path as SynPath, TraitItem, - Type, UseTree, Visibility, visit::Visit, -}; - -const API_BOUNDARIES_RELATIVE: &str = "contracts/releases/api_boundaries.toml"; -const SPEC_ID: &str = "radroots.crates.release.v1"; -const POLICY_SCHEMA_VERSION: u16 = 1; -const CURRENT_STEP: u16 = 25; - -#[derive(Debug, Deserialize)] -#[serde(deny_unknown_fields)] -struct ApiBoundaryPolicy { - schema_version: u16, - spec_id: String, - forbidden_public_paths: Vec<String>, - package: Vec<ApiPackagePolicy>, - #[serde(default)] - exception: Vec<ApiException>, -} - -#[derive(Debug, Deserialize)] -#[serde(deny_unknown_fields)] -struct ApiPackagePolicy { - name: String, - allowed_public_paths: Vec<String>, -} - -#[derive(Debug, Deserialize)] -#[serde(deny_unknown_fields)] -struct ApiException { - id: String, - package: String, - source: String, - forbidden_path: String, - items: Vec<String>, - observed_paths: Vec<String>, - adr: String, - removal_step: u16, - rationale: String, -} - -#[derive(Debug, Deserialize)] -struct CargoMetadata { - packages: Vec<CargoPackage>, - workspace_members: Vec<String>, -} - -#[derive(Debug, Deserialize)] -struct CargoPackage { - id: String, - name: String, - manifest_path: String, - targets: Vec<CargoTarget>, -} - -#[derive(Debug, Deserialize)] -struct CargoTarget { - kind: Vec<String>, - src_path: String, -} - -struct ModuleUnit { - module: Vec<String>, - parent: Option<Vec<String>>, - declared_public: bool, - initially_effective: bool, - source_relative: String, - items: Vec<Item>, -} - -#[derive(Debug, Clone, Eq, Ord, PartialEq, PartialOrd)] -struct ApiFinding { - package: String, - source: String, - item: String, - forbidden_path: String, - observed_path: String, -} - -#[derive(Debug)] -enum InternalExport { - Module(Vec<String>), - Item(Vec<String>, String), - External, -} - -pub(super) fn validate_policy_catalog( - workspace_root: &Path, - expected_packages: &BTreeSet<String>, -) -> Result<(), String> { - let policy = load_policy(workspace_root)?; - validate_policy(workspace_root, &policy, expected_packages) -} - -pub(super) fn validate_public_api(workspace_root: &Path) -> Result<(), String> { - let policy = load_policy(workspace_root)?; - let expected_packages = policy - .package - .iter() - .map(|package| package.name.clone()) - .collect::<BTreeSet<_>>(); - validate_policy(workspace_root, &policy, &expected_packages)?; - let metadata = load_metadata(workspace_root)?; - let findings = scan_workspace(workspace_root, &policy, &metadata)?; - let unapproved = findings - .into_iter() - .filter(|finding| !exception_matches(&policy.exception, finding)) - .collect::<Vec<_>>(); - if unapproved.is_empty() { - Ok(()) - } else { - Err(format!( - "forbidden public implementation type leakage:\n{}", - unapproved - .iter() - .map(format_finding) - .collect::<Vec<_>>() - .join("\n") - )) - } -} - -fn load_policy(workspace_root: &Path) -> Result<ApiBoundaryPolicy, String> { - let path = workspace_root.join(API_BOUNDARIES_RELATIVE); - let raw = - fs::read_to_string(&path).map_err(|error| format!("read {}: {error}", path.display()))?; - toml::from_str(&raw).map_err(|error| format!("parse {}: {error}", path.display())) -} - -fn validate_policy( - workspace_root: &Path, - policy: &ApiBoundaryPolicy, - expected_packages: &BTreeSet<String>, -) -> Result<(), String> { - if policy.schema_version != POLICY_SCHEMA_VERSION { - return Err(format!( - "{API_BOUNDARIES_RELATIVE} schema_version must be {POLICY_SCHEMA_VERSION}" - )); - } - if policy.spec_id != SPEC_ID { - return Err(format!( - "{API_BOUNDARIES_RELATIVE} spec_id must be {SPEC_ID}" - )); - } - - let forbidden = sorted_unique( - "forbidden_public_paths", - &policy.forbidden_public_paths, - false, - )?; - if forbidden.len() != policy.forbidden_public_paths.len() { - return Err(format!( - "{API_BOUNDARIES_RELATIVE} forbidden_public_paths must be unique" - )); - } - for required in [ - "keyring", - "nostr_sdk", - "reqwest", - "sqlx", - "std::os", - "tokio", - ] { - if !forbidden.contains(required) { - return Err(format!( - "{API_BOUNDARIES_RELATIVE} must forbid public {required} paths" - )); - } - } - - let mut package_names = BTreeSet::new(); - for package in &policy.package { - if !package_names.insert(package.name.as_str()) { - return Err(format!( - "{API_BOUNDARIES_RELATIVE} package {} is declared more than once", - package.name - )); - } - let allowed = sorted_unique( - &format!("package {} allowed_public_paths", package.name), - &package.allowed_public_paths, - true, - )?; - for path in allowed { - if !forbidden.contains(path) { - return Err(format!( - "{API_BOUNDARIES_RELATIVE} package {} allows {path}, which is not forbidden globally", - package.name - )); - } - } - } - let actual_packages = package_names - .into_iter() - .map(str::to_owned) - .collect::<BTreeSet<_>>(); - if &actual_packages != expected_packages { - return Err(package_set_mismatch(expected_packages, &actual_packages)); - } - - let package_policy = policy - .package - .iter() - .map(|package| (package.name.as_str(), package)) - .collect::<BTreeMap<_, _>>(); - let mut exception_ids = BTreeSet::new(); - let mut exception_keys = BTreeSet::new(); - for exception in &policy.exception { - if !exception_ids.insert(exception.id.as_str()) { - return Err(format!( - "{API_BOUNDARIES_RELATIVE} exception id {} is duplicated", - exception.id - )); - } - if !valid_exception_id(&exception.id) { - return Err(format!( - "{API_BOUNDARIES_RELATIVE} exception id {} must match RCRV1-API-NNN", - exception.id - )); - } - let package = package_policy - .get(exception.package.as_str()) - .ok_or_else(|| { - format!( - "{API_BOUNDARIES_RELATIVE} exception {} names unknown package {}", - exception.id, exception.package - ) - })?; - if !forbidden.contains(exception.forbidden_path.as_str()) { - return Err(format!( - "{API_BOUNDARIES_RELATIVE} exception {} names unknown forbidden path {}", - exception.id, exception.forbidden_path - )); - } - if package - .allowed_public_paths - .iter() - .any(|allowed| allowed == &exception.forbidden_path) - { - return Err(format!( - "{API_BOUNDARIES_RELATIVE} exception {} is redundant with package allowance {}", - exception.id, exception.forbidden_path - )); - } - validate_relative_source(&exception.id, &exception.source)?; - let items = sorted_unique( - &format!("exception {} items", exception.id), - &exception.items, - false, - )?; - if items.len() != exception.items.len() { - return Err(format!( - "{API_BOUNDARIES_RELATIVE} exception {} items must be unique", - exception.id - )); - } - let observed_paths = sorted_unique( - &format!("exception {} observed_paths", exception.id), - &exception.observed_paths, - false, - )?; - if observed_paths.len() != exception.observed_paths.len() - || observed_paths.iter().any(|path| { - !(path == &exception.forbidden_path - || path - .strip_prefix(&exception.forbidden_path) - .is_some_and(|suffix| suffix.starts_with("::"))) - }) - { - return Err(format!( - "{API_BOUNDARIES_RELATIVE} exception {} observed_paths must be sorted, unique, and rooted at {}", - exception.id, exception.forbidden_path - )); - } - if exception.removal_step <= CURRENT_STEP { - return Err(format!( - "{API_BOUNDARIES_RELATIVE} exception {} expired at Step {}", - exception.id, exception.removal_step - )); - } - if exception.rationale.trim().is_empty() { - return Err(format!( - "{API_BOUNDARIES_RELATIVE} exception {} must include a rationale", - exception.id - )); - } - validate_adr(workspace_root, exception)?; - for item in &exception.items { - let key = ( - exception.package.as_str(), - exception.source.as_str(), - item.as_str(), - exception.forbidden_path.as_str(), - ); - if !exception_keys.insert(key) { - return Err(format!( - "{API_BOUNDARIES_RELATIVE} exception {} duplicates an item-scoped allowance", - exception.id - )); - } - } - } - Ok(()) -} - -fn sorted_unique<'a>( - label: &str, - values: &'a [String], - allow_empty: bool, -) -> Result<BTreeSet<&'a str>, String> { - if !allow_empty && values.is_empty() { - return Err(format!( - "{API_BOUNDARIES_RELATIVE} {label} must not be empty" - )); - } - let unique = values.iter().map(String::as_str).collect::<BTreeSet<_>>(); - if unique.iter().copied().ne(values.iter().map(String::as_str)) { - return Err(format!( - "{API_BOUNDARIES_RELATIVE} {label} must be sorted and unique" - )); - } - Ok(unique) -} - -fn valid_exception_id(id: &str) -> bool { - id.strip_prefix("RCRV1-API-") - .is_some_and(|suffix| suffix.len() == 3 && suffix.bytes().all(|byte| byte.is_ascii_digit())) -} - -fn validate_relative_source(id: &str, source: &str) -> Result<(), String> { - let path = Path::new(source); - if source.is_empty() - || path.is_absolute() - || path - .components() - .any(|component| !matches!(component, Component::Normal(_))) - || path.extension().and_then(|extension| extension.to_str()) != Some("rs") - { - return Err(format!( - "{API_BOUNDARIES_RELATIVE} exception {id} source must be a normalized relative Rust path" - )); - } - Ok(()) -} - -fn validate_adr(workspace_root: &Path, exception: &ApiException) -> Result<(), String> { - let path = Path::new(&exception.adr); - if path.is_absolute() - || !exception.adr.starts_with("docs/decisions/") - || path - .components() - .any(|component| !matches!(component, Component::Normal(_))) - || path.extension().and_then(|extension| extension.to_str()) != Some("md") - { - return Err(format!( - "{API_BOUNDARIES_RELATIVE} exception {} ADR must be a normalized docs/decisions/*.md path", - exception.id - )); - } - let full = workspace_root.join(path); - let raw = fs::read_to_string(&full).map_err(|error| { - format!( - "{API_BOUNDARIES_RELATIVE} exception {} ADR {} is not readable: {error}", - exception.id, - full.display() - ) - })?; - if !raw.contains(&exception.id) { - return Err(format!( - "{API_BOUNDARIES_RELATIVE} exception {} ADR {} must cite the exception id", - exception.id, exception.adr - )); - } - Ok(()) -} - -fn package_set_mismatch(expected: &BTreeSet<String>, actual: &BTreeSet<String>) -> String { - let missing = expected - .difference(actual) - .cloned() - .collect::<Vec<_>>() - .join(", "); - let extra = actual - .difference(expected) - .cloned() - .collect::<Vec<_>>() - .join(", "); - format!( - "{API_BOUNDARIES_RELATIVE} package catalog mismatch; missing: {missing}; extra: {extra}" - ) -} - -fn load_metadata(workspace_root: &Path) -> Result<CargoMetadata, String> { - let output = Command::new("cargo") - .args(["metadata", "--format-version", "1", "--locked", "--no-deps"]) - .current_dir(workspace_root) - .output() - .map_err(|error| format!("run cargo metadata: {error}"))?; - if !output.status.success() { - return Err(format!( - "cargo metadata failed while checking public API boundaries: {}", - String::from_utf8_lossy(&output.stderr).trim() - )); - } - serde_json::from_slice(&output.stdout) - .map_err(|error| format!("parse cargo metadata for public API boundaries: {error}")) -} - -fn scan_workspace( - _workspace_root: &Path, - policy: &ApiBoundaryPolicy, - metadata: &CargoMetadata, -) -> Result<Vec<ApiFinding>, String> { - let workspace_members = metadata - .workspace_members - .iter() - .map(String::as_str) - .collect::<BTreeSet<_>>(); - let package_policy = policy - .package - .iter() - .map(|package| (package.name.as_str(), package)) - .collect::<BTreeMap<_, _>>(); - let forbidden = policy - .forbidden_public_paths - .iter() - .map(String::as_str) - .collect::<BTreeSet<_>>(); - let mut findings = BTreeSet::new(); - - for package in &metadata.packages { - if !workspace_members.contains(package.id.as_str()) { - continue; - } - let Some(package_policy) = package_policy.get(package.name.as_str()).copied() else { - continue; - }; - let manifest_path = Path::new(&package.manifest_path); - let package_root = manifest_path.parent().ok_or_else(|| { - format!( - "package {} manifest has no parent: {}", - package.name, package.manifest_path - ) - })?; - let allowed = package_policy - .allowed_public_paths - .iter() - .map(String::as_str) - .collect::<BTreeSet<_>>(); - let library_targets = package - .targets - .iter() - .filter(|target| { - target - .kind - .iter() - .any(|kind| kind == "lib" || kind == "proc-macro") - }) - .collect::<Vec<_>>(); - if library_targets.len() != 1 { - return Err(format!( - "public package {} must expose exactly one library target for API leakage analysis", - package.name - )); - } - let source_path = Path::new(&library_targets[0].src_path); - let source_module_directory = module_directory(source_path)?; - let mut units = BTreeMap::new(); - collect_module( - package_root, - Vec::new(), - None, - true, - true, - source_path, - &source_module_directory, - None, - &mut units, - )?; - findings.extend(scan_package_units( - &package.name, - &units, - &forbidden, - &allowed, - )?); - } - - Ok(findings.into_iter().collect()) -} - -#[allow(clippy::too_many_arguments)] -fn collect_module( - package_root: &Path, - module: Vec<String>, - parent: Option<Vec<String>>, - declared_public: bool, - initially_effective: bool, - source_path: &Path, - module_directory: &Path, - inline_items: Option<Vec<Item>>, - units: &mut BTreeMap<Vec<String>, ModuleUnit>, -) -> Result<(), String> { - if units.contains_key(&module) { - return Ok(()); - } - let (items, source_relative) = if let Some(items) = inline_items { - let relative = source_path - .strip_prefix(package_root) - .map_err(|_| { - format!( - "module source {} escapes package {}", - source_path.display(), - package_root.display() - ) - })? - .to_string_lossy() - .replace('\\', "/"); - (items, relative) - } else { - let raw = fs::read_to_string(source_path) - .map_err(|error| format!("read {}: {error}", source_path.display()))?; - let file = syn::parse_file(&raw) - .map_err(|error| format!("parse {}: {error}", source_path.display()))?; - let relative = source_path - .strip_prefix(package_root) - .map_err(|_| { - format!( - "module source {} escapes package {}", - source_path.display(), - package_root.display() - ) - })? - .to_string_lossy() - .replace('\\', "/"); - (file.items, relative) - }; - - let children = items - .iter() - .filter_map(|item| match item { - Item::Mod(item_mod) => Some(item_mod.clone()), - _ => None, - }) - .collect::<Vec<_>>(); - units.insert( - module.clone(), - ModuleUnit { - module: module.clone(), - parent, - declared_public, - initially_effective, - source_relative, - items, - }, - ); - - for child in children { - let mut child_module = module.clone(); - child_module.push(child.ident.to_string()); - let child_public = is_public(&child.vis); - let child_effective = initially_effective && child_public; - if let Some((_, inline)) = child.content { - collect_module( - package_root, - child_module, - Some(module.clone()), - child_public, - child_effective, - source_path, - &module_directory.join(child.ident.to_string()), - Some(inline), - units, - )?; - } else { - let child_path = module_source_path(source_path, module_directory, &child)?; - collect_module( - package_root, - child_module, - Some(module.clone()), - child_public, - child_effective, - &child_path, - &module_directory.join(child.ident.to_string()), - None, - units, - )?; - } - } - Ok(()) -} - -fn module_directory(source_path: &Path) -> Result<PathBuf, String> { - let parent = source_path - .parent() - .ok_or_else(|| format!("module source has no parent: {}", source_path.display()))?; - let file_name = source_path.file_name().and_then(|name| name.to_str()); - if matches!(file_name, Some("lib.rs" | "main.rs" | "mod.rs")) { - Ok(parent.to_path_buf()) - } else { - Ok(parent.join( - source_path - .file_stem() - .ok_or_else(|| format!("module source has no stem: {}", source_path.display()))?, - )) - } -} - -fn module_source_path( - source_path: &Path, - module_directory: &Path, - item_mod: &syn::ItemMod, -) -> Result<PathBuf, String> { - if let Some(path) = path_attribute(item_mod) { - return Ok(source_path - .parent() - .unwrap_or_else(|| Path::new(".")) - .join(path)); - } - let name = item_mod.ident.to_string(); - let flat = module_directory.join(format!("{name}.rs")); - let nested = module_directory.join(&name).join("mod.rs"); - match (flat.is_file(), nested.is_file()) { - (true, false) => Ok(flat), - (false, true) => Ok(nested), - (true, true) => Err(format!( - "module {} is ambiguous between {} and {}", - item_mod.ident, - flat.display(), - nested.display() - )), - (false, false) => Err(format!( - "module {} source is missing under {}", - item_mod.ident, - module_directory.display() - )), - } -} - -fn path_attribute(item_mod: &syn::ItemMod) -> Option<String> { - item_mod.attrs.iter().find_map(|attribute| { - if !attribute.path().is_ident("path") { - return None; - } - let Meta::NameValue(value) = &attribute.meta else { - return None; - }; - let Expr::Lit(ExprLit { - lit: Lit::Str(path), - .. - }) = &value.value - else { - return None; - }; - Some(path.value()) - }) -} - -fn scan_package_units( - package: &str, - units: &BTreeMap<Vec<String>, ModuleUnit>, - forbidden: &BTreeSet<&str>, - allowed: &BTreeSet<&str>, -) -> Result<Vec<ApiFinding>, String> { - let mut effective = units - .values() - .filter(|unit| unit.initially_effective) - .map(|unit| unit.module.clone()) - .collect::<BTreeSet<_>>(); - let mut exported_items = BTreeSet::new(); - let mut changed = true; - while changed { - changed = propagate_public_modules(units, &mut effective); - let scopes = effective.iter().cloned().collect::<Vec<_>>(); - for scope in scopes { - let unit = units - .get(&scope) - .ok_or_else(|| format!("missing module unit {}", module_label(&scope)))?; - for item_use in unit.items.iter().filter_map(|item| match item { - Item::Use(item_use) if is_public(&item_use.vis) => Some(item_use), - _ => None, - }) { - for (segments, glob) in flatten_use_tree(&item_use.tree) { - match resolve_internal_export(&scope, &segments, glob, units) { - InternalExport::Module(module) => { - changed |= effective.insert(module); - } - InternalExport::Item(module, item) => { - changed |= exported_items.insert((module, item)); - } - InternalExport::External => {} - } - } - } - } - } - propagate_public_modules(units, &mut effective); - - let mut findings = BTreeSet::new(); - for unit in units.values() { - let imports = import_map(&unit.items); - let unit_effective = effective.contains(&unit.module); - let explicitly_exported = exported_items - .iter() - .filter(|(module, _)| module == &unit.module) - .map(|(_, item)| item.as_str()) - .collect::<BTreeSet<_>>(); - - for item in &unit.items { - if let Item::Use(item_use) = item { - if unit_effective && is_public(&item_use.vis) { - scan_public_use( - package, - unit, - item_use, - &imports, - forbidden, - allowed, - &mut findings, - ); - } - continue; - } - if let Item::Impl(item_impl) = item { - let Some(self_name) = impl_self_name(&item_impl.self_ty) else { - continue; - }; - if unit_effective || explicitly_exported.contains(self_name.as_str()) { - scan_impl( - package, - unit, - item_impl, - &self_name, - &imports, - forbidden, - allowed, - &mut findings, - ); - } - continue; - } - let Some(name) = item_name(item) else { - continue; - }; - if (unit_effective && item_is_public(item)) - || explicitly_exported.contains(name.as_str()) - { - scan_item( - package, - unit, - item, - &imports, - forbidden, - allowed, - &mut findings, - ); - } - } - } - Ok(findings.into_iter().collect()) -} - -fn propagate_public_modules( - units: &BTreeMap<Vec<String>, ModuleUnit>, - effective: &mut BTreeSet<Vec<String>>, -) -> bool { - let mut changed = false; - loop { - let before = effective.len(); - for unit in units.values() { - if unit.declared_public - && unit - .parent - .as_ref() - .is_some_and(|parent| effective.contains(parent)) - { - effective.insert(unit.module.clone()); - } - } - if effective.len() == before { - break; - } - changed = true; - } - changed -} - -fn flatten_use_tree(tree: &UseTree) -> Vec<(Vec<String>, bool)> { - fn visit(tree: &UseTree, prefix: &mut Vec<String>, output: &mut Vec<(Vec<String>, bool)>) { - match tree { - UseTree::Path(path) => { - prefix.push(path.ident.to_string()); - visit(&path.tree, prefix, output); - prefix.pop(); - } - UseTree::Name(name) => { - if name.ident == "self" { - output.push((prefix.clone(), false)); - } else { - let mut path = prefix.clone(); - path.push(name.ident.to_string()); - output.push((path, false)); - } - } - UseTree::Rename(rename) => { - let mut path = prefix.clone(); - path.push(rename.ident.to_string()); - output.push((path, false)); - } - UseTree::Glob(_) => output.push((prefix.clone(), true)), - UseTree::Group(group) => { - for item in &group.items { - visit(item, prefix, output); - } - } - } - } - - let mut output = Vec::new(); - visit(tree, &mut Vec::new(), &mut output); - output -} - -fn resolve_internal_export( - current: &[String], - segments: &[String], - glob: bool, - units: &BTreeMap<Vec<String>, ModuleUnit>, -) -> InternalExport { - if segments.is_empty() { - return InternalExport::External; - } - let candidates = internal_candidates(current, segments); - for candidate in candidates { - if glob && units.contains_key(&candidate) { - return InternalExport::Module(candidate); - } - if units.contains_key(&candidate) { - return InternalExport::Module(candidate); - } - if let Some((item, module)) = candidate.split_last() { - let module = module.to_vec(); - if units.contains_key(&module) { - return InternalExport::Item(module, item.clone()); - } - } - } - InternalExport::External -} - -fn internal_candidates(current: &[String], segments: &[String]) -> Vec<Vec<String>> { - let mut candidates = Vec::new(); - match segments.first().map(String::as_str) { - Some("crate") => candidates.push(segments[1..].to_vec()), - Some("self") => { - let mut path = current.to_vec(); - path.extend_from_slice(&segments[1..]); - candidates.push(path); - } - Some("super") => { - let mut base = current.to_vec(); - let mut index = 0; - while segments.get(index).map(String::as_str) == Some("super") { - base.pop(); - index += 1; - } - base.extend_from_slice(&segments[index..]); - candidates.push(base); - } - _ => { - candidates.push(segments.to_vec()); - let mut local = current.to_vec(); - local.extend_from_slice(segments); - if !candidates.contains(&local) { - candidates.push(local); - } - } - } - candidates -} - -fn import_map(items: &[Item]) -> BTreeMap<String, Vec<String>> { - fn visit(tree: &UseTree, prefix: &mut Vec<String>, output: &mut BTreeMap<String, Vec<String>>) { - match tree { - UseTree::Path(path) => { - prefix.push(path.ident.to_string()); - visit(&path.tree, prefix, output); - prefix.pop(); - } - UseTree::Name(name) => { - if name.ident == "self" { - if let Some(local) = prefix.last() { - output.insert(local.clone(), prefix.clone()); - } - } else { - let mut path = prefix.clone(); - path.push(name.ident.to_string()); - output.insert(name.ident.to_string(), path); - } - } - UseTree::Rename(rename) => { - let mut path = prefix.clone(); - path.push(rename.ident.to_string()); - output.insert(rename.rename.to_string(), path); - } - UseTree::Glob(_) => {} - UseTree::Group(group) => { - for item in &group.items { - visit(item, prefix, output); - } - } - } - } - - let mut output = BTreeMap::new(); - for item_use in items.iter().filter_map(|item| match item { - Item::Use(item_use) => Some(item_use), - _ => None, - }) { - visit(&item_use.tree, &mut Vec::new(), &mut output); - } - output -} - -#[allow(clippy::too_many_arguments)] -fn scan_item( - package: &str, - unit: &ModuleUnit, - item: &Item, - imports: &BTreeMap<String, Vec<String>>, - forbidden: &BTreeSet<&str>, - allowed: &BTreeSet<&str>, - findings: &mut BTreeSet<ApiFinding>, -) { - let name = item_name(item).unwrap_or_else(|| "<item>".to_owned()); - let label = qualified_item(&unit.module, &name); - let mut visitor = - LeakageVisitor::new(package, unit, label, imports, forbidden, allowed, findings); - match item { - Item::Const(item) => visitor.visit_type(&item.ty), - Item::Enum(item) => { - visitor.visit_generics(&item.generics); - for variant in &item.variants { - visit_fields(&mut visitor, &variant.fields, true); - } - } - Item::Fn(item) => visitor.visit_signature(&item.sig), - Item::ForeignMod(item) => { - for foreign in &item.items { - if let ForeignItem::Fn(function) = foreign { - visitor.visit_signature(&function.sig); - } - } - } - Item::Static(item) => visitor.visit_type(&item.ty), - Item::Struct(item) => { - visitor.visit_generics(&item.generics); - visit_fields(&mut visitor, &item.fields, false); - } - Item::Trait(item) => { - visitor.visit_generics(&item.generics); - for bound in &item.supertraits { - visitor.visit_type_param_bound(bound); - } - for trait_item in &item.items { - match trait_item { - TraitItem::Const(item) => visitor.visit_type(&item.ty), - TraitItem::Fn(item) => visitor.visit_signature(&item.sig), - TraitItem::Type(item) => { - visitor.visit_generics(&item.generics); - for bound in &item.bounds { - visitor.visit_type_param_bound(bound); - } - if let Some((_, ty)) = &item.default { - visitor.visit_type(ty); - } - } - _ => {} - } - } - } - Item::TraitAlias(item) => { - visitor.visit_generics(&item.generics); - for bound in &item.bounds { - visitor.visit_type_param_bound(bound); - } - } - Item::Type(item) => { - visitor.visit_generics(&item.generics); - visitor.visit_type(&item.ty); - } - Item::Union(item) => { - visitor.visit_generics(&item.generics); - for field in &item.fields.named { - if is_public(&field.vis) { - visitor.visit_type(&field.ty); - } - } - } - _ => {} - } -} - -fn visit_fields(visitor: &mut LeakageVisitor<'_>, fields: &Fields, all_visible: bool) { - for field in fields { - if all_visible || is_public(&field.vis) { - visitor.visit_type(&field.ty); - } - } -} - -#[allow(clippy::too_many_arguments)] -fn scan_impl( - package: &str, - unit: &ModuleUnit, - item_impl: &syn::ItemImpl, - self_name: &str, - imports: &BTreeMap<String, Vec<String>>, - forbidden: &BTreeSet<&str>, - allowed: &BTreeSet<&str>, - findings: &mut BTreeSet<ApiFinding>, -) { - let trait_impl = item_impl.trait_.is_some(); - for item in &item_impl.items { - let (name, is_exposed) = match item { - ImplItem::Const(item) => (item.ident.to_string(), trait_impl || is_public(&item.vis)), - ImplItem::Fn(item) => ( - item.sig.ident.to_string(), - trait_impl || is_public(&item.vis), - ), - ImplItem::Type(item) => (item.ident.to_string(), trait_impl || is_public(&item.vis)), - _ => continue, - }; - if !is_exposed { - continue; - } - let label = format!("{}::{name}", qualified_item(&unit.module, self_name)); - let mut visitor = - LeakageVisitor::new(package, unit, label, imports, forbidden, allowed, findings); - if let Some((_, trait_path, _)) = &item_impl.trait_ { - visitor.visit_path(trait_path); - } - match item { - ImplItem::Const(item) => visitor.visit_type(&item.ty), - ImplItem::Fn(item) => visitor.visit_signature(&item.sig), - ImplItem::Type(item) => { - visitor.visit_generics(&item.generics); - visitor.visit_type(&item.ty); - } - _ => {} - } - } -} - -#[allow(clippy::too_many_arguments)] -fn scan_public_use( - package: &str, - unit: &ModuleUnit, - item_use: &syn::ItemUse, - imports: &BTreeMap<String, Vec<String>>, - forbidden: &BTreeSet<&str>, - allowed: &BTreeSet<&str>, - findings: &mut BTreeSet<ApiFinding>, -) { - for (segments, _) in flatten_use_tree(&item_use.tree) { - if segments.is_empty() { - continue; - } - let label = format!("{}::pub use", module_label(&unit.module)); - record_path( - package, unit, &label, &segments, imports, forbidden, allowed, findings, - ); - } -} - -struct LeakageVisitor<'a> { - package: &'a str, - unit: &'a ModuleUnit, - item: String, - imports: &'a BTreeMap<String, Vec<String>>, - forbidden: &'a BTreeSet<&'a str>, - allowed: &'a BTreeSet<&'a str>, - findings: &'a mut BTreeSet<ApiFinding>, -} - -impl<'a> LeakageVisitor<'a> { - #[allow(clippy::too_many_arguments)] - fn new( - package: &'a str, - unit: &'a ModuleUnit, - item: String, - imports: &'a BTreeMap<String, Vec<String>>, - forbidden: &'a BTreeSet<&'a str>, - allowed: &'a BTreeSet<&'a str>, - findings: &'a mut BTreeSet<ApiFinding>, - ) -> Self { - Self { - package, - unit, - item, - imports, - forbidden, - allowed, - findings, - } - } -} - -impl<'ast> Visit<'ast> for LeakageVisitor<'_> { - fn visit_path(&mut self, path: &'ast SynPath) { - let segments = path - .segments - .iter() - .map(|segment| segment.ident.to_string()) - .collect::<Vec<_>>(); - record_path( - self.package, - self.unit, - &self.item, - &segments, - self.imports, - self.forbidden, - self.allowed, - self.findings, - ); - syn::visit::visit_path(self, path); - } -} - -#[allow(clippy::too_many_arguments)] -fn record_path( - package: &str, - unit: &ModuleUnit, - item: &str, - segments: &[String], - imports: &BTreeMap<String, Vec<String>>, - forbidden: &BTreeSet<&str>, - allowed: &BTreeSet<&str>, - findings: &mut BTreeSet<ApiFinding>, -) { - if segments.is_empty() { - return; - } - let resolved = if let Some(imported) = imports.get(&segments[0]) { - let mut resolved = imported.clone(); - resolved.extend_from_slice(&segments[1..]); - resolved - } else { - segments.to_vec() - }; - let observed = resolved.join("::"); - for forbidden_path in forbidden { - if allowed.contains(forbidden_path) - || !(observed == *forbidden_path - || observed - .strip_prefix(forbidden_path) - .is_some_and(|suffix| suffix.starts_with("::"))) - { - continue; - } - findings.insert(ApiFinding { - package: package.to_owned(), - source: unit.source_relative.clone(), - item: item.to_owned(), - forbidden_path: (*forbidden_path).to_owned(), - observed_path: observed.clone(), - }); - } -} - -fn exception_matches(exceptions: &[ApiException], finding: &ApiFinding) -> bool { - exceptions.iter().any(|exception| { - exception.package == finding.package - && exception.source == finding.source - && exception.forbidden_path == finding.forbidden_path - && exception.items.binary_search(&finding.item).is_ok() - && exception - .observed_paths - .binary_search(&finding.observed_path) - .is_ok() - }) -} - -fn format_finding(finding: &ApiFinding) -> String { - format!( - "package={} source={} item={} forbidden_path={} observed_path={}", - finding.package, - finding.source, - finding.item, - finding.forbidden_path, - finding.observed_path - ) -} - -fn item_is_public(item: &Item) -> bool { - match item { - Item::Const(item) => is_public(&item.vis), - Item::Enum(item) => is_public(&item.vis), - Item::Fn(item) => is_public(&item.vis), - Item::ForeignMod(_) => true, - Item::Static(item) => is_public(&item.vis), - Item::Struct(item) => is_public(&item.vis), - Item::Trait(item) => is_public(&item.vis), - Item::TraitAlias(item) => is_public(&item.vis), - Item::Type(item) => is_public(&item.vis), - Item::Union(item) => is_public(&item.vis), - _ => false, - } -} - -fn item_name(item: &Item) -> Option<String> { - match item { - Item::Const(item) => Some(item.ident.to_string()), - Item::Enum(item) => Some(item.ident.to_string()), - Item::Fn(item) => Some(item.sig.ident.to_string()), - Item::Static(item) => Some(item.ident.to_string()), - Item::Struct(item) => Some(item.ident.to_string()), - Item::Trait(item) => Some(item.ident.to_string()), - Item::TraitAlias(item) => Some(item.ident.to_string()), - Item::Type(item) => Some(item.ident.to_string()), - Item::Union(item) => Some(item.ident.to_string()), - _ => None, - } -} - -fn impl_self_name(self_ty: &Type) -> Option<String> { - let Type::Path(path) = self_ty else { - return None; - }; - path.path - .segments - .last() - .map(|segment| segment.ident.to_string()) -} - -fn is_public(visibility: &Visibility) -> bool { - matches!(visibility, Visibility::Public(_)) -} - -fn qualified_item(module: &[String], item: &str) -> String { - if module.is_empty() { - item.to_owned() - } else { - format!("{}::{item}", module.join("::")) - } -} - -fn module_label(module: &[String]) -> String { - if module.is_empty() { - "crate".to_owned() - } else { - module.join("::") - } -} - -#[cfg(test)] -mod tests { - use super::{ - ApiBoundaryPolicy, ApiException, CargoMetadata, exception_matches, scan_workspace, - validate_policy, - }; - use serde::Deserialize; - use std::{collections::BTreeSet, fs}; - - const POLICY: &str = include_str!("../../../../contracts/releases/api_boundaries.toml"); - const ARCHITECTURE: &str = - include_str!("../../../../docs/specs/radroots_crates_release_v1.toml"); - const GENERIC_SQLX: &str = include_str!("../../tests/fixtures/api-leakage/generic-sqlx.rs"); - const GENERIC_RENAMED_TOKIO: &str = - include_str!("../../tests/fixtures/api-leakage/generic-renamed-tokio.rs"); - const ALLOWED_ADAPTER: &str = - include_str!("../../tests/fixtures/api-leakage/allowed-concrete-adapter.rs"); - const PRIVATE_IMPLEMENTATION: &str = - include_str!("../../tests/fixtures/api-leakage/private-implementation.rs"); - const ADR_EXCEPTION: &str = include_str!("../../tests/fixtures/api-leakage/adr-exception.rs"); - - #[derive(Deserialize)] - struct ArchitectureCatalog { - package: Vec<ArchitecturePackage>, - } - - #[derive(Deserialize)] - struct ArchitecturePackage { - name: String, - } - - fn policy() -> ApiBoundaryPolicy { - toml::from_str(POLICY).expect("API boundary policy") - } - - fn expected_packages() -> BTreeSet<String> { - toml::from_str::<ArchitectureCatalog>(ARCHITECTURE) - .expect("architecture") - .package - .into_iter() - .map(|package| package.name) - .collect() - } - - fn fixture_metadata(root: &std::path::Path, package: &str, source: &str) -> CargoMetadata { - let package_root = root.join(package); - fs::create_dir_all(package_root.join("src")).expect("create fixture source"); - fs::write( - package_root.join("Cargo.toml"), - "[package]\nname='fixture'\n", - ) - .expect("write fixture manifest"); - fs::write(package_root.join("src/lib.rs"), source).expect("write fixture source"); - let id = format!("fixture#{package}@0.1.0"); - CargoMetadata { - workspace_members: vec![id.clone()], - packages: vec![super::CargoPackage { - id, - name: package.to_owned(), - manifest_path: package_root - .join("Cargo.toml") - .to_string_lossy() - .into_owned(), - targets: vec![super::CargoTarget { - kind: vec!["lib".to_owned()], - src_path: package_root - .join("src/lib.rs") - .to_string_lossy() - .into_owned(), - }], - }], - } - } - - fn scan_fixture(package: &str, source: &str) -> Vec<super::ApiFinding> { - let root = tempfile::TempDir::new().expect("fixture root"); - scan_workspace( - root.path(), - &policy(), - &fixture_metadata(root.path(), package, source), - ) - .expect("scan fixture") - } - - fn write_baseline_adr(root: &std::path::Path) { - fs::create_dir_all(root.join("docs/decisions")).expect("baseline ADR directory"); - fs::write( - root.join("docs/decisions/0001-public-api-leakage-migration-baseline.md"), - "RCRV1-API-001 RCRV1-API-002 RCRV1-API-003 RCRV1-API-004 RCRV1-API-005 RCRV1-API-006 RCRV1-API-007 RCRV1-API-008\n", - ) - .expect("baseline ADR"); - } - - #[test] - fn policy_covers_exact_architecture_catalog() { - let root = tempfile::TempDir::new().expect("policy root"); - write_baseline_adr(root.path()); - validate_policy(root.path(), &policy(), &expected_packages()) - .expect("policy without exceptions"); - } - - #[test] - fn generic_sqlx_public_field_is_forbidden() { - let findings = scan_fixture("radroots_storage", GENERIC_SQLX); - assert_eq!(findings.len(), 1); - assert_eq!(findings[0].item, "StorageHandle"); - assert_eq!(findings[0].forbidden_path, "sqlx"); - assert_eq!(findings[0].observed_path, "sqlx::SqlitePool"); - } - - #[test] - fn renamed_tokio_return_type_is_resolved() { - let findings = scan_fixture("radroots_sync", GENERIC_RENAMED_TOKIO); - assert_eq!(findings.len(), 1); - assert_eq!(findings[0].item, "executor"); - assert_eq!(findings[0].forbidden_path, "tokio"); - assert_eq!(findings[0].observed_path, "tokio::runtime::Handle"); - } - - #[test] - fn specified_nostr_adapter_may_expose_protocol_types() { - let findings = scan_fixture("radroots_nostr", ALLOWED_ADAPTER); - assert!(findings.is_empty()); - } - - #[test] - fn private_implementation_types_are_not_public_api() { - let findings = scan_fixture("radroots_sdk", PRIVATE_IMPLEMENTATION); - assert!(findings.is_empty()); - } - - #[test] - fn exact_item_exception_requires_resolving_adr() { - let root = tempfile::TempDir::new().expect("exception root"); - write_baseline_adr(root.path()); - fs::create_dir_all(root.path().join("docs/decisions")).expect("ADR directory"); - fs::write( - root.path().join("docs/decisions/0001-test.md"), - "# Test\n\nRCRV1-API-999\n", - ) - .expect("ADR"); - let mut policy = policy(); - policy.exception.push(ApiException { - id: "RCRV1-API-999".to_owned(), - package: "radroots_sdk".to_owned(), - source: "src/lib.rs".to_owned(), - forbidden_path: "reqwest".to_owned(), - items: vec!["temporary_client".to_owned()], - observed_paths: vec!["reqwest::Client".to_owned()], - adr: "docs/decisions/0001-test.md".to_owned(), - removal_step: 226, - rationale: "test-only exception".to_owned(), - }); - validate_policy(root.path(), &policy, &expected_packages()).expect("resolving ADR"); - let finding = scan_workspace( - root.path(), - &policy, - &fixture_metadata(root.path(), "radroots_sdk", ADR_EXCEPTION), - ) - .expect("scan exception fixture") - .pop() - .expect("finding"); - assert!(exception_matches(&policy.exception, &finding)); - - fs::remove_file(root.path().join("docs/decisions/0001-test.md")).expect("remove ADR"); - let error = validate_policy(root.path(), &policy, &expected_packages()) - .expect_err("missing ADR must fail"); - assert!(error.contains("is not readable")); - } -} diff --git a/tools/sdk_xtask_import/src/architecture/dependency_boundary.rs b/tools/sdk_xtask_import/src/architecture/dependency_boundary.rs @@ -1,718 +0,0 @@ -use std::{ - collections::{BTreeMap, BTreeSet}, - fs, - path::Path, - process::Command, -}; - -use serde::Deserialize; - -const PACKAGE_TIERS_RELATIVE: &str = "contracts/releases/package_tiers.toml"; -const SPEC_ID: &str = "radroots.crates.release.v1"; -const DIRECTION: &str = "same_or_lower"; -const POLICY_SCHEMA_VERSION: u16 = 1; -const CURRENT_STEP: u16 = 24; -const EXPECTED_TIERS: &[(&str, u8)] = &[ - ("foundation", 0), - ("domain", 1), - ("spi", 2), - ("adapter", 3), - ("orchestration", 4), - ("sdk", 5), - ("facade", 6), -]; -const EXPECTED_DEPENDENCY_KINDS: &[&str] = &["build", "dev", "normal"]; - -#[derive(Debug, Deserialize)] -#[serde(deny_unknown_fields)] -struct DependencyBoundaryPolicy { - schema_version: u16, - spec_id: String, - direction: String, - enforced_dependency_kinds: Vec<String>, - tier: Vec<PackageTier>, - #[serde(default)] - temporary_exception: Vec<TemporaryException>, -} - -#[derive(Debug, Deserialize)] -#[serde(deny_unknown_fields)] -struct PackageTier { - id: String, - rank: u8, - packages: Vec<String>, -} - -#[derive(Debug, Deserialize)] -#[serde(deny_unknown_fields)] -struct TemporaryException { - owner: String, - dependency: String, - kind: String, - target: String, - features: Vec<String>, - uses_default_features: bool, - removal_step: u16, - rationale: String, -} - -#[derive(Debug, Deserialize)] -struct CargoMetadata { - packages: Vec<CargoPackage>, - resolve: Option<CargoResolve>, -} - -#[derive(Debug, Deserialize)] -struct CargoPackage { - id: String, - name: String, - #[serde(default)] - dependencies: Vec<CargoDependency>, -} - -#[derive(Debug, Deserialize)] -struct CargoDependency { - name: String, - #[serde(default)] - kind: Option<String>, - #[serde(default)] - features: Vec<String>, - #[serde(default)] - target: Option<String>, - #[serde(default = "default_true")] - uses_default_features: bool, -} - -#[derive(Debug, Deserialize)] -struct CargoResolve { - nodes: Vec<CargoNode>, -} - -#[derive(Debug, Deserialize)] -struct CargoNode { - id: String, - #[serde(default)] - deps: Vec<CargoNodeDependency>, -} - -#[derive(Debug, Deserialize)] -struct CargoNodeDependency { - name: String, - pkg: String, - #[serde(default)] - dep_kinds: Vec<CargoDependencyKind>, -} - -#[derive(Debug, Deserialize)] -struct CargoDependencyKind { - #[serde(default)] - kind: Option<String>, - #[serde(default)] - target: Option<String>, -} - -#[derive(Clone, Copy)] -struct PackagePlacement<'a> { - tier: &'a str, - rank: u8, -} - -fn default_true() -> bool { - true -} - -pub(super) fn validate_policy_catalog( - workspace_root: &Path, - expected_packages: &BTreeSet<String>, -) -> Result<(), String> { - let policy = load_policy(workspace_root)?; - validate_policy(&policy, expected_packages).map(|_| ()) -} - -pub(super) fn validate_resolved_boundaries(workspace_root: &Path) -> Result<(), String> { - let policy = load_policy(workspace_root)?; - let expected_packages = policy - .tier - .iter() - .flat_map(|tier| tier.packages.iter().cloned()) - .collect::<BTreeSet<_>>(); - let placements = validate_policy(&policy, &expected_packages)?; - let metadata = load_metadata(workspace_root)?; - validate_metadata(&policy, &placements, &metadata) -} - -fn load_policy(workspace_root: &Path) -> Result<DependencyBoundaryPolicy, String> { - let path = workspace_root.join(PACKAGE_TIERS_RELATIVE); - let raw = - fs::read_to_string(&path).map_err(|error| format!("read {}: {error}", path.display()))?; - toml::from_str(&raw).map_err(|error| format!("parse {}: {error}", path.display())) -} - -fn validate_policy<'a>( - policy: &'a DependencyBoundaryPolicy, - expected_packages: &BTreeSet<String>, -) -> Result<BTreeMap<&'a str, PackagePlacement<'a>>, String> { - if policy.schema_version != POLICY_SCHEMA_VERSION { - return Err(format!( - "{PACKAGE_TIERS_RELATIVE} schema_version must be {POLICY_SCHEMA_VERSION}" - )); - } - if policy.spec_id != SPEC_ID { - return Err(format!( - "{PACKAGE_TIERS_RELATIVE} spec_id must be {SPEC_ID}" - )); - } - if policy.direction != DIRECTION { - return Err(format!( - "{PACKAGE_TIERS_RELATIVE} direction must be {DIRECTION}" - )); - } - - let actual_kinds = unique_strings( - "enforced_dependency_kinds", - &policy.enforced_dependency_kinds, - )?; - let expected_kinds = EXPECTED_DEPENDENCY_KINDS - .iter() - .copied() - .collect::<BTreeSet<_>>(); - if actual_kinds != expected_kinds { - return Err(format!( - "{PACKAGE_TIERS_RELATIVE} enforced_dependency_kinds must be build, dev, normal" - )); - } - - let actual_tiers = policy - .tier - .iter() - .map(|tier| (tier.id.as_str(), tier.rank)) - .collect::<Vec<_>>(); - if actual_tiers != EXPECTED_TIERS { - return Err(format!( - "{PACKAGE_TIERS_RELATIVE} tiers must match the approved ordered architecture" - )); - } - - let mut placements = BTreeMap::new(); - for tier in &policy.tier { - if tier.packages.is_empty() { - return Err(format!( - "{PACKAGE_TIERS_RELATIVE} tier {} must allocate at least one package", - tier.id - )); - } - for package in &tier.packages { - if placements - .insert( - package.as_str(), - PackagePlacement { - tier: &tier.id, - rank: tier.rank, - }, - ) - .is_some() - { - return Err(format!( - "{PACKAGE_TIERS_RELATIVE} package {package} is allocated more than once" - )); - } - } - } - - let actual_packages = placements - .keys() - .map(|package| (*package).to_owned()) - .collect::<BTreeSet<_>>(); - if &actual_packages != expected_packages { - return Err(package_set_mismatch(expected_packages, &actual_packages)); - } - - let mut exception_keys = BTreeSet::new(); - for exception in &policy.temporary_exception { - let owner = placements.get(exception.owner.as_str()).ok_or_else(|| { - format!( - "{PACKAGE_TIERS_RELATIVE} exception owner {} is not an approved package", - exception.owner - ) - })?; - let dependency = placements - .get(exception.dependency.as_str()) - .ok_or_else(|| { - format!( - "{PACKAGE_TIERS_RELATIVE} exception dependency {} is not an approved package", - exception.dependency - ) - })?; - if dependency.rank <= owner.rank { - return Err(format!( - "{PACKAGE_TIERS_RELATIVE} exception {} -> {} does not describe an upward edge", - exception.owner, exception.dependency - )); - } - if !expected_kinds.contains(exception.kind.as_str()) { - return Err(format!( - "{PACKAGE_TIERS_RELATIVE} exception {} -> {} uses unknown dependency kind {}", - exception.owner, exception.dependency, exception.kind - )); - } - if exception.target.trim().is_empty() { - return Err(format!( - "{PACKAGE_TIERS_RELATIVE} exception {} -> {} must name its exact target", - exception.owner, exception.dependency - )); - } - let normalized_features = normalized_features(&exception.features); - if normalized_features.len() != exception.features.len() - || normalized_features.iter().ne(exception.features.iter()) - { - return Err(format!( - "{PACKAGE_TIERS_RELATIVE} exception {} -> {} features must be sorted and unique", - exception.owner, exception.dependency - )); - } - if exception.removal_step <= CURRENT_STEP { - return Err(format!( - "{PACKAGE_TIERS_RELATIVE} exception {} -> {} expired at Step {}", - exception.owner, exception.dependency, exception.removal_step - )); - } - if exception.rationale.trim().is_empty() { - return Err(format!( - "{PACKAGE_TIERS_RELATIVE} exception {} -> {} must include a rationale", - exception.owner, exception.dependency - )); - } - let key = ( - exception.owner.as_str(), - exception.dependency.as_str(), - exception.kind.as_str(), - exception.target.as_str(), - exception.features.clone(), - exception.uses_default_features, - ); - if !exception_keys.insert(key) { - return Err(format!( - "{PACKAGE_TIERS_RELATIVE} contains a duplicate exception for {} -> {}", - exception.owner, exception.dependency - )); - } - } - - Ok(placements) -} - -fn unique_strings<'a>(label: &str, values: &'a [String]) -> Result<BTreeSet<&'a str>, String> { - let unique = values.iter().map(String::as_str).collect::<BTreeSet<_>>(); - if unique.len() != values.len() { - return Err(format!( - "{PACKAGE_TIERS_RELATIVE} {label} must not contain duplicates" - )); - } - Ok(unique) -} - -fn package_set_mismatch(expected: &BTreeSet<String>, actual: &BTreeSet<String>) -> String { - let missing = expected - .difference(actual) - .cloned() - .collect::<Vec<_>>() - .join(", "); - let extra = actual - .difference(expected) - .cloned() - .collect::<Vec<_>>() - .join(", "); - format!("{PACKAGE_TIERS_RELATIVE} package catalog mismatch; missing: {missing}; extra: {extra}") -} - -fn load_metadata(workspace_root: &Path) -> Result<CargoMetadata, String> { - let output = Command::new("cargo") - .args([ - "metadata", - "--format-version", - "1", - "--locked", - "--all-features", - ]) - .current_dir(workspace_root) - .output() - .map_err(|error| format!("run cargo metadata: {error}"))?; - if !output.status.success() { - return Err(format!( - "cargo metadata failed while checking dependency boundaries: {}", - String::from_utf8_lossy(&output.stderr).trim() - )); - } - serde_json::from_slice(&output.stdout) - .map_err(|error| format!("parse cargo metadata for dependency boundaries: {error}")) -} - -fn validate_metadata( - policy: &DependencyBoundaryPolicy, - placements: &BTreeMap<&str, PackagePlacement<'_>>, - metadata: &CargoMetadata, -) -> Result<(), String> { - let resolve = metadata - .resolve - .as_ref() - .ok_or_else(|| "cargo metadata did not include a resolved dependency graph".to_owned())?; - let mut packages_by_id = BTreeMap::new(); - for package in &metadata.packages { - if packages_by_id - .insert(package.id.as_str(), package) - .is_some() - { - return Err(format!( - "cargo metadata contains duplicate package id {}", - package.id - )); - } - } - - let mut nodes_by_id = BTreeMap::new(); - for node in &resolve.nodes { - if nodes_by_id.insert(node.id.as_str(), node).is_some() { - return Err(format!( - "cargo metadata contains duplicate resolve node {}", - node.id - )); - } - } - - let enforced_kinds = policy - .enforced_dependency_kinds - .iter() - .map(String::as_str) - .collect::<BTreeSet<_>>(); - let mut violations = Vec::new(); - - for node in &resolve.nodes { - let Some(owner_package) = packages_by_id.get(node.id.as_str()).copied() else { - return Err(format!( - "cargo metadata resolve node {} has no package record", - node.id - )); - }; - let Some(owner_placement) = placements.get(owner_package.name.as_str()).copied() else { - continue; - }; - - for resolved_dependency in &node.deps { - let dependency_package = packages_by_id - .get(resolved_dependency.pkg.as_str()) - .copied() - .ok_or_else(|| { - format!( - "cargo metadata resolved dependency {} has no package record", - resolved_dependency.pkg - ) - })?; - let Some(dependency_placement) = - placements.get(dependency_package.name.as_str()).copied() - else { - continue; - }; - if resolved_dependency.dep_kinds.is_empty() { - return Err(format!( - "cargo metadata edge {} -> {} has no dependency kind", - owner_package.name, dependency_package.name - )); - } - - for dependency_kind in &resolved_dependency.dep_kinds { - let kind = normalized_kind(dependency_kind.kind.as_deref()); - if !enforced_kinds.contains(kind) { - continue; - } - let declaration = - resolve_declaration(owner_package, dependency_package, dependency_kind)?; - if dependency_placement.rank <= owner_placement.rank { - continue; - } - if exception_matches( - &policy.temporary_exception, - owner_package, - dependency_package, - dependency_kind, - declaration, - ) { - continue; - } - violations.push(format_violation( - owner_package, - owner_placement, - dependency_package, - dependency_placement, - resolved_dependency, - dependency_kind, - declaration, - )); - } - } - } - - if violations.is_empty() { - Ok(()) - } else { - violations.sort(); - Err(format!( - "forbidden package dependency direction(s):\n{}", - violations.join("\n") - )) - } -} - -fn resolve_declaration<'a>( - owner: &'a CargoPackage, - dependency: &CargoPackage, - dependency_kind: &CargoDependencyKind, -) -> Result<&'a CargoDependency, String> { - let kind = normalized_kind(dependency_kind.kind.as_deref()); - let candidates = owner - .dependencies - .iter() - .filter(|candidate| { - candidate.name == dependency.name - && normalized_kind(candidate.kind.as_deref()) == kind - && candidate.target == dependency_kind.target - }) - .collect::<Vec<_>>(); - match candidates.as_slice() { - [declaration] => Ok(*declaration), - [] => Err(format!( - "cargo metadata edge {} -> {} kind={kind} target={} has no matching package dependency declaration", - owner.name, - dependency.name, - target_label(dependency_kind.target.as_deref()) - )), - _ => Err(format!( - "cargo metadata edge {} -> {} kind={kind} target={} has ambiguous package dependency declarations", - owner.name, - dependency.name, - target_label(dependency_kind.target.as_deref()) - )), - } -} - -fn exception_matches( - exceptions: &[TemporaryException], - owner: &CargoPackage, - dependency: &CargoPackage, - dependency_kind: &CargoDependencyKind, - declaration: &CargoDependency, -) -> bool { - let kind = normalized_kind(dependency_kind.kind.as_deref()); - let target = target_label(dependency_kind.target.as_deref()); - let features = normalized_features(&declaration.features); - exceptions.iter().any(|exception| { - exception.owner == owner.name - && exception.dependency == dependency.name - && exception.kind == kind - && exception.target == target - && exception.features == features - && exception.uses_default_features == declaration.uses_default_features - }) -} - -fn format_violation( - owner: &CargoPackage, - owner_placement: PackagePlacement<'_>, - dependency: &CargoPackage, - dependency_placement: PackagePlacement<'_>, - resolved_dependency: &CargoNodeDependency, - dependency_kind: &CargoDependencyKind, - declaration: &CargoDependency, -) -> String { - let features = normalized_features(&declaration.features); - format!( - "{} (tier={} rank={}) -> {} (tier={} rank={}); owner_id={}; dependency_id={}; alias={}; kind={}; target={}; features=[{}]; default_features={}", - owner.name, - owner_placement.tier, - owner_placement.rank, - dependency.name, - dependency_placement.tier, - dependency_placement.rank, - owner.id, - dependency.id, - resolved_dependency.name, - normalized_kind(dependency_kind.kind.as_deref()), - target_label(dependency_kind.target.as_deref()), - features.join(","), - declaration.uses_default_features, - ) -} - -fn normalized_kind(kind: Option<&str>) -> &str { - kind.unwrap_or("normal") -} - -fn target_label(target: Option<&str>) -> &str { - target.unwrap_or("all") -} - -fn normalized_features(features: &[String]) -> Vec<String> { - features - .iter() - .cloned() - .collect::<BTreeSet<_>>() - .into_iter() - .collect() -} - -#[cfg(test)] -mod tests { - use super::{ - CargoMetadata, DependencyBoundaryPolicy, exception_matches, validate_metadata, - validate_policy, - }; - use serde::Deserialize; - use std::collections::BTreeSet; - - const POLICY: &str = include_str!("../../../../contracts/releases/package_tiers.toml"); - const ARCHITECTURE: &str = - include_str!("../../../../docs/specs/radroots_crates_release_v1.toml"); - const DOMAIN_TO_STORAGE: &str = - include_str!("../../tests/fixtures/dependency-boundaries/domain-to-storage.json"); - const SPI_TO_ADAPTER: &str = - include_str!("../../tests/fixtures/dependency-boundaries/spi-to-adapter.json"); - const SERVICE_TO_SDK: &str = - include_str!("../../tests/fixtures/dependency-boundaries/service-to-sdk.json"); - const VALID_DOWNWARD: &str = - include_str!("../../tests/fixtures/dependency-boundaries/valid-downward.json"); - - #[derive(Deserialize)] - struct ArchitectureCatalog { - package: Vec<ArchitecturePackage>, - } - - #[derive(Deserialize)] - struct ArchitecturePackage { - name: String, - } - - fn policy() -> DependencyBoundaryPolicy { - toml::from_str(POLICY).expect("package tier policy") - } - - fn metadata(raw: &str) -> CargoMetadata { - serde_json::from_str(raw).expect("Cargo metadata fixture") - } - - fn placements<'a>( - policy: &'a DependencyBoundaryPolicy, - ) -> std::collections::BTreeMap<&'a str, super::PackagePlacement<'a>> { - let architecture = - toml::from_str::<ArchitectureCatalog>(ARCHITECTURE).expect("architecture catalog"); - let expected = architecture - .package - .into_iter() - .map(|package| package.name) - .collect::<BTreeSet<_>>(); - validate_policy(policy, &expected).expect("valid package tier policy") - } - - #[test] - fn policy_covers_the_exact_architecture_catalog() { - let policy = policy(); - let placements = placements(&policy); - assert_eq!(placements.len(), 19); - } - - #[test] - fn domain_to_storage_reports_resolved_alias_kind_target_and_features() { - let policy = policy(); - let error = validate_metadata(&policy, &placements(&policy), &metadata(DOMAIN_TO_STORAGE)) - .expect_err("domain must not depend on storage"); - assert!(error.contains("radroots_event (tier=domain rank=1)")); - assert!(error.contains("radroots_storage (tier=spi rank=2)")); - assert!(error.contains("alias=storage_alias")); - assert!(error.contains("kind=normal")); - assert!(error.contains("target=cfg(unix)")); - assert!(error.contains("features=[memory]")); - } - - #[test] - fn spi_to_adapter_reports_build_and_target_specific_edge() { - let policy = policy(); - let error = validate_metadata(&policy, &placements(&policy), &metadata(SPI_TO_ADAPTER)) - .expect_err("SPI must not depend on adapter"); - assert!(error.contains("radroots_transport (tier=spi rank=2)")); - assert!(error.contains("radroots_nostr (tier=adapter rank=3)")); - assert!(error.contains("alias=protocol_adapter")); - assert!(error.contains("kind=build")); - assert!(error.contains("target=cfg(target_arch = \"wasm32\")")); - assert!(error.contains("features=[events]")); - } - - #[test] - fn service_orchestration_to_sdk_is_forbidden() { - let policy = policy(); - let error = validate_metadata(&policy, &placements(&policy), &metadata(SERVICE_TO_SDK)) - .expect_err("service orchestration must not depend on SDK"); - assert!(error.contains("radroots_sync (tier=orchestration rank=4)")); - assert!(error.contains("radroots_sdk (tier=sdk rank=5)")); - assert!(error.contains("alias=client_engine")); - } - - #[test] - fn valid_downward_dev_target_edge_passes() { - let policy = policy(); - validate_metadata(&policy, &placements(&policy), &metadata(VALID_DOWNWARD)) - .expect("adapter dev dependency on SPI points downward"); - } - - #[test] - fn migration_exception_is_exact_about_features() { - let policy = policy(); - let owner = super::CargoPackage { - id: "owner".to_owned(), - name: "radroots_trade".to_owned(), - dependencies: Vec::new(), - }; - let dependency = super::CargoPackage { - id: "dependency".to_owned(), - name: "radroots_nostr".to_owned(), - dependencies: Vec::new(), - }; - let kind = super::CargoDependencyKind { - kind: Some("dev".to_owned()), - target: None, - }; - let exact = super::CargoDependency { - name: "radroots_nostr".to_owned(), - kind: Some("dev".to_owned()), - features: vec!["std".to_owned(), "events".to_owned()], - target: None, - uses_default_features: false, - }; - assert!(exception_matches( - &policy.temporary_exception, - &owner, - &dependency, - &kind, - &exact - )); - - let broadened = super::CargoDependency { - features: vec!["client".to_owned(), "events".to_owned(), "std".to_owned()], - ..exact - }; - assert!(!exception_matches( - &policy.temporary_exception, - &owner, - &dependency, - &kind, - &broadened - )); - } - - #[test] - fn metadata_without_resolve_graph_fails_closed() { - let policy = policy(); - let mut fixture = metadata(VALID_DOWNWARD); - fixture.resolve = None; - let error = validate_metadata(&policy, &placements(&policy), &fixture) - .expect_err("missing resolved graph must fail"); - assert!(error.contains("did not include a resolved dependency graph")); - } -} diff --git a/tools/sdk_xtask_import/src/bindings.rs b/tools/sdk_xtask_import/src/bindings.rs @@ -1,281 +0,0 @@ -use std::{ - collections::BTreeMap, - env, fs, - path::{Path, PathBuf}, - process::Command, -}; - -use serde::{Deserialize, Serialize}; -use sha2::{Digest, Sha256}; - -use crate::fs::{workspace_root, write_if_changed}; - -const FFI_PACKAGE: &str = "radroots_sdk_ffi"; -const FFI_VERSION: &str = "0.1.0-alpha"; -const BINDGEN_VERSION: &str = "0.29.5"; - -#[derive(Debug, Deserialize, Eq, PartialEq, Serialize)] -struct SourceLock { - schema_version: u16, - source_package: String, - source_version: String, - source_sha256: String, - generator: String, - generator_version: String, - language: String, - outputs: BTreeMap<String, String>, -} - -pub fn generate(args: &[String]) -> Result<(), String> { - match args { - [language] if language == "swift" => generate_language("swift"), - [language] if language == "kotlin" => generate_language("kotlin"), - _ => Err("usage: cargo xtask generate bindings <swift|kotlin>".to_owned()), - } -} - -fn generate_language(language: &'static str) -> Result<(), String> { - let root = workspace_root()?; - build_bindgen(&root)?; - let target_dir = env::var_os("CARGO_TARGET_DIR") - .map(PathBuf::from) - .ok_or_else(|| "CARGO_TARGET_DIR must be set by extbuild for FFI generation".to_owned())?; - let library = target_dir.join("debug").join(format!( - "{}radroots_sdk_ffi{}", - env::consts::DLL_PREFIX, - env::consts::DLL_SUFFIX - )); - if !library.is_file() { - return Err(format!("missing built FFI library: {}", library.display())); - } - let bindgen = target_dir - .join("debug") - .join(format!("radroots-sdk-bindgen{}", env::consts::EXE_SUFFIX)); - if !bindgen.is_file() { - return Err(format!( - "missing FFI bindgen executable: {}", - bindgen.display() - )); - } - let temporary = tempfile::tempdir() - .map_err(|error| format!("failed to create binding output directory: {error}"))?; - let status = Command::new(&bindgen) - .args(["generate", "--library"]) - .arg(&library) - .args(["--crate", FFI_PACKAGE, "--language", language, "--out-dir"]) - .arg(temporary.path()) - .current_dir(&root) - .status() - .map_err(|error| format!("failed to start {}: {error}", bindgen.display()))?; - if !status.success() { - return Err(format!( - "{language} binding generation failed with {status}" - )); - } - install_outputs(&root, language, temporary.path())?; - println!("generated {language} bindings for {FFI_PACKAGE}"); - Ok(()) -} - -fn build_bindgen(root: &Path) -> Result<(), String> { - let cargo = env::var_os("CARGO").unwrap_or_else(|| "cargo".into()); - let status = Command::new(cargo) - .args([ - "build", - "--locked", - "-p", - FFI_PACKAGE, - "--lib", - "--bin", - "radroots-sdk-bindgen", - ]) - .current_dir(root) - .status() - .map_err(|error| format!("failed to start Cargo for FFI generation: {error}"))?; - if !status.success() { - return Err(format!( - "FFI library and bindgen build failed with {status}" - )); - } - Ok(()) -} - -fn install_outputs(root: &Path, language: &'static str, temporary: &Path) -> Result<(), String> { - let output_dir = root.join("generated").join(language); - fs::create_dir_all(&output_dir) - .map_err(|error| format!("failed to create {}: {error}", output_dir.display()))?; - let mut generated = Vec::new(); - collect_files(temporary, &mut generated)?; - generated.sort(); - let mut outputs = BTreeMap::new(); - for path in generated { - let name = path - .strip_prefix(temporary) - .map_err(|error| format!("failed to relativize {}: {error}", path.display()))? - .to_str() - .ok_or_else(|| format!("non-UTF-8 generated binding path: {}", path.display()))? - .replace('\\', "/"); - let contents = fs::read(&path) - .map_err(|error| format!("failed to read {}: {error}", path.display()))?; - let destination = output_dir.join(&name); - let text = String::from_utf8(contents) - .map_err(|error| format!("generated text binding is not UTF-8: {error}"))?; - let text = normalize_generated_text(&text); - outputs.insert( - name.clone(), - format!("{:x}", Sha256::digest(text.as_bytes())), - ); - if let Some(parent) = destination.parent() { - fs::create_dir_all(parent) - .map_err(|error| format!("failed to create {}: {error}", parent.display()))?; - } - write_if_changed(&destination, &text)?; - } - if outputs.is_empty() { - return Err(format!("{language} binding generator emitted no files")); - } - let lock = SourceLock { - schema_version: 1, - source_package: FFI_PACKAGE.to_owned(), - source_version: FFI_VERSION.to_owned(), - source_sha256: ffi_source_sha256(root)?, - generator: "uniffi".to_owned(), - generator_version: BINDGEN_VERSION.to_owned(), - language: language.to_owned(), - outputs, - }; - let mut rendered = serde_json::to_string_pretty(&lock) - .map_err(|error| format!("failed to render {language} source lock: {error}"))?; - rendered.push('\n'); - write_if_changed(&output_dir.join("source.lock"), &rendered).map(|_| ()) -} - -fn normalize_generated_text(contents: &str) -> String { - let mut normalized = contents - .lines() - .map(str::trim_end) - .collect::<Vec<_>>() - .join("\n"); - while normalized.ends_with('\n') { - normalized.pop(); - } - normalized.push('\n'); - normalized -} - -pub fn check(root: &Path) -> Result<(), String> { - check_language( - root, - "swift", - &[ - "radroots_sdk.swift", - "radroots_sdkFFI.h", - "radroots_sdkFFI.modulemap", - ], - )?; - check_language(root, "kotlin", &["uniffi/radroots_sdk/radroots_sdk.kt"])?; - check_kotlin_schema_inventory(root) -} - -fn check_kotlin_schema_inventory(root: &Path) -> Result<(), String> { - let path = root.join("generated/kotlin/uniffi/radroots_sdk/radroots_sdk.kt"); - let source = fs::read_to_string(&path) - .map_err(|error| format!("failed to read {}: {error}", path.display()))?; - for required in [ - "class MobileClient", - "data class CapabilityStatus", - "enum class CapabilityAvailability", - "enum class CapabilityMaturity", - "sealed class Exception", - ] { - if !source.contains(required) { - return Err(format!( - "generated Kotlin schema inventory is missing {required}: {}", - path.display() - )); - } - } - for forbidden in ["Keychain", "BackgroundTask", "Presentation"] { - if source.contains(forbidden) { - return Err(format!( - "generated Kotlin bindings contain host-owned {forbidden} API: {}", - path.display() - )); - } - } - Ok(()) -} - -fn check_language(root: &Path, language: &'static str, expected: &[&str]) -> Result<(), String> { - let output_dir = root.join("generated").join(language); - let lock_path = output_dir.join("source.lock"); - let raw = fs::read_to_string(&lock_path) - .map_err(|error| format!("failed to read {}: {error}", lock_path.display()))?; - let actual: SourceLock = serde_json::from_str(&raw) - .map_err(|error| format!("failed to parse {}: {error}", lock_path.display()))?; - let mut outputs = BTreeMap::new(); - for name in expected { - let path = output_dir.join(name); - let contents = fs::read(&path) - .map_err(|error| format!("failed to read {}: {error}", path.display()))?; - outputs.insert( - (*name).to_owned(), - format!("{:x}", Sha256::digest(contents)), - ); - } - let expected_lock = SourceLock { - schema_version: 1, - source_package: FFI_PACKAGE.to_owned(), - source_version: FFI_VERSION.to_owned(), - source_sha256: ffi_source_sha256(root)?, - generator: "uniffi".to_owned(), - generator_version: BINDGEN_VERSION.to_owned(), - language: language.to_owned(), - outputs, - }; - if actual != expected_lock { - return Err(format!( - "stale generated {language} bindings: {}", - lock_path.display() - )); - } - Ok(()) -} - -fn ffi_source_sha256(root: &Path) -> Result<String, String> { - let crate_dir = root.join("crates/ffi"); - let mut paths = vec![crate_dir.join("Cargo.toml"), crate_dir.join("README.md")]; - collect_files(&crate_dir.join("src"), &mut paths)?; - paths.sort(); - let mut hasher = Sha256::new(); - for path in paths { - let relative = path - .strip_prefix(root) - .map_err(|error| format!("failed to relativize {}: {error}", path.display()))?; - hasher.update(relative.to_string_lossy().as_bytes()); - hasher.update([0]); - hasher.update( - fs::read(&path) - .map_err(|error| format!("failed to read {}: {error}", path.display()))?, - ); - hasher.update([0]); - } - Ok(format!("{:x}", hasher.finalize())) -} - -fn collect_files(dir: &Path, paths: &mut Vec<PathBuf>) -> Result<(), String> { - let mut entries = fs::read_dir(dir) - .map_err(|error| format!("failed to read {}: {error}", dir.display()))? - .collect::<Result<Vec<_>, _>>() - .map_err(|error| format!("failed to enumerate {}: {error}", dir.display()))?; - entries.sort_by_key(std::fs::DirEntry::file_name); - for entry in entries { - let path = entry.path(); - if path.is_dir() { - collect_files(&path, paths)?; - } else if path.is_file() { - paths.push(path); - } - } - Ok(()) -} diff --git a/tools/sdk_xtask_import/src/check.rs b/tools/sdk_xtask_import/src/check.rs @@ -1,3710 +0,0 @@ -use std::{ - collections::{BTreeMap, BTreeSet}, - fs, - io::Read, - path::{Path, PathBuf}, - process::Command, -}; - -use flate2::read::GzDecoder; -use serde::Deserialize; -use tar::Archive; - -use crate::{ - contracts::validate_sdk_contracts, - fs::workspace_root, - output::{PackageOutput, package_outputs}, - package_matrix::{ - FORBIDDEN_PACKAGE_NAMES, WasmPackageSpec, package_specs, validate_package_matrix, - wasm_package_specs, - }, - package_metadata::{PACKAGE_FILES, check_package_distribution_metadata, package_description}, - ts::generated_header, - wasm::validate_provenance_manifest, - wasm_declarations::declaration_files, -}; - -const PACKAGE_VERSION: &str = "0.1.0"; -const PACKAGE_LICENSE: &str = "MIT OR Apache-2.0"; -const PACKAGE_HOMEPAGE: &str = "https://radroots.org"; -const PACKAGE_REPOSITORY_URL: &str = "git+https://github.com/radrootslabs/sdk.git"; -const PUBLISH_ACCESS: &str = "public"; -const ALLOWED_RETICULUM_PREVIEW_PACKAGE: &str = "radroots_transport_reticulum"; -const RADROOTS_FACADE_MODULES: [&str; 11] = [ - "client", - "event", - "farm", - "identity", - "knowledge", - "listing", - "signing", - "storage", - "sync", - "trade", - "transport", -]; -const RADROOTS_FACADE_FEATURES: [&str; 9] = [ - "client", - "default", - "full", - "geonames", - "knowledge", - "native", - "nip46", - "nostr", - "radrootsd", -]; - -#[derive(Debug, Deserialize)] -struct PnpmPackEntry { - filename: String, - files: Vec<PnpmPackFile>, -} - -#[derive(Debug, Deserialize)] -struct PnpmPackFile { - path: String, -} - -#[derive(Debug)] -struct PackedPackage { - package_name: String, - tarball_path: PathBuf, - files: Vec<NpmPackFile>, -} - -#[derive(Debug)] -struct NpmPackFile { - path: String, -} - -#[derive(Debug, Deserialize)] -struct CargoMetadata { - packages: Vec<CargoMetadataPackage>, -} - -#[derive(Debug, Deserialize)] -struct CargoMetadataPackage { - name: String, -} - -#[derive(Debug, Deserialize)] -struct PublicationPolicyFile { - publication: PublicationPolicy, - workspace_classification: WorkspacePublicationClassification, - #[serde(default)] - publish_order: PublishOrder, -} - -#[derive(Debug, Default, Deserialize)] -struct PublishOrder { - crates: Vec<String>, -} - -#[derive(Debug, Deserialize)] -struct PublicationPolicy { - frozen: bool, - registry: String, - final_enablement_step: u16, - spec_id: String, - approved_packages: Vec<String>, - local_packages: Vec<String>, - external_packages: Vec<String>, -} - -#[derive(Debug, Deserialize)] -struct WorkspacePublicationClassification { - private: Vec<String>, - build_codegen: Vec<String>, - test_support: Vec<String>, - preview: Vec<String>, - retired: Vec<String>, -} - -#[derive(Debug, Deserialize)] -struct CratesReleaseArchitecture { - spec_id: String, - package_count: usize, - repositories: CratesReleaseRepositories, - package: Vec<CratesReleasePackage>, -} - -#[derive(Debug, Deserialize)] -struct CratesReleaseRepositories { - lib: CratesReleaseRepository, - sdk: CratesReleaseRepository, -} - -#[derive(Debug, Deserialize)] -struct CratesReleaseRepository { - packages: Vec<String>, -} - -#[derive(Debug, Deserialize)] -struct CratesReleasePackage { - name: String, -} - -pub fn check() -> Result<(), String> { - validate_package_matrix()?; - let root = workspace_root()?; - crate::architecture::validate(&root)?; - check_publication_policy(&root)?; - check_public_rust_package_metadata(&root)?; - check_radroots_facade_conformance(&root)?; - validate_sdk_contracts(&root)?; - check_sdk_feature_matrix(&root)?; - check_forbidden_packages(&root)?; - check_binding_crate_sources(&root)?; - check_package_source_metadata(&root)?; - check_generated_outputs(&root)?; - check_package_build_artifacts(&root)?; - check_npm_pack_payloads(&root)?; - crate::bindings::check(&root)?; - Ok(()) -} - -pub fn architecture_ci(root: &Path) -> Result<(), String> { - validate_package_matrix()?; - check_publication_policy(root)?; - check_public_rust_package_metadata(root)?; - check_radroots_facade_conformance(root)?; - validate_sdk_contracts(root)?; - check_sdk_feature_matrix(root)?; - check_forbidden_packages(root)?; - check_binding_crate_sources(root)?; - check_package_source_metadata(root)?; - check_generated_outputs(root)?; - crate::bindings::check(root) -} - -fn check_radroots_facade_conformance(root: &Path) -> Result<(), String> { - let manifest_path = root.join("crates/radroots/Cargo.toml"); - let manifest_raw = fs::read_to_string(&manifest_path) - .map_err(|error| format!("failed to read {}: {error}", manifest_path.display()))?; - let manifest = manifest_raw - .parse::<toml::Value>() - .map_err(|error| format!("failed to parse {}: {error}", manifest_path.display()))?; - let package = manifest - .get("package") - .and_then(toml::Value::as_table) - .ok_or_else(|| "crates/radroots/Cargo.toml must define [package]".to_owned())?; - for (field, expected) in [ - ("name", "radroots"), - ( - "description", - "Curated ordinary-user Rust facade for Radroots", - ), - ("readme", "README.md"), - ] { - if package.get(field).and_then(toml::Value::as_str) != Some(expected) { - return Err(format!( - "crates/radroots/Cargo.toml package.{field} must be {expected}" - )); - } - } - let publish = package - .get("publish") - .and_then(toml::Value::as_array) - .ok_or_else(|| "radroots facade must set publish = [\"crates-io\"]".to_owned())?; - if publish.len() != 1 || publish[0].as_str() != Some("crates-io") { - return Err("radroots facade must set publish = [\"crates-io\"]".to_owned()); - } - for field in [ - "version", - "edition", - "rust-version", - "license", - "repository", - "homepage", - ] { - let inherited = package - .get(field) - .and_then(toml::Value::as_table) - .and_then(|value| value.get("workspace")) - .and_then(toml::Value::as_bool); - if inherited != Some(true) { - return Err(format!( - "crates/radroots/Cargo.toml package.{field} must inherit workspace metadata" - )); - } - } - let library = manifest - .get("lib") - .and_then(toml::Value::as_table) - .ok_or_else(|| "crates/radroots/Cargo.toml must define [lib]".to_owned())?; - if library.get("name").and_then(toml::Value::as_str) != Some("radroots") - || library.get("path").and_then(toml::Value::as_str) != Some("src/lib.rs") - { - return Err("radroots facade [lib] must use name radroots and path src/lib.rs".to_owned()); - } - let dependencies = manifest - .get("dependencies") - .and_then(toml::Value::as_table) - .ok_or_else(|| "radroots facade must define [dependencies]".to_owned())?; - let actual_dependencies = dependencies - .keys() - .map(String::as_str) - .collect::<BTreeSet<_>>(); - let expected_dependencies = [ - "radroots_core", - "radroots_event", - "radroots_identity", - "radroots_sdk", - "radroots_trade", - "radroots_transport", - ] - .into_iter() - .collect::<BTreeSet<_>>(); - if actual_dependencies != expected_dependencies { - return Err(format!( - "radroots facade dependencies must be exactly {}; found {}", - expected_dependencies - .into_iter() - .collect::<Vec<_>>() - .join(", "), - actual_dependencies - .into_iter() - .collect::<Vec<_>>() - .join(", ") - )); - } - for (name, dependency) in dependencies { - if dependency - .as_table() - .and_then(|value| value.get("workspace")) - .and_then(toml::Value::as_bool) - != Some(true) - { - return Err(format!( - "radroots facade dependency {name} must inherit the governed workspace entry" - )); - } - } - for dependency_table in ["dev-dependencies", "build-dependencies"] { - if manifest - .get(dependency_table) - .and_then(toml::Value::as_table) - .is_some_and(|dependencies| !dependencies.is_empty()) - { - return Err(format!( - "radroots facade must not define [{dependency_table}]" - )); - } - } - - let features = manifest - .get("features") - .and_then(toml::Value::as_table) - .ok_or_else(|| "crates/radroots/Cargo.toml must define [features]".to_owned())?; - let actual_features = features.keys().map(String::as_str).collect::<BTreeSet<_>>(); - let expected_features = RADROOTS_FACADE_FEATURES - .into_iter() - .collect::<BTreeSet<_>>(); - if actual_features != expected_features { - return Err(format!( - "radroots facade features must be exactly {}; found {}", - expected_features.into_iter().collect::<Vec<_>>().join(", "), - actual_features.into_iter().collect::<Vec<_>>().join(", ") - )); - } - let defaults = features - .get("default") - .and_then(toml::Value::as_array) - .ok_or_else(|| "radroots facade default feature must be an array".to_owned())?; - if defaults.len() != 1 || defaults[0].as_str() != Some("client") { - return Err("radroots facade default feature must be exactly client".to_owned()); - } - for (name, expected) in [ - ("client", &["radroots_sdk/default"][..]), - ("native", &["client", "radroots_sdk/native"]), - ("nostr", &["client", "radroots_sdk/nostr"]), - ("nip46", &["nostr", "radroots_sdk/nip46"]), - ("radrootsd", &["client", "radroots_sdk/radrootsd"]), - ("geonames", &["client", "radroots_sdk/geonames"]), - ("knowledge", &["client", "radroots_sdk/knowledge"]), - ("full", &["radroots_sdk/full"]), - ] { - let actual = features - .get(name) - .and_then(toml::Value::as_array) - .ok_or_else(|| format!("radroots facade feature {name} must be an array"))? - .iter() - .filter_map(toml::Value::as_str) - .collect::<Vec<_>>(); - if actual != expected { - return Err(format!( - "radroots facade feature {name} must forward exactly {}", - expected.join(", ") - )); - } - } - - if package.get("autoexamples").and_then(toml::Value::as_bool) != Some(false) { - return Err("radroots facade must disable implicit example discovery".to_owned()); - } - let examples = manifest - .get("example") - .and_then(toml::Value::as_array) - .ok_or_else(|| "radroots facade must declare its ordinary example".to_owned())?; - if examples.len() != 1 - || examples[0].get("name").and_then(toml::Value::as_str) != Some("ordinary_memory") - || examples[0].get("path").and_then(toml::Value::as_str) - != Some("examples/ordinary_memory.rs") - { - return Err("radroots facade must declare only the ordinary_memory example".to_owned()); - } - - let source_path = root.join("crates/radroots/src/lib.rs"); - let source = fs::read_to_string(&source_path) - .map_err(|error| format!("failed to read {}: {error}", source_path.display()))?; - let actual_modules = source - .lines() - .filter_map(|line| { - line.trim() - .strip_prefix("pub mod ") - .and_then(|module| module.strip_suffix(';')) - }) - .collect::<BTreeSet<_>>(); - let expected_modules = RADROOTS_FACADE_MODULES.into_iter().collect::<BTreeSet<_>>(); - if actual_modules != expected_modules { - return Err(format!( - "radroots facade modules must be exactly {}; found {}", - expected_modules.into_iter().collect::<Vec<_>>().join(", "), - actual_modules.into_iter().collect::<Vec<_>>().join(", ") - )); - } - if source.contains("pub mod sdk") || source.contains("pub use radroots_sdk::*") { - return Err( - "radroots facade must not expose an sdk namespace or broad SDK re-export".to_owned(), - ); - } - if !source.contains("pub use radroots_sdk::{Client, ClientBuilder, Error, Result};") { - return Err( - "radroots facade must export exactly the four approved SDK root entry points" - .to_owned(), - ); - } - for module in RADROOTS_FACADE_MODULES { - let module_path = root - .join("crates/radroots/src") - .join(format!("{module}.rs")); - if !module_path.is_file() { - return Err(format!( - "radroots facade module file {module}.rs is missing" - )); - } - let module_source = fs::read_to_string(&module_path) - .map_err(|error| format!("failed to read {}: {error}", module_path.display()))?; - if module_source.contains("pub trait ") || module_source.contains("::*") { - return Err(format!( - "radroots facade module {module} must not define traits or wildcard reexports" - )); - } - } - let readme_path = root.join("crates/radroots/README.md"); - let readme = fs::read_to_string(&readme_path) - .map_err(|error| format!("failed to read {}: {error}", readme_path.display()))?; - if !readme.contains("```compile_fail\nuse radroots::sdk;\n```") { - return Err("radroots facade README must compile-fail the SDK namespace".to_owned()); - } - Ok(()) -} - -fn check_publication_policy(root: &Path) -> Result<(), String> { - let policy_path = root.join("contracts/releases/publication.toml"); - let raw = fs::read_to_string(&policy_path) - .map_err(|error| format!("failed to read {}: {error}", policy_path.display()))?; - let policy_file = toml::from_str::<PublicationPolicyFile>(&raw) - .map_err(|error| format!("failed to parse {}: {error}", policy_path.display()))?; - let publish_order = policy_file.publish_order.crates.clone(); - let policy = policy_file.publication; - if policy.registry != "crates-io" { - return Err("publication.registry must be crates-io".to_owned()); - } - if policy.final_enablement_step != 305 { - return Err("publication.final_enablement_step must be 305".to_owned()); - } - let architecture_path = root.join("docs/specs/radroots_crates_release_v1.toml"); - let architecture_raw = fs::read_to_string(&architecture_path) - .map_err(|error| format!("failed to read {}: {error}", architecture_path.display()))?; - let architecture = toml::from_str::<CratesReleaseArchitecture>(&architecture_raw) - .map_err(|error| format!("failed to parse {}: {error}", architecture_path.display()))?; - let expected_approved = policy_set( - architecture - .package - .iter() - .map(|package| package.name.clone()), - "architecture.package.name", - )?; - if architecture.package_count != 19 || expected_approved.len() != architecture.package_count { - return Err(format!( - "release architecture must define exactly 19 unique packages, found package_count {} and {} unique package records", - architecture.package_count, - expected_approved.len() - )); - } - if policy.spec_id != architecture.spec_id || policy.spec_id != "radroots.crates.release.v1" { - return Err(format!( - "publication.spec_id {} must match architecture id {}", - policy.spec_id, architecture.spec_id - )); - } - let approved = policy_set(policy.approved_packages, "publication.approved_packages")?; - let local = policy_set(policy.local_packages, "publication.local_packages")?; - let external = policy_set(policy.external_packages, "publication.external_packages")?; - let expected_local = policy_set( - architecture.repositories.sdk.packages, - "architecture.repositories.sdk.packages", - )?; - let expected_external = policy_set( - architecture.repositories.lib.packages, - "architecture.repositories.lib.packages", - )?; - for (field, actual, expected) in [ - ( - "publication.approved_packages", - &approved, - &expected_approved, - ), - ("publication.local_packages", &local, &expected_local), - ( - "publication.external_packages", - &external, - &expected_external, - ), - ] { - if actual != expected { - let missing = expected - .difference(actual) - .cloned() - .collect::<Vec<_>>() - .join(", "); - let extra = actual - .difference(expected) - .cloned() - .collect::<Vec<_>>() - .join(", "); - return Err(format!( - "{field} is missing approved packages: {missing}; {field} has unapproved packages: {extra}" - )); - } - } - if !local.is_disjoint(&external) { - return Err("local and external approved package ownership must not overlap".to_owned()); - } - let mut owned = local.clone(); - owned.extend(external.iter().cloned()); - if owned != approved { - return Err( - "local and external package ownership must partition approved packages".to_owned(), - ); - } - if policy.frozen { - if !publish_order.is_empty() { - return Err( - "publish_order.crates must remain empty while publication is frozen".to_owned(), - ); - } - } else { - let publish_order_set = policy_set(publish_order.iter().cloned(), "publish_order.crates")?; - if publish_order_set != local { - return Err( - "publish_order.crates must contain exactly the approved local packages when publication is enabled" - .to_owned(), - ); - } - if publish_order != ["radroots_sdk", "radroots"] { - return Err("publish_order.crates must place radroots_sdk before radroots".to_owned()); - } - } - - let mut workspace_packages = BTreeSet::new(); - for path in sdk_manifest_paths(root)?.into_iter().skip(1) { - let raw = fs::read_to_string(&path) - .map_err(|error| format!("failed to read {}: {error}", path.display()))?; - let manifest = raw - .parse::<toml::Value>() - .map_err(|error| format!("failed to parse {}: {error}", path.display()))?; - let package = manifest - .get("package") - .and_then(toml::Value::as_table) - .ok_or_else(|| format!("{} must define [package]", path.display()))?; - let name = package - .get("name") - .and_then(toml::Value::as_str) - .ok_or_else(|| format!("{} package.name must be a string", path.display()))?; - if !workspace_packages.insert(name.to_owned()) { - return Err(format!("duplicate workspace package name {name}")); - } - let publish = package.get("publish"); - if policy.frozen { - if publish.and_then(toml::Value::as_bool) != Some(false) { - return Err(format!( - "publication freeze requires workspace package {name} to set publish = false" - )); - } - } else if approved.contains(name) { - let registries = publish.and_then(toml::Value::as_array).ok_or_else(|| { - format!("approved package {name} must set publish = [\"crates-io\"]") - })?; - if registries.len() != 1 || registries[0].as_str() != Some("crates-io") { - return Err(format!( - "approved package {name} must set publish = [\"crates-io\"]" - )); - } - } else if publish.and_then(toml::Value::as_bool) != Some(false) { - return Err(format!( - "non-approved workspace package {name} must set publish = false" - )); - } - } - let external_in_workspace = external - .intersection(&workspace_packages) - .cloned() - .collect::<Vec<_>>(); - if !external_in_workspace.is_empty() { - return Err(format!( - "externally owned approved packages must not be workspace members: {}", - external_in_workspace.join(", ") - )); - } - let classification = policy_file.workspace_classification; - let private = policy_set(classification.private, "workspace_classification.private")?; - let build_codegen = policy_set( - classification.build_codegen, - "workspace_classification.build_codegen", - )?; - let test_support = policy_set( - classification.test_support, - "workspace_classification.test_support", - )?; - let preview = policy_set(classification.preview, "workspace_classification.preview")?; - let retired = policy_set(classification.retired, "workspace_classification.retired")?; - let classes = [ - ("private", &private), - ("build-codegen", &build_codegen), - ("test-support", &test_support), - ("preview", &preview), - ("retired", &retired), - ]; - for index in 0..classes.len() { - for other_index in (index + 1)..classes.len() { - let overlap = classes[index] - .1 - .intersection(classes[other_index].1) - .cloned() - .collect::<Vec<_>>(); - if !overlap.is_empty() { - return Err(format!( - "workspace classification overlap is not allowed between {} and {}: {}", - classes[index].0, - classes[other_index].0, - overlap.join(", ") - )); - } - } - } - let mut classified = BTreeSet::new(); - for (_, entries) in classes { - classified.extend(entries.iter().cloned()); - } - let local_workspace = local - .intersection(&workspace_packages) - .cloned() - .collect::<BTreeSet<_>>(); - let public_classification_overlap = classified - .intersection(&local_workspace) - .cloned() - .collect::<Vec<_>>(); - if !public_classification_overlap.is_empty() { - return Err(format!( - "approved local packages must not be classified as private workspace packages: {}", - public_classification_overlap.join(", ") - )); - } - let mut accounted = classified; - accounted.extend(local_workspace.iter().cloned()); - if accounted != workspace_packages { - let missing = workspace_packages - .difference(&accounted) - .cloned() - .collect::<Vec<_>>() - .join(", "); - let extra = accounted - .difference(&workspace_packages) - .cloned() - .collect::<Vec<_>>() - .join(", "); - return Err(format!( - "workspace classification is missing packages: {missing}; workspace classification has unknown packages: {extra}" - )); - } - if !policy.frozen && !local.is_subset(&workspace_packages) { - let missing = local - .difference(&workspace_packages) - .cloned() - .collect::<Vec<_>>() - .join(", "); - return Err(format!( - "publication enablement is missing approved workspace packages: {missing}" - )); - } - Ok(()) -} - -fn policy_set( - values: impl IntoIterator<Item = String>, - field: &str, -) -> Result<BTreeSet<String>, String> { - let mut result = BTreeSet::new(); - for value in values { - if value.trim().is_empty() { - return Err(format!("{field} must not contain empty package names")); - } - if !result.insert(value.clone()) { - return Err(format!( - "{field} must not contain duplicate package {value}" - )); - } - } - Ok(result) -} - -fn package_field_configured(package: &toml::value::Table, field: &str) -> bool { - match package.get(field) { - Some(toml::Value::String(value)) => !value.trim().is_empty(), - Some(toml::Value::Array(values)) => !values.is_empty(), - Some(toml::Value::Table(value)) => value - .get("workspace") - .and_then(toml::Value::as_bool) - .is_some_and(|configured| configured), - _ => false, - } -} - -fn package_string_array<'a>( - package: &'a toml::value::Table, - package_name: &str, - field: &str, -) -> Result<Vec<&'a str>, String> { - let values = package - .get(field) - .and_then(toml::Value::as_array) - .ok_or_else(|| format!("public package {package_name} must define package.{field}"))?; - if values.is_empty() { - return Err(format!( - "public package {package_name} package.{field} must not be empty" - )); - } - let mut resolved = Vec::with_capacity(values.len()); - let mut unique = BTreeSet::new(); - for value in values { - let value = value.as_str().ok_or_else(|| { - format!("public package {package_name} package.{field} entries must be strings") - })?; - if value.trim().is_empty() || !unique.insert(value) { - return Err(format!( - "public package {package_name} package.{field} entries must be non-empty and unique" - )); - } - resolved.push(value); - } - Ok(resolved) -} - -fn check_public_rust_package_metadata(root: &Path) -> Result<(), String> { - let policy_path = root.join("contracts/releases/publication.toml"); - let policy_raw = fs::read_to_string(&policy_path) - .map_err(|error| format!("failed to read {}: {error}", policy_path.display()))?; - let policy_file = toml::from_str::<PublicationPolicyFile>(&policy_raw) - .map_err(|error| format!("failed to parse {}: {error}", policy_path.display()))?; - let local = policy_set( - policy_file.publication.local_packages, - "publication.local_packages", - )?; - - let mut manifests = BTreeMap::new(); - for manifest_path in sdk_manifest_paths(root)?.into_iter().skip(1) { - let raw = fs::read_to_string(&manifest_path) - .map_err(|error| format!("failed to read {}: {error}", manifest_path.display()))?; - let manifest = raw - .parse::<toml::Value>() - .map_err(|error| format!("failed to parse {}: {error}", manifest_path.display()))?; - let package_name = manifest - .get("package") - .and_then(toml::Value::as_table) - .and_then(|package| package.get("name")) - .and_then(toml::Value::as_str) - .ok_or_else(|| format!("{} must define package.name", manifest_path.display()))? - .to_owned(); - if manifests - .insert(package_name.clone(), (manifest_path, manifest)) - .is_some() - { - return Err(format!("duplicate workspace package name {package_name}")); - } - } - - for package_name in local { - let (manifest_path, manifest) = manifests - .get(&package_name) - .ok_or_else(|| format!("public package {package_name} has no workspace manifest"))?; - let package = manifest - .get("package") - .and_then(toml::Value::as_table) - .expect("workspace package manifest has a package table"); - for field in [ - "authors", - "version", - "edition", - "rust-version", - "license", - "repository", - "homepage", - "documentation", - "readme", - "description", - ] { - if !package_field_configured(package, field) { - return Err(format!( - "public package {package_name} must configure package.{field}" - )); - } - } - let expected_documentation = format!("https://docs.rs/{package_name}"); - if package.get("documentation").and_then(toml::Value::as_str) - != Some(expected_documentation.as_str()) - { - return Err(format!( - "public package {package_name} package.documentation must be {expected_documentation}" - )); - } - if package.get("license-file").is_some() { - return Err(format!( - "public package {package_name} must use the workspace SPDX license expression" - )); - } - - let keywords = package_string_array(package, &package_name, "keywords")?; - let categories = package_string_array(package, &package_name, "categories")?; - if keywords.len() > 5 || categories.len() > 5 { - return Err(format!( - "public package {package_name} keywords and categories must respect crates.io limits" - )); - } - let include = package_string_array(package, &package_name, "include")?; - for required in [ - "src/**", - "tests/**", - "README.md", - "LICENSE-APACHE", - "LICENSE-MIT", - ] { - if !include.contains(&required) { - return Err(format!( - "public package {package_name} package.include must contain {required}" - )); - } - } - - let package_root = manifest_path - .parent() - .expect("workspace member manifest has a parent"); - for relative in ["LICENSE-APACHE", "LICENSE-MIT"] { - let package_bytes = fs::read(package_root.join(relative)).map_err(|error| { - format!("public package {package_name} must include {relative}: {error}") - })?; - let root_bytes = fs::read(root.join(relative)) - .map_err(|error| format!("failed to read {relative}: {error}"))?; - if package_bytes != root_bytes { - return Err(format!( - "public package {package_name} {relative} must match the workspace license" - )); - } - } - if !package_root.join("README.md").is_file() { - return Err(format!( - "public package {package_name} must include a package-local README.md" - )); - } - - let docs_rs = package - .get("metadata") - .and_then(toml::Value::as_table) - .and_then(|metadata| metadata.get("docs")) - .and_then(toml::Value::as_table) - .and_then(|docs| docs.get("rs")) - .and_then(toml::Value::as_table) - .ok_or_else(|| { - format!("public package {package_name} must define [package.metadata.docs.rs]") - })?; - if docs_rs - .get("all-features") - .and_then(toml::Value::as_bool) - .unwrap_or(false) - { - return Err(format!( - "public package {package_name} docs.rs must use an intentional feature set" - )); - } - let docs_features = docs_rs - .get("features") - .and_then(toml::Value::as_array) - .ok_or_else(|| format!("public package {package_name} docs.rs must define features"))?; - let declared_features = manifest.get("features").and_then(toml::Value::as_table); - for feature in docs_features { - let feature = feature.as_str().ok_or_else(|| { - format!("public package {package_name} docs.rs features must be strings") - })?; - if !declared_features.is_some_and(|features| features.contains_key(feature)) { - return Err(format!( - "public package {package_name} docs.rs selects unknown feature {feature}" - )); - } - } - } - Ok(()) -} - -fn check_sdk_feature_matrix(root: &Path) -> Result<(), String> { - let path = root.join("crates/sdk/Cargo.toml"); - let raw = fs::read_to_string(&path) - .map_err(|error| format!("failed to read {}: {error}", path.display()))?; - let manifest = raw - .parse::<toml::Value>() - .map_err(|error| format!("failed to parse {}: {error}", path.display()))?; - let features = manifest - .get("features") - .and_then(toml::Value::as_table) - .ok_or_else(|| format!("{} must define [features]", path.display()))?; - let expected = BTreeSet::from([ - "default", - "full", - "geonames", - "knowledge", - "local-signing", - "memory", - "native", - "nip46", - "nostr", - "radrootsd", - "sqlite", - "sync", - ]); - let actual = features.keys().map(String::as_str).collect::<BTreeSet<_>>(); - if actual != expected { - return Err(format!( - "crates/sdk/Cargo.toml feature vocabulary mismatch: expected {expected:?}, found {actual:?}" - )); - } - for (feature, expected_entries) in [ - ("default", &["memory"][..]), - ("memory", &["radroots_storage/memory"]), - ("sqlite", &["dep:radroots_storage_sqlite"]), - ("sync", &["dep:radroots_sync", "dep:uuid"]), - ( - "nostr", - &["sync", "dep:radroots_nostr", "dep:radroots_transport_nostr"], - ), - ("nip46", &["nostr", "dep:radroots_nostr_connect"]), - ( - "local-signing", - &[ - "dep:radroots_nostr", - "dep:radroots_secrets", - "radroots_nostr/signing", - ], - ), - ( - "radrootsd", - &["sync", "dep:reqwest", "dep:serde", "dep:serde_json"], - ), - ("geonames", &["dep:radroots_geonames"]), - ( - "knowledge", - &["radroots_event/knowledge", "radroots_event_codec/knowledge"], - ), - ("native", &["sqlite", "sync", "local-signing"]), - ( - "full", - &[ - "native", - "nostr", - "nip46", - "radrootsd", - "geonames", - "knowledge", - ], - ), - ] { - let actual_entries = feature_entries(features, feature)? - .into_iter() - .collect::<BTreeSet<_>>(); - let expected_entries = expected_entries.iter().copied().collect::<BTreeSet<_>>(); - if actual_entries != expected_entries { - return Err(format!( - "crates/sdk/Cargo.toml feature `{feature}` mismatch: expected {expected_entries:?}, found {actual_entries:?}" - )); - } - } - check_sdk_workspace_reticulum_dependency_boundaries(root)?; - Ok(()) -} - -fn check_sdk_workspace_reticulum_dependency_boundaries(root: &Path) -> Result<(), String> { - for path in sdk_manifest_paths(root)? { - let raw = fs::read_to_string(&path) - .map_err(|error| format!("failed to read {}: {error}", path.display()))?; - let manifest = raw - .parse::<toml::Value>() - .map_err(|error| format!("failed to parse {}: {error}", path.display()))?; - let manifest_label = relative_path_string(root, &path)?; - check_manifest_reticulum_dependency_boundaries(&manifest, &manifest_label)?; - } - check_cargo_lock_reticulum_package_names(root)?; - check_cargo_metadata_reticulum_package_names(root)?; - Ok(()) -} - -fn sdk_manifest_paths(root: &Path) -> Result<Vec<PathBuf>, String> { - let root_manifest_path = root.join("Cargo.toml"); - let raw = fs::read_to_string(&root_manifest_path) - .map_err(|error| format!("failed to read {}: {error}", root_manifest_path.display()))?; - let manifest = raw - .parse::<toml::Value>() - .map_err(|error| format!("failed to parse {}: {error}", root_manifest_path.display()))?; - let members = manifest - .get("workspace") - .and_then(|workspace| workspace.get("members")) - .and_then(toml::Value::as_array) - .ok_or_else(|| "Cargo.toml must define workspace.members".to_owned())?; - let mut paths = Vec::with_capacity(members.len() + 1); - paths.push(root_manifest_path); - for member in members { - let member = member - .as_str() - .ok_or_else(|| "Cargo.toml workspace.members entries must be strings".to_owned())?; - if member.contains('*') { - return Err(format!( - "Cargo.toml workspace member globs are not supported by the SDK Reticulum gate: {member}" - )); - } - paths.push(root.join(member).join("Cargo.toml")); - } - Ok(paths) -} - -fn check_manifest_reticulum_dependency_boundaries( - manifest: &toml::Value, - manifest_label: &str, -) -> Result<(), String> { - let table = manifest - .as_table() - .ok_or_else(|| format!("{manifest_label} must parse as a TOML table"))?; - inspect_manifest_table_for_reticulum_dependencies(table, manifest_label, &mut Vec::new()) -} - -fn inspect_manifest_table_for_reticulum_dependencies( - table: &toml::map::Map<String, toml::Value>, - manifest_label: &str, - path: &mut Vec<String>, -) -> Result<(), String> { - for (key, value) in table { - path.push(key.clone()); - if is_dependency_section_key(key) { - let dependencies = value.as_table().ok_or_else(|| { - format!( - "{manifest_label} section `{}` must be a table", - path.join(".") - ) - })?; - check_reticulum_dependency_table(dependencies, manifest_label, path)?; - path.pop(); - continue; - } - if key == "features" && path.len() == 1 { - let features = value.as_table().ok_or_else(|| { - format!( - "{manifest_label} section `{}` must be a table", - path.join(".") - ) - })?; - check_reticulum_feature_table(features, manifest_label, path)?; - } - if let Some(nested) = value.as_table() { - inspect_manifest_table_for_reticulum_dependencies(nested, manifest_label, path)?; - } - path.pop(); - } - Ok(()) -} - -fn is_dependency_section_key(key: &str) -> bool { - matches!( - key, - "dependencies" | "dev-dependencies" | "build-dependencies" - ) -} - -fn check_reticulum_dependency_table( - dependencies: &toml::map::Map<String, toml::Value>, - manifest_label: &str, - path: &[String], -) -> Result<(), String> { - let section = path.join("."); - for (dependency_key, dependency_spec) in dependencies { - reject_forbidden_reticulum_runtime_name(dependency_key, manifest_label, &section)?; - if let Some(package_name) = dependency_spec - .as_table() - .and_then(|table| table.get("package")) - .and_then(toml::Value::as_str) - { - reject_forbidden_reticulum_runtime_name(package_name, manifest_label, &section)?; - } - } - Ok(()) -} - -fn check_reticulum_feature_table( - features: &toml::map::Map<String, toml::Value>, - manifest_label: &str, - path: &[String], -) -> Result<(), String> { - let section = path.join("."); - for (feature_name, entries) in features { - if feature_name == "transport-reticulum-preview" || feature_name == "reticulum-runtime" { - return Err(format!( - "{manifest_label} feature `{feature_name}` must not introduce a Reticulum preview alias" - )); - } - reject_forbidden_reticulum_runtime_name(feature_name, manifest_label, &section)?; - let entries = entries - .as_array() - .ok_or_else(|| format!("{manifest_label} feature `{feature_name}` must be an array"))?; - for entry in entries { - let entry = entry.as_str().ok_or_else(|| { - format!("{manifest_label} feature `{feature_name}` entries must be strings") - })?; - check_reticulum_feature_entry(entry, manifest_label, feature_name)?; - } - } - Ok(()) -} - -fn check_reticulum_feature_entry( - entry: &str, - manifest_label: &str, - feature_name: &str, -) -> Result<(), String> { - let feature_dependency = entry.strip_prefix("dep:").unwrap_or(entry); - let (dependency_name, dependency_feature) = feature_dependency - .split_once('/') - .map_or((feature_dependency, None), |(name, feature)| { - (name, Some(feature)) - }); - if dependency_name == ALLOWED_RETICULUM_PREVIEW_PACKAGE && dependency_feature.is_some() { - return Err(format!( - "{manifest_label} feature `{feature_name}` must not enable Reticulum preview crate features through `{entry}`" - )); - } - reject_forbidden_reticulum_runtime_name(dependency_name, manifest_label, "features")?; - Ok(()) -} - -fn check_cargo_lock_reticulum_package_names(root: &Path) -> Result<(), String> { - let path = root.join("Cargo.lock"); - let raw = fs::read_to_string(&path) - .map_err(|error| format!("failed to read {}: {error}", path.display()))?; - let lock = raw - .parse::<toml::Value>() - .map_err(|error| format!("failed to parse {}: {error}", path.display()))?; - let packages = lock - .get("package") - .and_then(toml::Value::as_array) - .ok_or_else(|| "Cargo.lock must define package entries".to_owned())?; - for package in packages { - let name = package - .get("name") - .and_then(toml::Value::as_str) - .ok_or_else(|| "Cargo.lock package entry must define name".to_owned())?; - reject_forbidden_reticulum_runtime_name(name, "Cargo.lock", "package")?; - } - Ok(()) -} - -fn check_cargo_metadata_reticulum_package_names(root: &Path) -> Result<(), String> { - let output = Command::new("cargo") - .args(["metadata", "--format-version", "1", "--all-features"]) - .current_dir(root) - .output() - .map_err(|error| format!("failed to run cargo metadata: {error}"))?; - if !output.status.success() { - return Err(format!( - "cargo metadata failed: {}", - String::from_utf8_lossy(&output.stderr).trim() - )); - } - let metadata = serde_json::from_slice::<CargoMetadata>(&output.stdout) - .map_err(|error| format!("failed to parse cargo metadata: {error}"))?; - for package in metadata.packages { - reject_forbidden_reticulum_runtime_name(&package.name, "cargo metadata", "packages")?; - } - Ok(()) -} - -fn reject_forbidden_reticulum_runtime_name( - name: &str, - manifest_label: &str, - section: &str, -) -> Result<(), String> { - if is_forbidden_reticulum_runtime_name(name) { - Err(format!( - "{manifest_label} section `{section}` must not reference real Reticulum or Python runtime dependency `{name}`" - )) - } else { - Ok(()) - } -} - -fn is_forbidden_reticulum_runtime_name(name: &str) -> bool { - if name == ALLOWED_RETICULUM_PREVIEW_PACKAGE { - return false; - } - let normalized = normalize_package_name(name); - matches!( - normalized.as_str(), - "rns" | "rnsd" | "reticulum" | "reticulum-rs" | "python" | "pyo3" - ) || normalized.contains("reticulum") - || normalized.starts_with("rns-") - || normalized.contains("-rns-") - || normalized.ends_with("-rns") - || normalized.starts_with("python-") - || normalized.starts_with("pyo3-") -} - -fn normalize_package_name(name: &str) -> String { - name.to_ascii_lowercase().replace('_', "-") -} - -fn feature_entries<'features>( - features: &'features toml::map::Map<String, toml::Value>, - feature: &str, -) -> Result<Vec<&'features str>, String> { - features - .get(feature) - .and_then(toml::Value::as_array) - .ok_or_else(|| format!("crates/sdk/Cargo.toml must define feature `{feature}`"))? - .iter() - .map(|entry| { - entry - .as_str() - .ok_or_else(|| format!("feature `{feature}` must contain only string entries")) - }) - .collect() -} - -fn check_package_source_metadata(root: &Path) -> Result<(), String> { - for spec in package_specs() { - let package_dir = root.join(spec.package_dir); - let package_json_path = package_dir.join("package.json"); - let index_path = package_dir.join("src/index.ts"); - let package_json = - check_package_json(&package_json_path, spec.package_name, spec.package_dir)?; - check_package_distribution_metadata(root, &package_dir, &package_json_path, &package_json)?; - if !index_path.is_file() { - return Err(format!("missing package index: {}", index_path.display())); - } - check_package_index(&index_path)?; - } - for spec in wasm_package_specs() { - let package_dir = root.join(spec.package_dir); - let package_json_path = package_dir.join("package.json"); - let package_json = - check_package_json(&package_json_path, spec.package_name, spec.package_dir)?; - check_package_distribution_metadata(root, &package_dir, &package_json_path, &package_json)?; - } - Ok(()) -} - -fn check_generated_outputs(root: &Path) -> Result<(), String> { - let outputs = package_outputs()?; - for output in &outputs { - check_generated_package_artifact_inventory(root, output)?; - } - for output in outputs { - for expected in output.files() { - let path = root - .join(output.spec.package_dir) - .join(expected.relative_path); - let actual = fs::read_to_string(&path) - .map_err(|error| format!("failed to read {}: {error}", path.display()))?; - if actual != expected.contents { - return Err(format!("stale generated output: {}", path.display())); - } - } - let expected = output.provenance_file(); - let path = root.join(&expected.relative_path); - let actual = fs::read_to_string(&path) - .map_err(|error| format!("failed to read {}: {error}", path.display()))?; - if actual != expected.contents { - return Err(format!("stale generated provenance: {}", path.display())); - } - } - Ok(()) -} - -fn check_package_build_artifacts(root: &Path) -> Result<(), String> { - for spec in package_specs() { - let package_dir = root.join(spec.package_dir); - let package_json_path = package_dir.join("package.json"); - let package_json = read_package_json_value(&package_json_path)?; - let surface_paths = package_surface_paths(&package_json, &package_json_path)?; - check_package_surface_artifacts(&package_dir, spec.package_name, &surface_paths)?; - } - for spec in wasm_package_specs() { - check_wasm_package_surface(root, *spec)?; - } - Ok(()) -} - -fn check_generated_package_artifact_inventory( - root: &Path, - output: &PackageOutput, -) -> Result<(), String> { - let package_dir = root.join(output.spec.package_dir); - let generated_dir = package_dir.join("src/generated"); - let expected = output - .files() - .into_iter() - .map(|file| file.relative_path) - .collect::<BTreeSet<_>>(); - let actual = generated_package_files(&package_dir, &generated_dir)?; - if actual != expected { - let missing = expected.difference(&actual).cloned().collect::<Vec<_>>(); - let extra = actual.difference(&expected).cloned().collect::<Vec<_>>(); - return Err(format!( - "generated artifact inventory mismatch for {}: missing {:?}, extra {:?}", - output.spec.package_name, missing, extra - )); - } - Ok(()) -} - -fn generated_package_files( - package_dir: &Path, - generated_dir: &Path, -) -> Result<BTreeSet<String>, String> { - let mut files = BTreeSet::new(); - collect_package_files(package_dir, generated_dir, &mut files)?; - Ok(files) -} - -fn collect_package_files( - package_dir: &Path, - dir: &Path, - files: &mut BTreeSet<String>, -) -> Result<(), String> { - for entry in - fs::read_dir(dir).map_err(|error| format!("failed to read {}: {error}", dir.display()))? - { - let entry = - entry.map_err(|error| format!("failed to read {} entry: {error}", dir.display()))?; - let path = entry.path(); - let file_type = entry - .file_type() - .map_err(|error| format!("failed to inspect {}: {error}", path.display()))?; - if file_type.is_dir() { - collect_package_files(package_dir, &path, files)?; - } else if file_type.is_file() { - files.insert(relative_path_string(package_dir, &path)?); - } - } - Ok(()) -} - -fn relative_path_string(base: &Path, path: &Path) -> Result<String, String> { - let relative = path - .strip_prefix(base) - .map_err(|error| format!("failed to relativize {}: {error}", path.display()))?; - Ok(relative - .components() - .map(|component| component.as_os_str().to_string_lossy()) - .collect::<Vec<_>>() - .join("/")) -} - -fn check_package_index(path: &Path) -> Result<(), String> { - let raw = fs::read_to_string(path) - .map_err(|error| format!("failed to read {}: {error}", path.display()))?; - if raw.starts_with(generated_header()) { - return Err(format!( - "package index must be handwritten source: {}", - path.display() - )); - } - Ok(()) -} - -fn check_binding_crate_sources(root: &Path) -> Result<(), String> { - for spec in package_specs() { - let crate_src_dir = root.join(spec.crate_dir).join("src"); - let typescript_dir = crate_src_dir.join("typescript"); - if typescript_dir.exists() { - return Err(format!( - "forbidden crate TypeScript source directory exists: {}", - typescript_dir.display() - )); - } - check_no_typescript_files(&crate_src_dir)?; - } - for spec in wasm_package_specs() { - check_no_typescript_files(&root.join(spec.crate_dir).join("src"))?; - } - Ok(()) -} - -fn check_no_typescript_files(dir: &Path) -> Result<(), String> { - for entry in - fs::read_dir(dir).map_err(|error| format!("failed to read {}: {error}", dir.display()))? - { - let entry = - entry.map_err(|error| format!("failed to read {} entry: {error}", dir.display()))?; - let path = entry.path(); - let file_type = entry - .file_type() - .map_err(|error| format!("failed to inspect {}: {error}", path.display()))?; - if file_type.is_dir() { - check_no_typescript_files(&path)?; - } else if file_type.is_file() - && path.extension().and_then(|extension| extension.to_str()) == Some("ts") - { - return Err(format!( - "forbidden crate TypeScript source file exists: {}", - path.display() - )); - } - } - Ok(()) -} - -fn check_forbidden_packages(root: &Path) -> Result<(), String> { - for forbidden in FORBIDDEN_PACKAGE_NAMES { - let package_leaf = forbidden.trim_start_matches("@radroots/").to_owned(); - let forbidden_dir = root.join("packages").join(package_leaf); - if forbidden_dir.exists() { - return Err(format!( - "forbidden package directory exists: {}", - forbidden_dir.display() - )); - } - } - Ok(()) -} - -fn check_package_json( - path: &Path, - expected_name: &str, - expected_directory: &str, -) -> Result<serde_json::Value, String> { - let raw = fs::read_to_string(path) - .map_err(|error| format!("failed to read {}: {error}", path.display()))?; - let json = serde_json::from_str::<serde_json::Value>(&raw) - .map_err(|error| format!("failed to parse {}: {error}", path.display()))?; - let actual_name = json - .get("name") - .and_then(serde_json::Value::as_str) - .ok_or_else(|| format!("package.json missing name: {}", path.display()))?; - if actual_name != expected_name { - return Err(format!( - "package name mismatch in {}: expected {expected_name}, found {actual_name}", - path.display() - )); - } - if json.get("private").is_some() { - return Err(format!( - "public package must not set private: {}", - path.display() - )); - } - let _ = package_description(&json, path)?; - require_string_field(&json, path, "version", PACKAGE_VERSION)?; - require_string_field(&json, path, "license", PACKAGE_LICENSE)?; - require_string_field(&json, path, "homepage", PACKAGE_HOMEPAGE)?; - require_string_field(&json, path, "type", "module")?; - require_bool_field(&json, path, "sideEffects", false)?; - check_publish_config(&json, path)?; - check_repository(&json, path, expected_directory)?; - check_package_files(&json, path)?; - check_no_pack_lifecycle_scripts(&json, path)?; - check_workspace_dependencies(&json, path)?; - Ok(json) -} - -pub(crate) fn check_wasm_package_surface(root: &Path, spec: WasmPackageSpec) -> Result<(), String> { - let package_dir = root.join(spec.package_dir); - let package_json_path = package_dir.join("package.json"); - let json = check_package_json(&package_json_path, spec.package_name, spec.package_dir)?; - check_package_distribution_metadata(root, &package_dir, &package_json_path, &json)?; - let dist_manifest = package_dir.join("dist").join("package.json"); - if dist_manifest.exists() { - return Err(format!( - "generated package manifest is forbidden: {}", - dist_manifest.display() - )); - } - check_no_wasm_dist_ignore_files(&package_dir, spec)?; - let surface_paths = package_surface_paths(&json, &package_json_path)?; - check_public_wasm_declaration_inventory(&surface_paths, spec)?; - check_package_surface_artifacts(&package_dir, spec.package_name, &surface_paths)?; - check_wasm_runtime_files(&package_dir, spec)?; - check_wasm_declaration_files(&package_dir, spec)?; - validate_provenance_manifest(root, spec)?; - Ok(()) -} - -fn require_string_field( - json: &serde_json::Value, - package_json_path: &Path, - field: &'static str, - expected: &str, -) -> Result<(), String> { - let actual = json - .get(field) - .and_then(serde_json::Value::as_str) - .ok_or_else(|| { - format!( - "package.json missing {field}: {}", - package_json_path.display() - ) - })?; - if actual != expected { - return Err(format!( - "package.json {field} mismatch in {}: expected {expected}, found {actual}", - package_json_path.display() - )); - } - Ok(()) -} - -fn require_bool_field( - json: &serde_json::Value, - package_json_path: &Path, - field: &'static str, - expected: bool, -) -> Result<(), String> { - let actual = json - .get(field) - .and_then(serde_json::Value::as_bool) - .ok_or_else(|| { - format!( - "package.json missing {field}: {}", - package_json_path.display() - ) - })?; - if actual != expected { - return Err(format!( - "package.json {field} mismatch in {}: expected {expected}, found {actual}", - package_json_path.display() - )); - } - Ok(()) -} - -fn check_publish_config(json: &serde_json::Value, package_json_path: &Path) -> Result<(), String> { - let access = json - .get("publishConfig") - .and_then(|value| value.get("access")) - .and_then(serde_json::Value::as_str) - .ok_or_else(|| { - format!( - "package.json missing publishConfig.access: {}", - package_json_path.display() - ) - })?; - if access != PUBLISH_ACCESS { - return Err(format!( - "package.json publishConfig.access mismatch in {}: expected {PUBLISH_ACCESS}, found {access}", - package_json_path.display() - )); - } - Ok(()) -} - -fn check_repository( - json: &serde_json::Value, - package_json_path: &Path, - expected_directory: &str, -) -> Result<(), String> { - let repository = json.get("repository").ok_or_else(|| { - format!( - "package.json missing repository: {}", - package_json_path.display() - ) - })?; - let repository_type = repository - .get("type") - .and_then(serde_json::Value::as_str) - .ok_or_else(|| { - format!( - "package.json missing repository.type: {}", - package_json_path.display() - ) - })?; - if repository_type != "git" { - return Err(format!( - "package.json repository.type mismatch in {}: expected git, found {repository_type}", - package_json_path.display() - )); - } - let repository_url = repository - .get("url") - .and_then(serde_json::Value::as_str) - .ok_or_else(|| { - format!( - "package.json missing repository.url: {}", - package_json_path.display() - ) - })?; - if repository_url != PACKAGE_REPOSITORY_URL { - return Err(format!( - "package.json repository.url mismatch in {}: expected {PACKAGE_REPOSITORY_URL}, found {repository_url}", - package_json_path.display() - )); - } - let repository_directory = repository - .get("directory") - .and_then(serde_json::Value::as_str) - .ok_or_else(|| { - format!( - "package.json missing repository.directory: {}", - package_json_path.display() - ) - })?; - if repository_directory != expected_directory { - return Err(format!( - "package.json repository.directory mismatch in {}: expected {expected_directory}, found {repository_directory}", - package_json_path.display() - )); - } - Ok(()) -} - -fn check_package_files(json: &serde_json::Value, package_json_path: &Path) -> Result<(), String> { - let files = json - .get("files") - .and_then(serde_json::Value::as_array) - .ok_or_else(|| { - format!( - "package.json missing files: {}", - package_json_path.display() - ) - })?; - let actual = files - .iter() - .map(|value| { - value.as_str().ok_or_else(|| { - format!( - "package.json files entries must be strings: {}", - package_json_path.display() - ) - }) - }) - .collect::<Result<Vec<_>, _>>()?; - if actual != PACKAGE_FILES { - return Err(format!( - "package.json files must publish dist plus approved metadata only: {}", - package_json_path.display() - )); - } - Ok(()) -} - -fn check_workspace_dependencies( - json: &serde_json::Value, - package_json_path: &Path, -) -> Result<(), String> { - let Some(dependencies) = json.get("dependencies") else { - return Ok(()); - }; - let dependencies = dependencies.as_object().ok_or_else(|| { - format!( - "package.json dependencies must be an object: {}", - package_json_path.display() - ) - })?; - for (name, value) in dependencies { - if name.starts_with("@radroots/") { - let version = value.as_str().ok_or_else(|| { - format!( - "package.json dependency versions must be strings: {}", - package_json_path.display() - ) - })?; - if version != "workspace:^" { - return Err(format!( - "package.json workspace dependency {name} must use workspace:^ in {}", - package_json_path.display() - )); - } - } - } - Ok(()) -} - -fn check_npm_pack_payloads(root: &Path) -> Result<(), String> { - let pack_dir = - tempfile::tempdir().map_err(|error| format!("failed to create pack temp dir: {error}"))?; - let mut packed_packages = Vec::new(); - for spec in package_specs() { - let package_dir = root.join(spec.package_dir); - let package_json_path = package_dir.join("package.json"); - let json = read_package_json_value(&package_json_path)?; - let expected_dist_files = - expected_packed_dist_files(&package_dir, &json, &package_json_path, None)?; - let required_files = required_npm_payload_files(&expected_dist_files); - let packed = pnpm_pack_package(&package_dir, spec.package_name, pack_dir.path())?; - let packed_json = read_packed_package_json(&packed, spec.package_name)?; - check_packed_package_json( - &json, - &packed_json, - &package_json_path, - spec.package_name, - spec.package_dir, - )?; - let payload_files = packed_payload_files(&packed)?; - validate_npm_pack_payload(spec.package_name, &payload_files, &required_files, None)?; - validate_packed_dist_inventory(spec.package_name, &payload_files, &expected_dist_files)?; - packed_packages.push(packed); - } - for spec in wasm_package_specs() { - let package_dir = root.join(spec.package_dir); - let package_json_path = package_dir.join("package.json"); - let json = read_package_json_value(&package_json_path)?; - let expected_dist_files = - expected_packed_dist_files(&package_dir, &json, &package_json_path, Some(*spec))?; - let required_files = required_npm_payload_files(&expected_dist_files); - let packed = pnpm_pack_package(&package_dir, spec.package_name, pack_dir.path())?; - let packed_json = read_packed_package_json(&packed, spec.package_name)?; - check_packed_package_json( - &json, - &packed_json, - &package_json_path, - spec.package_name, - spec.package_dir, - )?; - let payload_files = packed_payload_files(&packed)?; - validate_npm_pack_payload( - spec.package_name, - &payload_files, - &required_files, - Some(&format!("dist/{}_bg.wasm", spec.out_name)), - )?; - validate_packed_dist_inventory(spec.package_name, &payload_files, &expected_dist_files)?; - packed_packages.push(packed); - } - check_clean_consumer_smoke(&packed_packages)?; - Ok(()) -} - -fn check_no_pack_lifecycle_scripts( - json: &serde_json::Value, - package_json_path: &Path, -) -> Result<(), String> { - let Some(scripts) = json.get("scripts") else { - return Ok(()); - }; - let scripts = scripts.as_object().ok_or_else(|| { - format!( - "package.json scripts must be an object: {}", - package_json_path.display() - ) - })?; - for forbidden in [ - "prepack", - "postpack", - "prepare", - "prepublish", - "prepublishOnly", - ] { - if scripts.contains_key(forbidden) { - return Err(format!( - "package.json script {forbidden} is forbidden because pnpm pack runs lifecycle scripts: {}", - package_json_path.display() - )); - } - } - Ok(()) -} - -fn read_package_json_value(path: &Path) -> Result<serde_json::Value, String> { - let raw = fs::read_to_string(path) - .map_err(|error| format!("failed to read {}: {error}", path.display()))?; - serde_json::from_str::<serde_json::Value>(&raw) - .map_err(|error| format!("failed to parse {}: {error}", path.display())) -} - -fn required_npm_payload_files(expected_dist_files: &BTreeSet<String>) -> BTreeSet<String> { - let mut required = package_metadata_payload_files(); - required.extend(expected_dist_files.iter().cloned()); - required -} - -fn package_metadata_payload_files() -> BTreeSet<String> { - BTreeSet::from([ - "package.json".to_owned(), - "README.md".to_owned(), - "LICENSE-MIT".to_owned(), - "LICENSE-APACHE".to_owned(), - ]) -} - -fn expected_packed_dist_files( - package_dir: &Path, - json: &serde_json::Value, - package_json_path: &Path, - wasm_spec: Option<WasmPackageSpec>, -) -> Result<BTreeSet<String>, String> { - let mut expected = BTreeSet::new(); - for path in package_surface_paths(json, package_json_path)? { - expected.insert(normalized_package_path(&path)?); - } - if let Some(spec) = wasm_spec { - for path in wasm_runtime_files(spec) { - expected.insert(path); - } - expected.insert(format!("dist/{}.provenance.json", spec.out_name)); - } else { - expected.extend(expected_binding_generated_dist_files(package_dir)?); - } - Ok(expected) -} - -fn expected_binding_generated_dist_files(package_dir: &Path) -> Result<BTreeSet<String>, String> { - let generated_dir = package_dir.join("src/generated"); - let mut expected = BTreeSet::new(); - for source in generated_package_files(package_dir, &generated_dir)? { - let stem = source - .strip_prefix("src/generated/") - .and_then(|path| path.strip_suffix(".ts")) - .ok_or_else(|| { - format!( - "generated package source must be a TypeScript file under src/generated: {}", - package_dir.join(&source).display() - ) - })?; - expected.insert(format!("dist/generated/{stem}.js")); - expected.insert(format!("dist/generated/{stem}.d.ts")); - } - Ok(expected) -} - -fn pnpm_pack_package( - package_dir: &Path, - package_name: &str, - pack_destination: &Path, -) -> Result<PackedPackage, String> { - let output = Command::new("pnpm") - .args(["pack", "--json", "--pack-destination"]) - .arg(pack_destination) - .current_dir(package_dir) - .output() - .map_err(|error| { - format!( - "failed to run pnpm pack for {package_name} in {}: {error}", - package_dir.display() - ) - })?; - if !output.status.success() { - return Err(format!( - "pnpm pack failed for {package_name} in {}: {}", - package_dir.display(), - String::from_utf8_lossy(&output.stderr) - )); - } - let entry = parse_pnpm_pack_entry(package_name, &output.stdout, &output.stderr)?; - packed_package_from_pnpm_entry(package_name, pack_destination, entry) -} - -fn parse_pnpm_pack_entry( - package_name: &str, - stdout: &[u8], - stderr: &[u8], -) -> Result<PnpmPackEntry, String> { - serde_json::from_slice::<PnpmPackEntry>(stdout).map_err(|error| { - format!( - "failed to parse pnpm pack output for {package_name}: {error}; stdout: {}; stderr: {}", - String::from_utf8_lossy(stdout), - String::from_utf8_lossy(stderr) - ) - }) -} - -fn packed_package_from_pnpm_entry( - package_name: &str, - pack_destination: &Path, - entry: PnpmPackEntry, -) -> Result<PackedPackage, String> { - if entry.filename.trim().is_empty() { - return Err(format!( - "pnpm pack output for {package_name} is missing tarball filename" - )); - } - let tarball_path = PathBuf::from(&entry.filename); - if !tarball_path.starts_with(pack_destination) { - return Err(format!( - "pnpm pack tarball for {package_name} must be written under {}: {}", - pack_destination.display(), - tarball_path.display() - )); - } - if !tarball_path.is_file() { - return Err(format!( - "pnpm pack tarball for {package_name} does not exist: {}", - tarball_path.display() - )); - } - Ok(PackedPackage { - package_name: package_name.to_owned(), - tarball_path, - files: entry - .files - .into_iter() - .map(|file| NpmPackFile { path: file.path }) - .collect(), - }) -} - -fn check_clean_consumer_smoke(packed_packages: &[PackedPackage]) -> Result<(), String> { - let consumer_dir = tempfile::tempdir() - .map_err(|error| format!("failed to create clean npm consumer temp dir: {error}"))?; - fs::write( - consumer_dir.path().join("package.json"), - "{\n \"name\": \"radroots-sdk-package-smoke\",\n \"private\": true,\n \"type\": \"module\"\n}\n", - ) - .map_err(|error| { - format!( - "failed to write clean npm consumer package.json in {}: {error}", - consumer_dir.path().display() - ) - })?; - let npm_cache_dir = consumer_dir.path().join(".npm-cache"); - let mut install = Command::new("npm"); - install - .args([ - "install", - "--ignore-scripts", - "--no-audit", - "--no-fund", - "--registry", - "http://127.0.0.1:9", - "--cache", - ]) - .arg(&npm_cache_dir); - for packed in packed_packages { - install.arg(&packed.tarball_path); - } - let output = install - .current_dir(consumer_dir.path()) - .output() - .map_err(|error| { - format!( - "failed to run clean npm consumer install in {}: {error}", - consumer_dir.path().display() - ) - })?; - if !output.status.success() { - return Err(format!( - "clean npm consumer install failed in {}: stdout: {}; stderr: {}", - consumer_dir.path().display(), - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr) - )); - } - let package_names = packed_packages - .iter() - .map(|package| package.package_name.clone()) - .collect::<Vec<_>>(); - let smoke_script = consumer_smoke_script(&package_names)?; - fs::write(consumer_dir.path().join("smoke.mjs"), smoke_script).map_err(|error| { - format!( - "failed to write clean npm consumer smoke script in {}: {error}", - consumer_dir.path().display() - ) - })?; - let output = Command::new("node") - .arg("smoke.mjs") - .current_dir(consumer_dir.path()) - .output() - .map_err(|error| { - format!( - "failed to run clean npm consumer import smoke in {}: {error}", - consumer_dir.path().display() - ) - })?; - if !output.status.success() { - return Err(format!( - "clean npm consumer import smoke failed in {}: stdout: {}; stderr: {}", - consumer_dir.path().display(), - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr) - )); - } - Ok(()) -} - -fn consumer_smoke_script(package_names: &[String]) -> Result<String, String> { - let mut script = String::new(); - for package_name in package_names { - let quoted = serde_json::to_string(package_name) - .map_err(|error| format!("failed to quote package name {package_name}: {error}"))?; - script.push_str("await import("); - script.push_str(&quoted); - script.push_str(");\n"); - } - Ok(script) -} - -fn packed_payload_files(packed: &PackedPackage) -> Result<BTreeSet<String>, String> { - debug_assert!(packed.tarball_path.is_file()); - packed - .files - .iter() - .map(|file| normalized_package_path(&file.path)) - .collect() -} - -fn read_packed_package_json( - packed: &PackedPackage, - package_name: &str, -) -> Result<serde_json::Value, String> { - let file = fs::File::open(&packed.tarball_path).map_err(|error| { - format!( - "failed to open pnpm pack tarball for {package_name}: {}: {error}", - packed.tarball_path.display() - ) - })?; - let decoder = GzDecoder::new(file); - let mut archive = Archive::new(decoder); - let entries = archive.entries().map_err(|error| { - format!( - "failed to read pnpm pack tarball entries for {package_name}: {}: {error}", - packed.tarball_path.display() - ) - })?; - for entry in entries { - let mut entry = entry.map_err(|error| { - format!( - "failed to read pnpm pack tarball entry for {package_name}: {}: {error}", - packed.tarball_path.display() - ) - })?; - let path = entry.path().map_err(|error| { - format!( - "failed to read pnpm pack tarball entry path for {package_name}: {}: {error}", - packed.tarball_path.display() - ) - })?; - if path.as_ref() != Path::new("package/package.json") { - continue; - } - let mut raw = String::new(); - entry.read_to_string(&mut raw).map_err(|error| { - format!( - "failed to read packed package.json for {package_name}: {}: {error}", - packed.tarball_path.display() - ) - })?; - return serde_json::from_str::<serde_json::Value>(&raw).map_err(|error| { - format!( - "failed to parse packed package.json for {package_name}: {}: {error}", - packed.tarball_path.display() - ) - }); - } - Err(format!( - "pnpm pack tarball for {package_name} is missing package/package.json: {}", - packed.tarball_path.display() - )) -} - -fn check_packed_package_json( - source_json: &serde_json::Value, - packed_json: &serde_json::Value, - package_json_path: &Path, - expected_name: &str, - expected_directory: &str, -) -> Result<(), String> { - let source_description = package_description(source_json, package_json_path)?; - let packed_description = package_description(packed_json, package_json_path)?; - if packed_description != source_description { - return Err(format!( - "packed package.json description mismatch in {}: expected {source_description}, found {packed_description}", - package_json_path.display() - )); - } - require_string_field(packed_json, package_json_path, "name", expected_name)?; - require_string_field(packed_json, package_json_path, "version", PACKAGE_VERSION)?; - require_string_field(packed_json, package_json_path, "license", PACKAGE_LICENSE)?; - require_string_field(packed_json, package_json_path, "homepage", PACKAGE_HOMEPAGE)?; - require_string_field(packed_json, package_json_path, "type", "module")?; - require_bool_field(packed_json, package_json_path, "sideEffects", false)?; - check_publish_config(packed_json, package_json_path)?; - check_repository(packed_json, package_json_path, expected_directory)?; - check_package_files(packed_json, package_json_path)?; - check_no_pack_lifecycle_scripts(packed_json, package_json_path)?; - check_same_packed_field(source_json, packed_json, package_json_path, "main")?; - check_same_packed_field(source_json, packed_json, package_json_path, "types")?; - check_same_packed_field(source_json, packed_json, package_json_path, "exports")?; - check_same_packed_field(source_json, packed_json, package_json_path, "scripts")?; - check_packed_dependency_maps(source_json, packed_json, package_json_path)?; - let source_surface = package_surface_paths(source_json, package_json_path)?; - let packed_surface = package_surface_paths(packed_json, package_json_path)?; - if packed_surface != source_surface { - return Err(format!( - "packed package.json export surface mismatch in {}", - package_json_path.display() - )); - } - Ok(()) -} - -fn check_same_packed_field( - source_json: &serde_json::Value, - packed_json: &serde_json::Value, - package_json_path: &Path, - field: &'static str, -) -> Result<(), String> { - if source_json.get(field) != packed_json.get(field) { - return Err(format!( - "packed package.json {field} mismatch in {}", - package_json_path.display() - )); - } - Ok(()) -} - -fn check_packed_dependency_maps( - source_json: &serde_json::Value, - packed_json: &serde_json::Value, - package_json_path: &Path, -) -> Result<(), String> { - for field in ["dependencies", "peerDependencies", "optionalDependencies"] { - check_packed_dependency_map(source_json, packed_json, package_json_path, field)?; - } - Ok(()) -} - -fn check_packed_dependency_map( - source_json: &serde_json::Value, - packed_json: &serde_json::Value, - package_json_path: &Path, - field: &'static str, -) -> Result<(), String> { - let source_dependencies = optional_dependency_map(source_json, package_json_path, field)?; - let packed_dependencies = optional_dependency_map(packed_json, package_json_path, field)?; - let source_keys = source_dependencies - .iter() - .flat_map(|dependencies| dependencies.keys().cloned()) - .collect::<BTreeSet<_>>(); - let packed_keys = packed_dependencies - .iter() - .flat_map(|dependencies| dependencies.keys().cloned()) - .collect::<BTreeSet<_>>(); - if packed_keys != source_keys { - return Err(format!( - "packed package.json {field} keys mismatch in {}: expected {:?}, found {:?}", - package_json_path.display(), - source_keys, - packed_keys - )); - } - let Some(packed_dependencies) = packed_dependencies else { - return Ok(()); - }; - let source_dependencies = source_dependencies.expect("matching dependency keys require source"); - for (name, packed_version) in packed_dependencies { - let packed_version = packed_version.as_str().ok_or_else(|| { - format!( - "packed package.json {field} dependency versions must be strings in {}", - package_json_path.display() - ) - })?; - if packed_version.starts_with("workspace:") { - return Err(format!( - "packed package.json {field} dependency {name} must not use workspace protocol in {}", - package_json_path.display() - )); - } - let source_version = source_dependencies - .get(name) - .and_then(serde_json::Value::as_str) - .ok_or_else(|| { - format!( - "source package.json {field} dependency {name} must be a string in {}", - package_json_path.display() - ) - })?; - let expected = expected_packed_dependency_version(name, source_version); - if packed_version != expected { - return Err(format!( - "packed package.json {field} dependency {name} mismatch in {}: expected {expected}, found {packed_version}", - package_json_path.display() - )); - } - } - Ok(()) -} - -fn optional_dependency_map<'a>( - json: &'a serde_json::Value, - package_json_path: &Path, - field: &'static str, -) -> Result<Option<&'a serde_json::Map<String, serde_json::Value>>, String> { - json.get(field) - .map(|value| { - value.as_object().ok_or_else(|| { - format!( - "package.json {field} must be an object: {}", - package_json_path.display() - ) - }) - }) - .transpose() -} - -fn expected_packed_dependency_version(name: &str, source_version: &str) -> String { - if name.starts_with("@radroots/") && source_version == "workspace:^" { - format!("^{PACKAGE_VERSION}") - } else { - source_version.to_owned() - } -} - -fn validate_npm_pack_payload( - package_name: &str, - payload_files: &BTreeSet<String>, - required_files: &BTreeSet<String>, - expected_wasm_file: Option<&str>, -) -> Result<(), String> { - let missing = required_files - .difference(payload_files) - .cloned() - .collect::<Vec<_>>(); - if !missing.is_empty() { - return Err(format!( - "npm pack payload for {package_name} is missing required files: {missing:?}" - )); - } - for path in payload_files { - if let Some(reason) = forbidden_npm_payload_path(path) { - return Err(format!( - "npm pack payload for {package_name} includes forbidden {reason}: {path}" - )); - } - } - if let Some(expected_wasm_file) = expected_wasm_file { - let wasm_files = payload_files - .iter() - .filter(|path| path.ends_with(".wasm")) - .cloned() - .collect::<Vec<_>>(); - if wasm_files != [expected_wasm_file.to_owned()] { - return Err(format!( - "npm pack payload for {package_name} must include exactly {expected_wasm_file}; found {wasm_files:?}" - )); - } - } - Ok(()) -} - -fn validate_packed_dist_inventory( - package_name: &str, - payload_files: &BTreeSet<String>, - expected_dist_files: &BTreeSet<String>, -) -> Result<(), String> { - let actual_dist_files = payload_files - .iter() - .filter(|path| path.starts_with("dist/")) - .cloned() - .collect::<BTreeSet<_>>(); - if &actual_dist_files != expected_dist_files { - let missing = expected_dist_files - .difference(&actual_dist_files) - .cloned() - .collect::<Vec<_>>(); - let extra = actual_dist_files - .difference(expected_dist_files) - .cloned() - .collect::<Vec<_>>(); - return Err(format!( - "npm pack payload for {package_name} has dist inventory mismatch: missing {:?}, extra {:?}", - missing, extra - )); - } - Ok(()) -} - -fn normalized_package_path(path: &str) -> Result<String, String> { - let normalized = path.trim_start_matches("./"); - if normalized.is_empty() - || normalized.starts_with('/') - || normalized.contains('\\') - || normalized - .split('/') - .any(|segment| segment.is_empty() || segment == "." || segment == "..") - { - return Err(format!("invalid npm pack payload path: {path}")); - } - Ok(normalized.to_owned()) -} - -fn forbidden_npm_payload_path(path: &str) -> Option<&'static str> { - if path.starts_with("src/") { - return Some("source path"); - } - if path.starts_with("contracts/") { - return Some("contract path"); - } - if path.contains("sdk-manifest") { - return Some("manifest provenance path"); - } - if path.ends_with(".tsbuildinfo") { - return Some("TypeScript build info path"); - } - if path - .split('/') - .any(|segment| matches!(segment, ".gitignore" | ".npmignore")) - { - return Some("ignore file"); - } - None -} - -fn check_public_wasm_declaration_inventory( - surface_paths: &BTreeSet<String>, - spec: WasmPackageSpec, -) -> Result<(), String> { - let actual = surface_paths - .iter() - .filter(|path| path.ends_with(".d.ts")) - .cloned() - .collect::<BTreeSet<_>>(); - let expected = BTreeSet::from([format!("./dist/{}.d.ts", spec.out_name)]); - if actual != expected { - return Err(format!( - "public wasm declaration inventory mismatch for {}: expected {:?}, found {:?}", - spec.package_name, expected, actual - )); - } - Ok(()) -} - -fn check_no_wasm_dist_ignore_files( - package_dir: &Path, - spec: WasmPackageSpec, -) -> Result<(), String> { - for file_name in [".gitignore", ".npmignore"] { - let path = package_dir.join("dist").join(file_name); - if path.exists() { - return Err(format!( - "wasm dist ignore file would hide package payload from npm for {}: {}", - spec.package_name, - path.display() - )); - } - } - Ok(()) -} - -fn check_wasm_runtime_files(package_dir: &Path, spec: WasmPackageSpec) -> Result<(), String> { - for relative in wasm_runtime_files(spec) { - let path = package_dir.join(&relative); - if !path.is_file() { - return Err(format!( - "missing wasm package runtime artifact for {}: {}", - spec.package_name, - path.display() - )); - } - } - Ok(()) -} - -fn wasm_runtime_files(spec: WasmPackageSpec) -> [String; 3] { - [ - format!("dist/{}.js", spec.out_name), - format!("dist/{}_bg.wasm", spec.out_name), - format!("dist/{}_bg.wasm.d.ts", spec.out_name), - ] -} - -fn check_wasm_declaration_files(package_dir: &Path, spec: WasmPackageSpec) -> Result<(), String> { - for expected in declaration_files(spec)? { - let path = package_dir.join(&expected.relative_path); - let actual = fs::read_to_string(&path) - .map_err(|error| format!("failed to read {}: {error}", path.display()))?; - if actual != expected.contents { - return Err(format!( - "stale dto_bindgen wasm declaration: {}", - path.display() - )); - } - } - Ok(()) -} - -fn package_surface_paths( - json: &serde_json::Value, - package_json_path: &Path, -) -> Result<BTreeSet<String>, String> { - let mut paths = BTreeSet::new(); - collect_required_package_path(json, package_json_path, "main", &mut paths)?; - collect_required_package_path(json, package_json_path, "types", &mut paths)?; - let exports = json.get("exports").ok_or_else(|| { - format!( - "package.json missing exports: {}", - package_json_path.display() - ) - })?; - match exports { - serde_json::Value::String(path) => { - validate_package_surface_path(path, package_json_path, "exports")?; - paths.insert(path.clone()); - } - serde_json::Value::Object(map) => { - if map.keys().any(|key| key != ".") { - return Err(format!( - "package.json only supports root exports: {}", - package_json_path.display() - )); - } - let root_export = map.get(".").ok_or_else(|| { - format!( - "package.json missing root export: {}", - package_json_path.display() - ) - })?; - collect_export_paths(root_export, package_json_path, "exports[\".\"]", &mut paths)?; - } - _ => { - return Err(format!( - "package.json exports must be a string or object: {}", - package_json_path.display() - )); - } - } - Ok(paths) -} - -fn collect_required_package_path( - json: &serde_json::Value, - package_json_path: &Path, - field: &'static str, - paths: &mut BTreeSet<String>, -) -> Result<(), String> { - let value = json - .get(field) - .and_then(serde_json::Value::as_str) - .ok_or_else(|| { - format!( - "package.json missing {field}: {}", - package_json_path.display() - ) - })?; - validate_package_surface_path(value, package_json_path, field)?; - paths.insert(value.to_owned()); - Ok(()) -} - -fn collect_export_paths( - value: &serde_json::Value, - package_json_path: &Path, - field: &str, - paths: &mut BTreeSet<String>, -) -> Result<(), String> { - match value { - serde_json::Value::String(path) => { - validate_package_surface_path(path, package_json_path, field)?; - paths.insert(path.clone()); - Ok(()) - } - serde_json::Value::Object(map) => { - for (key, value) in map { - collect_export_paths(value, package_json_path, &format!("{field}.{key}"), paths)?; - } - Ok(()) - } - _ => Err(format!( - "package.json {field} must name file paths: {}", - package_json_path.display() - )), - } -} - -fn validate_package_surface_path( - value: &str, - package_json_path: &Path, - field: &str, -) -> Result<(), String> { - if value.trim().is_empty() - || value.trim() != value - || !value.starts_with("./dist/") - || value.contains('\\') - || value.split('/').any(|segment| segment == "..") - { - return Err(format!( - "package.json {field} must be a relative dist path: {}", - package_json_path.display() - )); - } - Ok(()) -} - -fn check_package_surface_artifacts( - package_dir: &Path, - package_name: &str, - surface_paths: &BTreeSet<String>, -) -> Result<(), String> { - for relative in surface_paths { - let normalized = relative.trim_start_matches("./"); - let path = package_dir.join(normalized); - if !path.is_file() { - return Err(format!( - "missing package export artifact for {package_name}: {}", - path.display() - )); - } - } - Ok(()) -} - -#[cfg(test)] -mod tests { - use std::{ - collections::BTreeSet, - fs, - path::{Path, PathBuf}, - time::{SystemTime, UNIX_EPOCH}, - }; - - use flate2::{Compression, write::GzEncoder}; - - use crate::{ - output::package_outputs, - package_matrix::{WasmPackageSpec, validate_package_matrix}, - package_metadata::package_readme, - }; - - use super::{ - PackedPackage, check_binding_crate_sources, check_generated_package_artifact_inventory, - check_manifest_reticulum_dependency_boundaries, check_no_typescript_files, - check_package_distribution_metadata, check_package_index, check_package_json, - check_package_surface_artifacts, check_packed_package_json, check_publication_policy, - check_reticulum_feature_entry, check_wasm_package_surface, consumer_smoke_script, - expected_packed_dist_files, is_forbidden_reticulum_runtime_name, normalized_package_path, - parse_pnpm_pack_entry, read_packed_package_json, validate_npm_pack_payload, - validate_packed_dist_inventory, - }; - - const APPROVED_CRATES: [&str; 19] = [ - "radroots_core", - "radroots_identity", - "radroots_blossom", - "radroots_protocol", - "radroots_event", - "radroots_event_codec", - "radroots_trade", - "radroots_signing", - "radroots_transport", - "radroots_nostr", - "radroots_nostr_connect", - "radroots_secrets", - "radroots_storage", - "radroots_storage_sqlite", - "radroots_transport_nostr", - "radroots_sync", - "radroots_geonames", - "radroots_sdk", - "radroots", - ]; - - fn toml_strings(values: &[&str]) -> String { - values - .iter() - .map(|value| format!("\"{value}\"")) - .collect::<Vec<_>>() - .join(", ") - } - - fn write_publication_architecture(root: &Path) { - fs::create_dir_all(root.join("docs/specs")).expect("create spec directory"); - let mut architecture = format!( - "spec_id = \"radroots.crates.release.v1\"\npackage_count = 19\n\n[repositories.lib]\nversion = \"0.1.0-alpha\"\npackages = [{}]\n\n[repositories.sdk]\nversion = \"0.1.0\"\npackages = [{}]\n", - toml_strings(&APPROVED_CRATES[..17]), - toml_strings(&APPROVED_CRATES[17..]), - ); - for name in APPROVED_CRATES { - architecture.push_str(&format!("\n[[package]]\nname = \"{name}\"\n")); - } - fs::write( - root.join("docs/specs/radroots_crates_release_v1.toml"), - architecture, - ) - .expect("write release architecture"); - } - - fn publication_policy( - frozen: bool, - approved: &[&str], - private: &[&str], - build_codegen: &[&str], - ) -> String { - let publish_order = if frozen { - String::new() - } else { - "\n[publish_order]\ncrates = [\"radroots_sdk\", \"radroots\"]\n".to_owned() - }; - format!( - r#"[publication] -frozen = {frozen} -registry = "crates-io" -final_enablement_step = 305 -spec_id = "radroots.crates.release.v1" -approved_packages = [{}] -local_packages = ["radroots_sdk", "radroots"] -external_packages = [{}] - -[workspace_classification] -private = [{}] -build_codegen = [{}] -test_support = [] -preview = [] -retired = [] -{publish_order} -"#, - toml_strings(approved), - toml_strings(&APPROVED_CRATES[..17]), - toml_strings(private), - toml_strings(build_codegen), - ) - } - - #[test] - fn package_skeleton_is_valid() { - validate_package_matrix().expect("package matrix validates"); - } - - #[test] - fn publication_freeze_rejects_publishable_or_implicit_packages() { - let root = test_root("publication_freeze"); - fs::create_dir_all(root.join("contracts/releases")).expect("create contracts"); - write_publication_architecture(&root); - fs::create_dir_all(root.join("crates/a")).expect("create crate a"); - fs::create_dir_all(root.join("crates/b")).expect("create crate b"); - fs::write( - root.join("Cargo.toml"), - "[workspace]\nmembers = [\"crates/a\", \"crates/b\"]\n", - ) - .expect("write workspace"); - fs::write( - root.join("contracts/releases/publication.toml"), - publication_policy(true, &APPROVED_CRATES, &["crate-a", "crate-b"], &[]), - ) - .expect("write policy"); - fs::write( - root.join("crates/a/Cargo.toml"), - "[package]\nname = \"crate-a\"\nversion = \"0.1.0\"\npublish = false\n", - ) - .expect("write crate a"); - fs::write( - root.join("crates/b/Cargo.toml"), - "[package]\nname = \"crate-b\"\nversion = \"0.1.0\"\npublish = false\n", - ) - .expect("write crate b"); - check_publication_policy(&root).expect("private workspace satisfies freeze"); - - fs::write( - root.join("crates/a/Cargo.toml"), - "[package]\nname = \"crate-a\"\nversion = \"0.1.0\"\npublish = [\"crates-io\"]\n", - ) - .expect("make crate a publishable"); - let publishable = - check_publication_policy(&root).expect_err("publishable crate must fail freeze"); - assert!(publishable.contains("publication freeze requires workspace package crate-a")); - - fs::write( - root.join("crates/a/Cargo.toml"), - "[package]\nname = \"crate-a\"\nversion = \"0.1.0\"\n", - ) - .expect("remove publish control"); - let implicit = - check_publication_policy(&root).expect_err("implicit publishability must fail freeze"); - assert!(implicit.contains("publication freeze requires workspace package crate-a")); - - fs::write( - root.join("crates/a/Cargo.toml"), - "[package]\nname = \"crate-a\"\nversion = \"0.1.0\"\npublish = false\n", - ) - .expect("restore private crate a"); - let mut unapproved = APPROVED_CRATES.to_vec(); - unapproved.push("unapproved-public"); - fs::write( - root.join("contracts/releases/publication.toml"), - publication_policy(true, &unapproved, &["crate-a", "crate-b"], &[]), - ) - .expect("write unapproved policy"); - let unapproved_error = check_publication_policy(&root) - .expect_err("unapproved public package must fail exact catalog validation"); - assert!(unapproved_error.contains("unapproved packages: unapproved-public")); - - fs::write( - root.join("contracts/releases/publication.toml"), - publication_policy(true, &APPROVED_CRATES, &["crate-a"], &[]), - ) - .expect("write unclassified policy"); - let unclassified = - check_publication_policy(&root).expect_err("unclassified workspace package must fail"); - assert!(unclassified.contains("workspace classification is missing packages: crate-b")); - - let _ = fs::remove_dir_all(root); - } - - #[test] - fn publication_enablement_allows_only_the_two_approved_packages() { - let root = test_root("publication_enablement"); - fs::create_dir_all(root.join("contracts/releases")).expect("create contracts"); - write_publication_architecture(&root); - for member in ["sdk", "facade", "tool"] { - fs::create_dir_all(root.join("crates").join(member)).expect("create member"); - } - fs::write( - root.join("Cargo.toml"), - "[workspace]\nmembers = [\"crates/sdk\", \"crates/facade\", \"crates/tool\"]\n", - ) - .expect("write workspace"); - fs::write( - root.join("contracts/releases/publication.toml"), - publication_policy(false, &APPROVED_CRATES, &[], &["build-tool"]), - ) - .expect("write policy"); - fs::write( - root.join("crates/sdk/Cargo.toml"), - "[package]\nname = \"radroots_sdk\"\nversion = \"0.1.0\"\npublish = [\"crates-io\"]\n", - ) - .expect("write SDK manifest"); - fs::write( - root.join("crates/facade/Cargo.toml"), - "[package]\nname = \"radroots\"\nversion = \"0.1.0\"\npublish = [\"crates-io\"]\n", - ) - .expect("write facade manifest"); - fs::write( - root.join("crates/tool/Cargo.toml"), - "[package]\nname = \"build-tool\"\nversion = \"0.1.0\"\npublish = false\n", - ) - .expect("write tool manifest"); - check_publication_policy(&root).expect("approved package staging should pass"); - - fs::write( - root.join("crates/tool/Cargo.toml"), - "[package]\nname = \"build-tool\"\nversion = \"0.1.0\"\npublish = [\"crates-io\"]\n", - ) - .expect("make tool publishable"); - let extra = check_publication_policy(&root) - .expect_err("non-approved publishable package must fail enablement"); - assert!(extra.contains("non-approved workspace package build-tool")); - - let _ = fs::remove_dir_all(root); - } - - #[test] - fn reticulum_gate_allows_first_party_preview_dependency_only() { - let manifest = manifest_value( - r#" -[package] -name = "radroots_sdk" - -[features] -runtime = ["dep:radroots_transport_reticulum"] - -[dependencies] -radroots_transport_reticulum = { workspace = true, optional = true, default-features = false } -"#, - ); - - check_manifest_reticulum_dependency_boundaries(&manifest, "crates/sdk/Cargo.toml") - .expect("first-party preview dependency is allowed"); - } - - #[test] - fn reticulum_gate_ignores_docs_rs_feature_selection() { - let manifest = manifest_value( - r#" -[package] -name = "radroots_sdk" - -[package.metadata.docs.rs] -features = ["nostr", "nip46"] - -[features] -nostr = [] -nip46 = ["nostr"] -"#, - ); - - check_manifest_reticulum_dependency_boundaries(&manifest, "crates/sdk/Cargo.toml") - .expect("docs.rs feature arrays are metadata, not Cargo feature tables"); - } - - #[test] - fn reticulum_gate_rejects_workspace_package_alias_to_real_runtime() { - let manifest = manifest_value( - r#" -[workspace.dependencies] -rr_mesh = { package = "reticulum", version = "1" } -"#, - ); - - let error = check_manifest_reticulum_dependency_boundaries(&manifest, "Cargo.toml") - .expect_err("Reticulum package alias is rejected"); - - assert!(error.contains("reticulum")); - assert!(error.contains("workspace.dependencies")); - } - - #[test] - fn reticulum_gate_rejects_build_dependency_alias_to_python_binding() { - let manifest = manifest_value( - r#" -[package] -name = "radroots_sdk" - -[build-dependencies] -ffi_bridge = { package = "pyo3", version = "0.24" } -"#, - ); - - let error = - check_manifest_reticulum_dependency_boundaries(&manifest, "crates/sdk/Cargo.toml") - .expect_err("Python runtime binding package alias is rejected"); - - assert!(error.contains("pyo3")); - assert!(error.contains("build-dependencies")); - } - - #[test] - fn reticulum_gate_rejects_reticulum_feature_aliases_and_runtime_features() { - let alias_manifest = manifest_value( - r#" -[package] -name = "radroots_sdk" - -[features] -transport-reticulum-preview = [] -"#, - ); - let alias_error = check_manifest_reticulum_dependency_boundaries( - &alias_manifest, - "crates/sdk/Cargo.toml", - ) - .expect_err("Reticulum feature alias is rejected"); - - assert!(alias_error.contains("transport-reticulum-preview")); - - let entry_error = check_reticulum_feature_entry( - "radroots_transport_reticulum/client", - "crates/sdk/Cargo.toml", - "runtime", - ) - .expect_err("Reticulum preview crate runtime feature is rejected"); - - assert!(entry_error.contains("radroots_transport_reticulum/client")); - } - - #[test] - fn reticulum_gate_package_name_classifier_is_runtime_scoped() { - for name in [ - "reticulum", - "reticulum-rs", - "rns", - "rns-client", - "python", - "python-runtime", - "pyo3", - "pyo3-ffi", - "radroots-transport-reticulum", - ] { - assert!( - is_forbidden_reticulum_runtime_name(name), - "{name} must be forbidden" - ); - } - for name in ["radroots_transport_reticulum", "dto_bindgen_backend_python"] { - assert!( - !is_forbidden_reticulum_runtime_name(name), - "{name} must be allowed" - ); - } - } - - #[test] - fn rejects_crate_typescript_directories() { - let root = test_root("typescript_dir"); - let typescript_dir = root - .join("crates") - .join("core_bindings") - .join("src") - .join("typescript"); - fs::create_dir_all(&typescript_dir).expect("create forbidden directory"); - - let error = check_binding_crate_sources(&root).expect_err("forbidden directory rejected"); - - assert!(error.contains("forbidden crate TypeScript source directory")); - let _ = fs::remove_dir_all(root); - } - - #[test] - fn rejects_crate_typescript_files() { - let root = test_root("typescript_file"); - let src_dir = root.join("crates/core_bindings/src"); - fs::create_dir_all(&src_dir).expect("create crate source directory"); - fs::write(src_dir.join("types.ts"), "export type A = string;\n") - .expect("write forbidden file"); - - let error = check_no_typescript_files(&src_dir).expect_err("forbidden file rejected"); - - assert!(error.contains("forbidden crate TypeScript source file")); - let _ = fs::remove_dir_all(root); - } - - #[test] - fn rejects_generated_package_index_source() { - let root = test_root("generated_index"); - let path = root.join("src/index.ts"); - fs::create_dir_all(path.parent().expect("parent")).expect("create source directory"); - fs::write( - &path, - "// @generated by cargo xtask generate ts\n// Do not edit by hand.\nexport {};\n", - ) - .expect("write generated index"); - - let error = check_package_index(&path).expect_err("generated index rejected"); - - assert!(error.contains("package index must be handwritten source")); - let _ = fs::remove_dir_all(root); - } - - #[test] - fn generated_package_artifact_inventory_rejects_extra_files() { - let root = test_root("generated_inventory_extra"); - let output = package_outputs() - .expect("package outputs") - .into_iter() - .find(|output| output.spec.key == "core") - .expect("core output"); - let package_dir = root.join(output.spec.package_dir); - fs::create_dir_all(package_dir.join("src/generated")).expect("create generated dir"); - for file in output.files() { - let path = package_dir.join(file.relative_path); - fs::write(path, file.contents).expect("write expected file"); - } - fs::write( - package_dir.join("src/generated").join("extra.ts"), - "export type Extra = string;\n", - ) - .expect("write extra file"); - - let error = check_generated_package_artifact_inventory(&root, &output) - .expect_err("extra generated file rejected"); - - assert!(error.contains("generated artifact inventory mismatch")); - assert!(error.contains("extra.ts")); - let _ = fs::remove_dir_all(root); - } - - #[test] - fn public_package_metadata_rejects_private_packages() { - let root = test_root("private_package_json"); - let package_dir = root.join("packages").join("example"); - fs::create_dir_all(&package_dir).expect("create package"); - let package_json = package_json("example").replace( - r#""sideEffects": false,"#, - r#""private": true, "sideEffects": false,"#, - ); - fs::write(package_dir.join("package.json"), package_json).expect("write package json"); - - let error = check_package_json( - &package_dir.join("package.json"), - "@radroots/example", - "packages/example", - ) - .expect_err("private package rejected"); - - assert!(error.contains("must not set private")); - let _ = fs::remove_dir_all(root); - } - - #[test] - fn public_package_metadata_rejects_src_generated_payloads() { - let root = test_root("src_generated_package_payload"); - let package_dir = root.join("packages").join("example"); - fs::create_dir_all(&package_dir).expect("create package"); - let package_json = package_json("example").replace( - r#""files": ["dist", "README.md", "LICENSE-MIT", "LICENSE-APACHE"]"#, - r#""files": ["dist", "README.md", "LICENSE-MIT", "LICENSE-APACHE", "src/generated"]"#, - ); - fs::write(package_dir.join("package.json"), package_json).expect("write package json"); - - let error = check_package_json( - &package_dir.join("package.json"), - "@radroots/example", - "packages/example", - ) - .expect_err("src generated package payload rejected"); - - assert!(error.contains("files must publish dist plus approved metadata only")); - let _ = fs::remove_dir_all(root); - } - - #[test] - fn public_package_metadata_rejects_pack_lifecycle_scripts() { - let root = test_root("pack_lifecycle_scripts"); - let package_dir = root.join("packages").join("example"); - fs::create_dir_all(&package_dir).expect("create package"); - let package_json = package_json("example").replace( - r#""type": "module","#, - r#""scripts": {"prepack": "echo forbidden"}, "type": "module","#, - ); - fs::write(package_dir.join("package.json"), package_json).expect("write package json"); - - let error = check_package_json( - &package_dir.join("package.json"), - "@radroots/example", - "packages/example", - ) - .expect_err("pack lifecycle script rejected"); - - assert!(error.contains("script prepack is forbidden")); - let _ = fs::remove_dir_all(root); - } - - #[test] - fn package_distribution_metadata_matches_root_license_files() { - let root = test_root("package_distribution_metadata"); - let package_dir = root.join("packages").join("example"); - fs::create_dir_all(&package_dir).expect("create package"); - fs::write(root.join("LICENSE-MIT"), "MIT license\n").expect("write MIT license"); - fs::write(root.join("LICENSE-APACHE"), "Apache license\n").expect("write Apache license"); - fs::write(package_dir.join("package.json"), package_json("example")) - .expect("write package json"); - fs::write( - package_dir.join("README.md"), - package_readme("@radroots/example", "Example package"), - ) - .expect("write readme"); - fs::write(package_dir.join("LICENSE-MIT"), "MIT license\n") - .expect("write package MIT license"); - fs::write(package_dir.join("LICENSE-APACHE"), "Apache license\n") - .expect("write package Apache license"); - let json = check_package_json( - &package_dir.join("package.json"), - "@radroots/example", - "packages/example", - ) - .expect("valid package json"); - - check_package_distribution_metadata( - &root, - &package_dir, - &package_dir.join("package.json"), - &json, - ) - .expect("metadata matches"); - - fs::write(package_dir.join("README.md"), "stale\n").expect("stale readme"); - let error = check_package_distribution_metadata( - &root, - &package_dir, - &package_dir.join("package.json"), - &json, - ) - .expect_err("stale readme rejected"); - assert!(error.contains("stale package README")); - - fs::write( - package_dir.join("README.md"), - package_readme("@radroots/example", "Example package"), - ) - .expect("restore readme"); - fs::write(package_dir.join("LICENSE-MIT"), "stale\n").expect("stale license"); - let error = check_package_distribution_metadata( - &root, - &package_dir, - &package_dir.join("package.json"), - &json, - ) - .expect_err("stale license rejected"); - assert!(error.contains("stale package license metadata")); - let _ = fs::remove_dir_all(root); - } - - #[test] - fn package_surface_artifacts_require_dist_files() { - let root = test_root("package_surface_artifacts"); - let package_dir = root.join("packages").join("example"); - fs::create_dir_all(package_dir.join("dist")).expect("create dist"); - fs::write(package_dir.join("dist").join("index.js"), "export {};\n").expect("write js"); - let surface_paths = - BTreeSet::from(["./dist/index.js".to_owned(), "./dist/index.d.ts".to_owned()]); - - let error = - check_package_surface_artifacts(&package_dir, "@radroots/example", &surface_paths) - .expect_err("missing declaration should fail"); - assert!(error.contains("missing package export artifact")); - assert!(error.contains("index.d.ts")); - - fs::write(package_dir.join("dist").join("index.d.ts"), "export {};\n").expect("write d.ts"); - check_package_surface_artifacts(&package_dir, "@radroots/example", &surface_paths) - .expect("surface artifacts present"); - - let _ = fs::remove_dir_all(root); - } - - #[test] - fn binding_dist_inventory_includes_generated_js_and_declarations() { - let root = test_root("binding_dist_inventory"); - let package_dir = root.join("packages").join("example"); - fs::create_dir_all(package_dir.join("src/generated")).expect("create generated dir"); - fs::write( - package_dir.join("src/generated").join("types.ts"), - "export type Example = string;\n", - ) - .expect("write generated types"); - fs::write( - package_dir.join("src/generated").join("constants.ts"), - "export const EXAMPLE = \"example\";\n", - ) - .expect("write generated constants"); - let json = package_json_value(&package_json("example")); - - let expected = expected_packed_dist_files( - &package_dir, - &json, - Path::new("packages/example/package.json"), - None, - ) - .expect("expected dist files"); - - assert_eq!( - expected, - BTreeSet::from([ - "dist/generated/constants.d.ts".to_owned(), - "dist/generated/constants.js".to_owned(), - "dist/generated/types.d.ts".to_owned(), - "dist/generated/types.js".to_owned(), - "dist/index.d.ts".to_owned(), - "dist/index.js".to_owned(), - ]) - ); - let _ = fs::remove_dir_all(root); - } - - #[test] - fn npm_pack_payload_requires_metadata_and_export_files() { - let payload_files = BTreeSet::from([ - "package.json".to_owned(), - "README.md".to_owned(), - "LICENSE-MIT".to_owned(), - "dist/index.js".to_owned(), - ]); - let required_files = BTreeSet::from([ - "package.json".to_owned(), - "README.md".to_owned(), - "LICENSE-MIT".to_owned(), - "LICENSE-APACHE".to_owned(), - "dist/index.js".to_owned(), - "dist/index.d.ts".to_owned(), - ]); - - let error = - validate_npm_pack_payload("@radroots/example", &payload_files, &required_files, None) - .expect_err("missing files should fail"); - assert!(error.contains("missing required files")); - assert!(error.contains("LICENSE-APACHE")); - assert!(error.contains("dist/index.d.ts")); - } - - #[test] - fn packed_dist_inventory_rejects_missing_generated_files() { - let expected_dist_files = BTreeSet::from([ - "dist/generated/types.d.ts".to_owned(), - "dist/generated/types.js".to_owned(), - "dist/index.d.ts".to_owned(), - "dist/index.js".to_owned(), - ]); - let payload_files = BTreeSet::from([ - "package.json".to_owned(), - "README.md".to_owned(), - "LICENSE-MIT".to_owned(), - "LICENSE-APACHE".to_owned(), - "dist/generated/types.js".to_owned(), - "dist/index.d.ts".to_owned(), - "dist/index.js".to_owned(), - ]); - - let error = validate_packed_dist_inventory( - "@radroots/example", - &payload_files, - &expected_dist_files, - ) - .expect_err("missing generated declaration rejected"); - - assert!(error.contains("dist inventory mismatch")); - assert!(error.contains("dist/generated/types.d.ts")); - } - - #[test] - fn packed_payload_path_normalization_rejects_invalid_paths() { - assert_eq!( - normalized_package_path("./dist/index.js").expect("valid path"), - "dist/index.js" - ); - for invalid in [ - "", - "./", - "/dist/index.js", - "dist\\index.js", - "dist/../index.js", - "dist//index.js", - "dist/./index.js", - ] { - let error = normalized_package_path(invalid).expect_err("invalid path rejected"); - assert!(error.contains("invalid npm pack payload path")); - } - } - - #[test] - fn consumer_smoke_script_imports_package_roots() { - let script = consumer_smoke_script(&[ - "@radroots/core-bindings".to_owned(), - "@radroots/event-codec-wasm".to_owned(), - ]) - .expect("smoke script renders"); - - assert_eq!( - script, - "await import(\"@radroots/core-bindings\");\nawait import(\"@radroots/event-codec-wasm\");\n" - ); - } - - #[test] - fn pnpm_pack_json_parser_accepts_single_package_object() { - let entry = parse_pnpm_pack_entry( - "@radroots/example", - br#"{ - "name": "@radroots/example", - "version": "0.1.0", - "filename": "/tmp/example.tgz", - "files": [ - {"path": "dist/index.js"}, - {"path": "package.json"} - ] -}"#, - b"", - ) - .expect("pnpm pack output parses"); - - assert_eq!(entry.filename, "/tmp/example.tgz"); - assert_eq!( - entry - .files - .into_iter() - .map(|file| file.path) - .collect::<Vec<_>>(), - ["dist/index.js", "package.json"] - ); - } - - #[test] - fn reads_packed_manifest_from_tgz() { - let root = test_root("packed_manifest_tgz"); - fs::create_dir_all(&root).expect("create root"); - let tarball_path = root.join("example.tgz"); - let file = fs::File::create(&tarball_path).expect("create tarball"); - let encoder = GzEncoder::new(file, Compression::default()); - let mut builder = tar::Builder::new(encoder); - let contents = br#"{"name":"@radroots/example","version":"0.1.0"}"#; - let mut header = tar::Header::new_gnu(); - header - .set_path("package/package.json") - .expect("set manifest path"); - header.set_size(contents.len() as u64); - header.set_mode(0o644); - header.set_cksum(); - builder - .append(&header, &contents[..]) - .expect("append manifest"); - let encoder = builder.into_inner().expect("finish tar"); - encoder.finish().expect("finish gzip"); - let packed = PackedPackage { - package_name: "@radroots/example".to_owned(), - tarball_path, - files: Vec::new(), - }; - - let json = - read_packed_package_json(&packed, "@radroots/example").expect("read packed manifest"); - - assert_eq!( - json.get("name").and_then(serde_json::Value::as_str), - Some("@radroots/example") - ); - let _ = fs::remove_dir_all(root); - } - - #[test] - fn packed_manifest_accepts_pnpm_workspace_dependency_rewrite() { - let source = package_json_value(&package_json_with_dependencies( - "trade-bindings", - r#""@radroots/core-bindings": "workspace:^", - "@radroots/event-bindings": "workspace:^""#, - )); - let packed = package_json_value(&package_json_with_dependencies( - "trade-bindings", - r#""@radroots/core-bindings": "^0.1.0", - "@radroots/event-bindings": "^0.1.0""#, - )); - - check_packed_package_json( - &source, - &packed, - Path::new("packages/trade-bindings/package.json"), - "@radroots/trade-bindings", - "packages/trade-bindings", - ) - .expect("packed manifest accepted"); - } - - #[test] - fn packed_manifest_rejects_workspace_dependency_ranges() { - let source = package_json_value(&package_json_with_dependencies( - "trade-bindings", - r#""@radroots/core-bindings": "workspace:^""#, - )); - let packed = package_json_value(&package_json_with_dependencies( - "trade-bindings", - r#""@radroots/core-bindings": "workspace:^""#, - )); - - let error = check_packed_package_json( - &source, - &packed, - Path::new("packages/trade-bindings/package.json"), - "@radroots/trade-bindings", - "packages/trade-bindings", - ) - .expect_err("workspace dependency rejected"); - - assert!(error.contains("must not use workspace protocol")); - } - - #[test] - fn packed_manifest_rejects_internal_dependency_range_mismatch() { - let source = package_json_value(&package_json_with_dependencies( - "trade-bindings", - r#""@radroots/core-bindings": "workspace:^""#, - )); - let packed = package_json_value(&package_json_with_dependencies( - "trade-bindings", - r#""@radroots/core-bindings": "^0.2.0""#, - )); - - let error = check_packed_package_json( - &source, - &packed, - Path::new("packages/trade-bindings/package.json"), - "@radroots/trade-bindings", - "packages/trade-bindings", - ) - .expect_err("internal dependency mismatch rejected"); - - assert!(error.contains("expected ^0.1.0")); - } - - #[test] - fn npm_pack_payload_rejects_source_and_provenance_internals() { - let required_files = BTreeSet::from(["package.json".to_owned()]); - for forbidden in [ - "src/generated/types.ts", - "contracts/provenance/typescript/core.json", - "dist/sdk-manifest.json", - "dist/index.tsbuildinfo", - "dist/.gitignore", - ".npmignore", - ] { - let payload_files = BTreeSet::from(["package.json".to_owned(), forbidden.to_owned()]); - - let error = validate_npm_pack_payload( - "@radroots/example", - &payload_files, - &required_files, - None, - ) - .expect_err("forbidden payload path should fail"); - assert!(error.contains("includes forbidden")); - assert!(error.contains(forbidden)); - } - } - - #[test] - fn npm_pack_payload_requires_exact_wasm_file() { - let required_files = BTreeSet::from([ - "package.json".to_owned(), - "dist/example.js".to_owned(), - "dist/example_bg.wasm".to_owned(), - ]); - let missing_wasm = - BTreeSet::from(["package.json".to_owned(), "dist/example.js".to_owned()]); - let error = validate_npm_pack_payload( - "@radroots/example-wasm", - &missing_wasm, - &required_files, - Some("dist/example_bg.wasm"), - ) - .expect_err("missing wasm should fail"); - assert!(error.contains("missing required files")); - - let extra_wasm = BTreeSet::from([ - "package.json".to_owned(), - "dist/example.js".to_owned(), - "dist/example_bg.wasm".to_owned(), - "dist/extra_bg.wasm".to_owned(), - ]); - let error = validate_npm_pack_payload( - "@radroots/example-wasm", - &extra_wasm, - &required_files, - Some("dist/example_bg.wasm"), - ) - .expect_err("extra wasm should fail"); - assert!(error.contains("must include exactly dist/example_bg.wasm")); - } - - #[test] - fn wasm_package_surface_requires_exported_dist_files() { - let root = test_root("wasm_surface"); - let package_dir = root.join("packages").join("example-wasm"); - fs::create_dir_all(package_dir.join("dist")).expect("create dist"); - fs::write( - package_dir.join("package.json"), - package_json("example-wasm").replace("./dist/index", "./dist/example"), - ) - .expect("write package json"); - write_distribution_metadata( - &root, - &package_dir, - "@radroots/example-wasm", - "Example package", - ); - fs::write(package_dir.join("dist").join("example.js"), "export {};\n").expect("write js"); - let spec = WasmPackageSpec { - key: "example", - crate_name: "radroots_example_wasm", - crate_dir: "crates/example_wasm", - package_name: "@radroots/example-wasm", - package_dir: "packages/example-wasm", - out_name: "example", - out_dir: "../../packages/example-wasm/dist", - }; - - let missing = - check_wasm_package_surface(&root, spec).expect_err("missing d.ts should fail"); - assert!(missing.contains("example.d.ts")); - fs::write( - package_dir.join("dist").join("example.d.ts"), - dto_declaration("example.d.ts"), - ) - .expect("write d.ts"); - fs::write( - package_dir.join("dist").join("example_bg.wasm.d.ts"), - dto_declaration("example_bg.wasm.d.ts"), - ) - .expect("write d.ts"); - fs::write(package_dir.join("dist").join("example_bg.wasm"), b"\0asm").expect("write wasm"); - let error = check_wasm_package_surface(&root, spec) - .expect_err("unknown declaration inventory rejected"); - assert!(error.contains("missing wasm declaration inventory")); - - let _ = fs::remove_dir_all(root); - } - - #[test] - fn wasm_package_surface_rejects_generated_package_manifest() { - let root = test_root("wasm_dist_package_manifest"); - let package_dir = root.join("packages").join("example-wasm"); - fs::create_dir_all(package_dir.join("dist")).expect("create dist"); - fs::write( - package_dir.join("package.json"), - package_json("example-wasm").replace("./dist/index", "./dist/example"), - ) - .expect("write package json"); - write_distribution_metadata( - &root, - &package_dir, - "@radroots/example-wasm", - "Example package", - ); - fs::write(package_dir.join("dist").join("example.js"), "export {};\n").expect("write js"); - fs::write( - package_dir.join("dist").join("example.d.ts"), - dto_declaration("example.d.ts"), - ) - .expect("write d.ts"); - fs::write( - package_dir.join("dist").join("example_bg.wasm.d.ts"), - dto_declaration("example_bg.wasm.d.ts"), - ) - .expect("write wasm d.ts"); - fs::write(package_dir.join("dist").join("package.json"), "{}\n") - .expect("write forbidden manifest"); - let spec = WasmPackageSpec { - key: "example", - crate_name: "radroots_example_wasm", - crate_dir: "crates/example_wasm", - package_name: "@radroots/example-wasm", - package_dir: "packages/example-wasm", - out_name: "example", - out_dir: "../../packages/example-wasm/dist", - }; - - let error = - check_wasm_package_surface(&root, spec).expect_err("dist package manifest rejected"); - assert!(error.contains("generated package manifest is forbidden")); - - let _ = fs::remove_dir_all(root); - } - - #[test] - fn wasm_package_surface_rejects_dist_ignore_files() { - let root = test_root("wasm_dist_ignore"); - let package_dir = root.join("packages").join("example-wasm"); - fs::create_dir_all(package_dir.join("dist")).expect("create dist"); - fs::write( - package_dir.join("package.json"), - package_json("example-wasm").replace("./dist/index", "./dist/example"), - ) - .expect("write package json"); - write_distribution_metadata( - &root, - &package_dir, - "@radroots/example-wasm", - "Example package", - ); - fs::write(package_dir.join("dist").join(".gitignore"), "*\n").expect("write ignore"); - let spec = WasmPackageSpec { - key: "example", - crate_name: "radroots_example_wasm", - crate_dir: "crates/example_wasm", - package_name: "@radroots/example-wasm", - package_dir: "packages/example-wasm", - out_name: "example", - out_dir: "../../packages/example-wasm/dist", - }; - - let error = check_wasm_package_surface(&root, spec).expect_err("dist ignore file rejected"); - assert!(error.contains("would hide package payload")); - - let _ = fs::remove_dir_all(root); - } - - #[test] - fn wasm_package_surface_requires_runtime_wasm_artifact() { - let root = test_root("wasm_runtime_artifact"); - let package_dir = root.join("packages").join("example-wasm"); - fs::create_dir_all(package_dir.join("dist")).expect("create dist"); - fs::write( - package_dir.join("package.json"), - package_json("example-wasm").replace("./dist/index", "./dist/example"), - ) - .expect("write package json"); - write_distribution_metadata( - &root, - &package_dir, - "@radroots/example-wasm", - "Example package", - ); - fs::write(package_dir.join("dist").join("example.js"), "export {};\n").expect("write js"); - fs::write( - package_dir.join("dist").join("example.d.ts"), - dto_declaration("example.d.ts"), - ) - .expect("write d.ts"); - fs::write( - package_dir.join("dist").join("example_bg.wasm.d.ts"), - dto_declaration("example_bg.wasm.d.ts"), - ) - .expect("write wasm d.ts"); - let spec = WasmPackageSpec { - key: "example", - crate_name: "radroots_example_wasm", - crate_dir: "crates/example_wasm", - package_name: "@radroots/example-wasm", - package_dir: "packages/example-wasm", - out_name: "example", - out_dir: "../../packages/example-wasm/dist", - }; - - let error = check_wasm_package_surface(&root, spec).expect_err("missing wasm rejected"); - assert!(error.contains("missing wasm package runtime artifact")); - - let _ = fs::remove_dir_all(root); - } - - #[test] - fn wasm_package_surface_rejects_subpath_exports() { - let root = test_root("wasm_subpath_exports"); - let package_dir = root.join("packages").join("example-wasm"); - fs::create_dir_all(package_dir.join("dist")).expect("create dist"); - fs::write( - package_dir.join("package.json"), - package_json("example-wasm") - .replace("./dist/index", "./dist/example") - .replace( - r#""exports": { - ".": { - "types": "./dist/example.d.ts", - "import": "./dist/example.js", - "default": "./dist/example.js" - } - }"#, - r#""exports": { - ".": "./dist/example.js", - "./extra": "./dist/extra.js" - }"#, - ), - ) - .expect("write package json"); - write_distribution_metadata( - &root, - &package_dir, - "@radroots/example-wasm", - "Example package", - ); - fs::write(package_dir.join("dist").join("example.js"), "export {};\n").expect("write js"); - fs::write( - package_dir.join("dist").join("example.d.ts"), - dto_declaration("example.d.ts"), - ) - .expect("write d.ts"); - fs::write( - package_dir.join("dist").join("example_bg.wasm.d.ts"), - dto_declaration("example_bg.wasm.d.ts"), - ) - .expect("write wasm d.ts"); - let spec = WasmPackageSpec { - key: "example", - crate_name: "radroots_example_wasm", - crate_dir: "crates/example_wasm", - package_name: "@radroots/example-wasm", - package_dir: "packages/example-wasm", - out_name: "example", - out_dir: "../../packages/example-wasm/dist", - }; - - let error = check_wasm_package_surface(&root, spec).expect_err("subpath export rejected"); - assert!(error.contains("only supports root exports")); - - let _ = fs::remove_dir_all(root); - } - - fn test_root(name: &str) -> PathBuf { - let stamp = SystemTime::now() - .duration_since(UNIX_EPOCH) - .expect("system time after epoch") - .as_nanos(); - let root = std::env::temp_dir().join(format!( - "radroots_sdk_xtask_check_{name}_{}_{}", - std::process::id(), - stamp - )); - let _ = fs::remove_dir_all(&root); - root - } - - fn dto_declaration(name: &str) -> String { - format!( - "// @generated by cargo xtask generate wasm via dto_bindgen\n// Do not edit by hand.\nexport type Generated = \"{name}\";\n" - ) - } - - fn write_distribution_metadata( - root: &Path, - package_dir: &Path, - package_name: &str, - description: &str, - ) { - fs::write(root.join("LICENSE-MIT"), "MIT license\n").expect("write MIT license"); - fs::write(root.join("LICENSE-APACHE"), "Apache license\n").expect("write Apache license"); - fs::write(package_dir.join("LICENSE-MIT"), "MIT license\n") - .expect("write package MIT license"); - fs::write(package_dir.join("LICENSE-APACHE"), "Apache license\n") - .expect("write package Apache license"); - fs::write( - package_dir.join("README.md"), - package_readme(package_name, description), - ) - .expect("write package README"); - } - - fn package_json(name: &str) -> String { - format!( - r#"{{ - "name": "@radroots/{name}", - "version": "0.1.0", - "description": "Example package", - "license": "MIT OR Apache-2.0", - "homepage": "https://radroots.org", - "repository": {{ - "type": "git", - "url": "git+https://github.com/radrootslabs/sdk.git", - "directory": "packages/{name}" - }}, - "publishConfig": {{ - "access": "public" - }}, - "type": "module", - "sideEffects": false, - "files": ["dist", "README.md", "LICENSE-MIT", "LICENSE-APACHE"], - "main": "./dist/index.js", - "types": "./dist/index.d.ts", - "exports": {{ - ".": {{ - "types": "./dist/index.d.ts", - "import": "./dist/index.js", - "default": "./dist/index.js" - }} - }} -}}"# - ) - } - - fn package_json_with_dependencies(name: &str, dependencies: &str) -> String { - let raw = package_json(name); - let body = raw.strip_suffix('}').expect("root package JSON object"); - format!( - r#"{body}, - "dependencies": {{ - {dependencies} - }} -}}"# - ) - } - - fn package_json_value(raw: &str) -> serde_json::Value { - serde_json::from_str(raw).expect("package json parses") - } - - fn manifest_value(raw: &str) -> toml::Value { - raw.parse::<toml::Value>().expect("manifest parses") - } -} diff --git a/tools/sdk_xtask_import/src/contracts.rs b/tools/sdk_xtask_import/src/contracts.rs @@ -1,694 +0,0 @@ -use std::{ - collections::{BTreeMap, BTreeSet}, - fs, - path::{Path, PathBuf}, -}; - -use serde::Deserialize; - -use crate::package_matrix::{PackageSpec, WasmPackageSpec, package_specs, wasm_package_specs}; - -#[derive(Debug, Deserialize)] -#[serde(deny_unknown_fields)] -struct ExportContract { - language: LanguageContract, - packages: BTreeMap<String, String>, - artifacts: Option<ExportArtifacts>, - runtime: RuntimeContract, -} - -#[derive(Debug, Deserialize)] -#[serde(deny_unknown_fields)] -struct PackageContract { - language: LanguageContract, - sdk: SdkPackageContract, - rollout: RolloutContract, - operations: BTreeMap<String, String>, - shared_types: BTreeMap<String, String>, - artifacts: Option<SdkArtifacts>, - npm_packages: Option<BTreeMap<String, NpmPackageContract>>, -} - -#[derive(Debug, Deserialize)] -#[serde(deny_unknown_fields)] -struct LanguageContract { - id: String, - repository: String, -} - -#[derive(Debug, Deserialize)] -#[serde(deny_unknown_fields)] -struct RuntimeContract { - networking: String, - signing: String, - deterministic_codec: String, -} - -#[derive(Debug, Deserialize)] -#[serde(deny_unknown_fields)] -struct ExportArtifacts { - models_dir: String, - constants_dir: String, - wasm_dist_dir: Option<String>, - manifest_file: String, -} - -#[derive(Debug, Deserialize)] -#[serde(deny_unknown_fields)] -struct SdkPackageContract { - package: Option<String>, - package_family: Option<String>, - module_format: Option<String>, - deterministic_codec: String, - signing: String, - networking: String, -} - -#[derive(Debug, Deserialize)] -#[serde(deny_unknown_fields)] -struct NpmPackageContract { - kind: String, - crate_name: String, - crate_dir: String, - package: String, - package_dir: String, - out_name: Option<String>, - out_dir: Option<String>, -} - -#[derive(Debug)] -struct ExpectedNpmPackage { - kind: &'static str, - crate_name: &'static str, - crate_dir: &'static str, - package: &'static str, - package_dir: &'static str, - out_name: Option<&'static str>, - out_dir: Option<&'static str>, -} - -#[derive(Debug, Deserialize)] -#[serde(deny_unknown_fields)] -struct RolloutContract { - stage: String, - order: u32, -} - -#[derive(Debug, Deserialize)] -#[serde(deny_unknown_fields)] -struct SdkArtifacts { - models_dir: String, - runtime_dir: String, - wasm_dist_dir: String, - manifest_file: String, -} - -pub fn validate_sdk_contracts(root: &Path) -> Result<(), String> { - radroots_protocol::runtime::v1::validate_catalog(radroots_protocol::runtime::v1::CATALOG) - .map_err(|error| error.to_string())?; - let exports = load_contract_dir::<ExportContract>(&root.join("contracts").join("exports"))?; - let packages = load_contract_dir::<PackageContract>(&root.join("contracts").join("packages"))?; - if exports.is_empty() { - return Err("contracts/exports must define at least one language".to_owned()); - } - if packages.is_empty() { - return Err("contracts/packages must define at least one language".to_owned()); - } - - let mut export_packages = BTreeMap::new(); - let mut export_languages = BTreeSet::new(); - for export in &exports { - validate_language(&export.language, "exports")?; - validate_non_empty_map(&export.packages, "exports packages")?; - validate_runtime( - &export.runtime.networking, - &export.runtime.signing, - &export.runtime.deterministic_codec, - &format!("exports {}", export.language.id), - )?; - let artifacts = export - .artifacts - .as_ref() - .ok_or_else(|| format!("exports {} artifacts are required", export.language.id))?; - validate_non_empty(&artifacts.models_dir, "exports artifacts.models_dir")?; - validate_non_empty(&artifacts.constants_dir, "exports artifacts.constants_dir")?; - validate_non_empty(&artifacts.manifest_file, "exports artifacts.manifest_file")?; - if export.language.id == "ts" { - validate_non_empty( - artifacts.wasm_dist_dir.as_deref().unwrap_or(""), - "exports ts artifacts.wasm_dist_dir", - )?; - } - if !export_languages.insert(export.language.id.clone()) { - return Err(format!("duplicate exports language {}", export.language.id)); - } - let packages = export - .packages - .values() - .cloned() - .collect::<BTreeSet<String>>(); - if export.language.id != "ts" && packages.len() != 1 { - return Err(format!( - "exports {} must resolve to one curated package", - export.language.id - )); - } - export_packages.insert(export.language.id.clone(), packages); - } - - let mut package_languages = BTreeSet::new(); - let mut operation_keys: Option<BTreeSet<String>> = None; - let mut shared_type_keys: Option<BTreeSet<String>> = None; - let mut rollout_orders = BTreeMap::new(); - for package in &packages { - validate_language(&package.language, "packages")?; - if let Some(package_name) = package.sdk.package.as_deref() { - validate_non_empty(package_name, "packages sdk.package")?; - } - if let Some(package_family) = package.sdk.package_family.as_deref() { - validate_non_empty(package_family, "packages sdk.package_family")?; - } - validate_runtime( - &package.sdk.networking, - &package.sdk.signing, - &package.sdk.deterministic_codec, - &format!("packages {}", package.language.id), - )?; - if let Some(module_format) = package.sdk.module_format.as_deref() { - validate_non_empty(module_format, "packages sdk.module_format")?; - } - if package.sdk.package.is_none() && package.sdk.package_family.is_none() { - return Err(format!( - "packages {} sdk.package or sdk.package_family is required", - package.language.id - )); - } - validate_rollout(&package.language.id, &package.rollout)?; - validate_non_empty_map(&package.operations, "packages operations")?; - validate_non_empty_map(&package.shared_types, "packages shared_types")?; - if package.language.id == "ts" { - let artifacts = package - .artifacts - .as_ref() - .ok_or_else(|| "packages ts artifacts are required".to_owned())?; - validate_non_empty(&artifacts.models_dir, "packages ts artifacts.models_dir")?; - validate_non_empty(&artifacts.runtime_dir, "packages ts artifacts.runtime_dir")?; - validate_non_empty( - &artifacts.wasm_dist_dir, - "packages ts artifacts.wasm_dist_dir", - )?; - validate_non_empty( - &artifacts.manifest_file, - "packages ts artifacts.manifest_file", - )?; - } - if !package_languages.insert(package.language.id.clone()) { - return Err(format!( - "duplicate packages language {}", - package.language.id - )); - } - let Some(packages_for_language) = export_packages.get(&package.language.id) else { - return Err(format!( - "packages {} is missing a matching export contract", - package.language.id - )); - }; - if package.language.id == "ts" { - let npm_packages = package - .npm_packages - .as_ref() - .ok_or_else(|| "packages ts npm_packages are required".to_owned())?; - validate_ts_npm_packages(package, packages_for_language, npm_packages)?; - } else { - if package.npm_packages.is_some() { - return Err(format!( - "packages {} npm_packages is only supported for ts", - package.language.id - )); - } - let sdk_package = package.sdk.package.as_ref().ok_or_else(|| { - format!("packages {} sdk.package is required", package.language.id) - })?; - let expected = [sdk_package.clone()].into_iter().collect::<BTreeSet<_>>(); - if packages_for_language != &expected { - return Err(format!( - "exports {} must resolve to package {}", - package.language.id, sdk_package - )); - } - } - let current_operations = package.operations.keys().cloned().collect::<BTreeSet<_>>(); - match &operation_keys { - Some(expected) if expected != &current_operations => { - return Err(format!( - "packages {} operations must match the shared operation set", - package.language.id - )); - } - None => operation_keys = Some(current_operations), - _ => {} - } - let current_shared_types = package - .shared_types - .keys() - .cloned() - .collect::<BTreeSet<_>>(); - match &shared_type_keys { - Some(expected) if expected != &current_shared_types => { - return Err(format!( - "packages {} shared_types must match the shared type set", - package.language.id - )); - } - None => shared_type_keys = Some(current_shared_types), - _ => {} - } - rollout_orders.insert(package.language.id.clone(), package.rollout.order); - } - - if export_languages != package_languages { - return Err("contracts/exports and contracts/packages languages must match".to_owned()); - } - if rollout_orders.get("ts") != Some(&1) { - return Err("packages ts rollout.order must be 1".to_owned()); - } - Ok(()) -} - -fn validate_ts_npm_packages( - package: &PackageContract, - export_packages: &BTreeSet<String>, - npm_packages: &BTreeMap<String, NpmPackageContract>, -) -> Result<(), String> { - if package.sdk.package.is_some() { - return Err( - "packages ts sdk.package must not be set for multi-package npm output".to_owned(), - ); - } - let package_family = package - .sdk - .package_family - .as_deref() - .ok_or_else(|| "packages ts sdk.package_family is required".to_owned())?; - if package_family != "@radroots" { - return Err("packages ts sdk.package_family must be @radroots".to_owned()); - } - let expected = expected_ts_npm_packages(); - let expected_keys = expected.keys().cloned().collect::<BTreeSet<_>>(); - let actual_keys = npm_packages.keys().cloned().collect::<BTreeSet<_>>(); - if actual_keys != expected_keys { - let missing = expected_keys - .difference(&actual_keys) - .cloned() - .collect::<Vec<_>>(); - let extra = actual_keys - .difference(&expected_keys) - .cloned() - .collect::<Vec<_>>(); - return Err(format!( - "packages ts npm_packages must match package matrix: missing {:?}, extra {:?}", - missing, extra - )); - } - for (key, expected_package) in expected { - let actual = npm_packages - .get(&key) - .ok_or_else(|| format!("packages ts npm package {key} is missing"))?; - validate_npm_package(&key, actual, &expected_package)?; - } - let expected_names = npm_packages - .values() - .map(|package| package.package.clone()) - .collect::<BTreeSet<_>>(); - if export_packages != &expected_names { - return Err("exports ts package set must match TypeScript npm package matrix".to_owned()); - } - Ok(()) -} - -fn expected_ts_npm_packages() -> BTreeMap<String, ExpectedNpmPackage> { - let mut expected = BTreeMap::new(); - for spec in package_specs() { - expected.insert(spec.key.to_owned(), expected_from_package_spec(*spec)); - } - for spec in wasm_package_specs() { - expected.insert(spec.key.to_owned(), expected_from_wasm_package_spec(*spec)); - } - expected -} - -fn expected_from_package_spec(spec: PackageSpec) -> ExpectedNpmPackage { - ExpectedNpmPackage { - kind: "bindings", - crate_name: spec.crate_name, - crate_dir: spec.crate_dir, - package: spec.package_name, - package_dir: spec.package_dir, - out_name: None, - out_dir: None, - } -} - -fn expected_from_wasm_package_spec(spec: WasmPackageSpec) -> ExpectedNpmPackage { - ExpectedNpmPackage { - kind: "wasm", - crate_name: spec.crate_name, - crate_dir: spec.crate_dir, - package: spec.package_name, - package_dir: spec.package_dir, - out_name: Some(spec.out_name), - out_dir: Some(spec.out_dir), - } -} - -fn validate_npm_package( - key: &str, - actual: &NpmPackageContract, - expected: &ExpectedNpmPackage, -) -> Result<(), String> { - validate_npm_field(key, "kind", &actual.kind, expected.kind)?; - validate_npm_field(key, "crate_name", &actual.crate_name, expected.crate_name)?; - validate_npm_field(key, "crate_dir", &actual.crate_dir, expected.crate_dir)?; - validate_npm_field(key, "package", &actual.package, expected.package)?; - validate_npm_field( - key, - "package_dir", - &actual.package_dir, - expected.package_dir, - )?; - validate_optional_npm_field( - key, - "out_name", - actual.out_name.as_deref(), - expected.out_name, - )?; - validate_optional_npm_field(key, "out_dir", actual.out_dir.as_deref(), expected.out_dir)?; - Ok(()) -} - -fn validate_npm_field(key: &str, field: &str, actual: &str, expected: &str) -> Result<(), String> { - validate_non_empty(actual, &format!("packages ts npm package {key} {field}"))?; - if actual != expected { - return Err(format!( - "packages ts npm package {key} {field} must be {expected}" - )); - } - Ok(()) -} - -fn validate_optional_npm_field( - key: &str, - field: &str, - actual: Option<&str>, - expected: Option<&str>, -) -> Result<(), String> { - match (actual, expected) { - (Some(actual), Some(expected)) => validate_npm_field(key, field, actual, expected), - (None, None) => Ok(()), - (Some(_), None) => Err(format!( - "packages ts npm package {key} {field} must not be set" - )), - (None, Some(expected)) => Err(format!( - "packages ts npm package {key} {field} must be {expected}" - )), - } -} - -fn load_contract_dir<T>(dir: &Path) -> Result<Vec<T>, String> -where - T: for<'de> Deserialize<'de>, -{ - let read_dir = - fs::read_dir(dir).map_err(|error| format!("failed to read {}: {error}", dir.display()))?; - let mut entries = read_dir - .collect::<Result<Vec<_>, _>>() - .map_err(|error| format!("failed to read {} entry: {error}", dir.display()))?; - entries.sort_by_key(|entry| entry.file_name()); - let mut contracts = Vec::new(); - for entry in entries { - let path = entry.path(); - if path.extension().and_then(|extension| extension.to_str()) != Some("toml") { - continue; - } - contracts.push(parse_toml(&path)?); - } - Ok(contracts) -} - -fn parse_toml<T>(path: &PathBuf) -> Result<T, String> -where - T: for<'de> Deserialize<'de>, -{ - let raw = fs::read_to_string(path) - .map_err(|error| format!("failed to read {}: {error}", path.display()))?; - toml::from_str(&raw).map_err(|error| format!("failed to parse {}: {error}", path.display())) -} - -fn validate_language(language: &LanguageContract, family: &str) -> Result<(), String> { - validate_non_empty(&language.id, &format!("{family} language.id"))?; - validate_non_empty( - &language.repository, - &format!("{family} language.repository"), - ) -} - -fn validate_runtime( - networking: &str, - signing: &str, - deterministic_codec: &str, - family: &str, -) -> Result<(), String> { - validate_non_empty(networking, &format!("{family} networking"))?; - validate_non_empty(signing, &format!("{family} signing"))?; - validate_non_empty( - deterministic_codec, - &format!("{family} deterministic_codec"), - ) -} - -fn validate_rollout(language: &str, rollout: &RolloutContract) -> Result<(), String> { - validate_non_empty(&rollout.stage, "packages rollout.stage")?; - if !matches!(rollout.stage.as_str(), "active" | "next" | "deferred") { - return Err(format!("packages {language} rollout.stage is invalid")); - } - if rollout.order == 0 { - return Err(format!( - "packages {language} rollout.order must be greater than zero" - )); - } - Ok(()) -} - -fn validate_non_empty(value: &str, field: &str) -> Result<(), String> { - if value.trim().is_empty() || value.trim() != value { - return Err(format!("{field} must be non-empty")); - } - Ok(()) -} - -fn validate_non_empty_map(map: &BTreeMap<String, String>, field: &str) -> Result<(), String> { - if map.is_empty() { - return Err(format!("{field} must not be empty")); - } - for (key, value) in map { - validate_non_empty(key, field)?; - validate_non_empty(value, field)?; - } - Ok(()) -} - -#[cfg(test)] -mod tests { - use std::{ - fs, - time::{SystemTime, UNIX_EPOCH}, - }; - - use super::validate_sdk_contracts; - - #[test] - fn current_sdk_contracts_validate() { - let root = crate::fs::workspace_root().expect("workspace root"); - validate_sdk_contracts(&root).expect("sdk contracts validate"); - } - - #[test] - fn rejects_mismatched_language_sets() { - let root = test_root("language_mismatch"); - write_contract(&root, "contracts/exports/ts.toml", EXPORT_TS); - let error = validate_sdk_contracts(&root).expect_err("missing packages should fail"); - assert!(error.contains("contracts/packages")); - let _ = fs::remove_dir_all(root); - } - - #[test] - fn rejects_package_export_mismatch() { - let root = test_root("package_mismatch"); - write_contract( - &root, - "contracts/exports/ts.toml", - EXPORT_TS - .replace("@radroots/core-bindings", "@radroots/other") - .as_str(), - ); - write_contract(&root, "contracts/packages/ts.toml", PACKAGE_TS); - let error = validate_sdk_contracts(&root).expect_err("mismatch should fail"); - assert!(error.contains("exports ts package set")); - let _ = fs::remove_dir_all(root); - } - - #[test] - fn rejects_ts_package_matrix_drift() { - let root = test_root("ts_matrix_drift"); - write_contract(&root, "contracts/exports/ts.toml", EXPORT_TS); - write_contract( - &root, - "contracts/packages/ts.toml", - PACKAGE_TS - .replace( - "package = \"@radroots/core-bindings\"", - "package = \"@radroots/other\"", - ) - .as_str(), - ); - let error = validate_sdk_contracts(&root).expect_err("mismatch should fail"); - assert!(error.contains("packages ts npm package core package")); - let _ = fs::remove_dir_all(root); - } - - fn test_root(name: &str) -> std::path::PathBuf { - let stamp = SystemTime::now() - .duration_since(UNIX_EPOCH) - .expect("time") - .as_nanos(); - std::env::temp_dir().join(format!("radroots_sdk_contracts_{name}_{stamp}")) - } - - fn write_contract(root: &std::path::Path, relative: &str, contents: &str) { - let path = root.join(relative); - fs::create_dir_all(path.parent().expect("parent")).expect("create parent"); - fs::write(path, contents).expect("write contract"); - } - - const EXPORT_TS: &str = r#"[language] -id = "ts" -repository = "sdk-typescript" - -[packages] -"radroots_core" = "@radroots/core-bindings" -"radroots_event" = "@radroots/event-bindings" -"radroots_identity" = "@radroots/identity-bindings" -"radroots_replica_schema" = "@radroots/replica-schema-bindings" -"radroots_trade" = "@radroots/trade-bindings" -"radroots_event_codec_wasm" = "@radroots/event-codec-wasm" -"radroots_replica_store_wasm" = "@radroots/replica-store-wasm" -"radroots_replica_sync_wasm" = "@radroots/replica-sync-wasm" - -[artifacts] -models_dir = "src/generated" -constants_dir = "src/generated" -wasm_dist_dir = "dist" -manifest_file = "export-manifest.json" - -[runtime] -networking = "native" -signing = "native" -deterministic_codec = "wasm" -"#; - - const PACKAGE_TS: &str = r#"[language] -id = "ts" -repository = "sdk-typescript" - -[sdk] -package_family = "@radroots" -module_format = "esm" -deterministic_codec = "wasm" -signing = "native" -networking = "native" - -[npm_packages.core] -kind = "bindings" -crate_name = "radroots_core_bindings" -crate_dir = "crates/core_bindings" -package = "@radroots/core-bindings" -package_dir = "packages/core-bindings" - -[npm_packages.event] -kind = "bindings" -crate_name = "radroots_event_bindings" -crate_dir = "crates/event_bindings" -package = "@radroots/event-bindings" -package_dir = "packages/event-bindings" - -[npm_packages.identity] -kind = "bindings" -crate_name = "radroots_identity_bindings" -crate_dir = "crates/identity_bindings" -package = "@radroots/identity-bindings" -package_dir = "packages/identity-bindings" - -[npm_packages.replica_schema] -kind = "bindings" -crate_name = "radroots_replica_schema_bindings" -crate_dir = "crates/replica_schema_bindings" -package = "@radroots/replica-schema-bindings" -package_dir = "packages/replica-schema-bindings" - -[npm_packages.trade] -kind = "bindings" -crate_name = "radroots_trade_bindings" -crate_dir = "crates/trade_bindings" -package = "@radroots/trade-bindings" -package_dir = "packages/trade-bindings" - -[npm_packages.event_codec] -kind = "wasm" -crate_name = "radroots_event_codec_wasm" -crate_dir = "crates/event_codec_wasm" -package = "@radroots/event-codec-wasm" -package_dir = "packages/event-codec-wasm" -out_name = "radroots_event_codec_wasm" -out_dir = "../../packages/event-codec-wasm/dist" - -[npm_packages.replica_store] -kind = "wasm" -crate_name = "radroots_replica_store_wasm" -crate_dir = "crates/replica_store_wasm" -package = "@radroots/replica-store-wasm" -package_dir = "packages/replica-store-wasm" -out_name = "radroots_replica_store_wasm" -out_dir = "../../packages/replica-store-wasm/dist" - -[npm_packages.replica_sync] -kind = "wasm" -crate_name = "radroots_replica_sync_wasm" -crate_dir = "crates/replica_sync_wasm" -package = "@radroots/replica-sync-wasm" -package_dir = "packages/replica-sync-wasm" -out_name = "radroots_replica_sync_wasm" -out_dir = "../../packages/replica-sync-wasm/dist" - -[rollout] -stage = "active" -order = 1 - -[operations] -"farm.build_draft" = "farm.buildDraft" - -[shared_types] -"RadrootsNip01EventWireParts" = "RadrootsNip01EventWireParts" -"RadrootsNip01EventWireDto" = "RadrootsNip01EventWireDto" -"RadrootsEventDraft" = "RadrootsEventDraft" -"RadrootsSignedEventDto" = "RadrootsSignedEventDto" -"RadrootsVerifiedSignedEventDto" = "RadrootsVerifiedSignedEventDto" -"RadrootsEventEnvelopeDto" = "RadrootsEventEnvelopeDto" - -[artifacts] -models_dir = "src/generated" -runtime_dir = "src/runtime" -wasm_dist_dir = "dist" -manifest_file = "export-manifest.json" -"#; -} diff --git a/tools/sdk_xtask_import/src/coverage.rs b/tools/sdk_xtask_import/src/coverage.rs @@ -1,156 +0,0 @@ -use std::{fs, path::Path, process::Command}; - -use crate::{ - check, - coverage_policy::{CoverageContract, evaluate_report, validate_contract}, - fs::workspace_root, - generate, wasm, -}; - -pub fn run(args: &[String]) -> Result<(), String> { - match args { - [command] if command == "run" => run_coverage(), - [command] if command == "check" => check_coverage(), - [] => Err(usage()), - _ => Err(usage()), - } -} - -fn usage() -> String { - "usage: cargo xtask coverage run | cargo xtask coverage check".to_owned() -} - -fn run_coverage() -> Result<(), String> { - let root = workspace_root()?; - let contract = load_contract(&root)?; - validate_contract(&contract)?; - preflight(&contract)?; - generate::generate_ts()?; - wasm::generate(&[])?; - check::check()?; - clean_report_output(&root, &contract)?; - run_llvm_cov(&root, &contract)?; - evaluate_report(&root, &root.join(&contract.report.output), &contract) -} - -fn check_coverage() -> Result<(), String> { - let root = workspace_root()?; - let contract = load_contract(&root)?; - validate_contract(&contract)?; - evaluate_report(&root, &root.join(&contract.report.output), &contract) -} - -fn load_contract(root: &Path) -> Result<CoverageContract, String> { - let path = root.join("contracts").join("coverage.toml"); - let raw = fs::read_to_string(&path) - .map_err(|error| format!("failed to read {}: {error}", path.display()))?; - toml::from_str(&raw).map_err(|error| format!("failed to parse {}: {error}", path.display())) -} - -fn preflight(contract: &CoverageContract) -> Result<(), String> { - require_command("rustup", &["--version"], "install rustup")?; - require_command( - "rustup", - &[ - "run", - &contract.toolchain.coverage_rust, - "cargo", - "llvm-cov", - "--version", - ], - "install cargo-llvm-cov for the SDK Rust toolchain", - )?; - let component_output = output( - "rustup", - &[ - "component", - "list", - "--toolchain", - &contract.toolchain.coverage_rust, - ], - ) - .map_err(|error| format!("failed to inspect Rust components: {error}"))?; - if !component_output - .lines() - .any(|line| line.starts_with("llvm-tools") && line.contains("(installed)")) - { - return Err(format!( - "missing llvm-tools-preview for Rust toolchain {}; run `rustup component add llvm-tools-preview --toolchain {}`", - contract.toolchain.coverage_rust, contract.toolchain.coverage_rust - )); - } - let target_output = output( - "rustup", - &["target", "list", "--toolchain", &contract.toolchain.rust], - ) - .map_err(|error| format!("failed to inspect Rust targets: {error}"))?; - let expected_target = format!("{} (installed)", contract.toolchain.wasm_target); - if !target_output.lines().any(|line| line == expected_target) { - return Err(format!( - "missing Rust target {}; run `rustup target add {} --toolchain {}`", - contract.toolchain.wasm_target, contract.toolchain.wasm_target, contract.toolchain.rust - )); - } - require_command("wasm-pack", &["--version"], "install wasm-pack")?; - require_command("pnpm", &["--version"], "install pnpm")?; - Ok(()) -} - -fn require_command(command: &str, args: &[&str], install_hint: &str) -> Result<(), String> { - output(command, args) - .map(|_| ()) - .map_err(|error| format!("missing {command}: {error}; {install_hint}")) -} - -fn output(command: &str, args: &[&str]) -> Result<String, String> { - let output = Command::new(command) - .args(args) - .output() - .map_err(|error| error.to_string())?; - if !output.status.success() { - return Err(String::from_utf8_lossy(&output.stderr).trim().to_string()); - } - Ok(String::from_utf8_lossy(&output.stdout).to_string()) -} - -fn clean_report_output(root: &Path, contract: &CoverageContract) -> Result<(), String> { - let output_path = root.join(&contract.report.output); - if let Some(parent) = output_path.parent() - && parent.exists() - { - fs::remove_dir_all(parent) - .map_err(|error| format!("failed to remove {}: {error}", parent.display()))?; - } - Ok(()) -} - -fn run_llvm_cov(root: &Path, contract: &CoverageContract) -> Result<(), String> { - let output_path = root.join(&contract.report.output); - if let Some(parent) = output_path.parent() { - fs::create_dir_all(parent) - .map_err(|error| format!("failed to create {}: {error}", parent.display()))?; - } - let status = Command::new("rustup") - .current_dir(root) - .args([ - "run", - &contract.toolchain.coverage_rust, - "cargo", - "llvm-cov", - "--workspace", - "--all-features", - "--json", - "--branch", - "--output-path", - &contract.report.output, - "--ignore-filename-regex", - &contract.report.ignore_filename_regex, - "--no-fail-fast", - ]) - .status() - .map_err(|error| format!("failed to run cargo llvm-cov: {error}"))?; - if !status.success() { - return Err(format!("cargo llvm-cov failed with status {status}")); - } - Ok(()) -} diff --git a/tools/sdk_xtask_import/src/coverage_policy.rs b/tools/sdk_xtask_import/src/coverage_policy.rs @@ -1,803 +0,0 @@ -use std::{ - collections::BTreeMap, - fs, - path::{Component, Path, PathBuf}, -}; - -use serde::Deserialize; - -#[derive(Debug, Deserialize)] -#[serde(deny_unknown_fields)] -pub(crate) struct CoverageContract { - policy: CoveragePolicy, - pub(crate) toolchain: CoverageToolchain, - pub(crate) report: CoverageReport, - generated: GeneratedCoveragePolicy, - scopes: BTreeMap<String, CoverageScope>, - exclusions: BTreeMap<String, CoverageExclusion>, -} - -#[derive(Debug, Deserialize)] -#[serde(deny_unknown_fields)] -struct CoveragePolicy { - enforce: bool, - require_regions: bool, - require_functions: bool, - require_executable_lines: bool, - require_branches: bool, -} - -#[derive(Debug, Deserialize)] -#[serde(deny_unknown_fields)] -pub(crate) struct CoverageToolchain { - pub(crate) rust: String, - pub(crate) coverage_rust: String, - pub(crate) wasm_target: String, -} - -#[derive(Debug, Deserialize)] -#[serde(deny_unknown_fields)] -pub(crate) struct CoverageReport { - pub(crate) output: String, - pub(crate) ignore_filename_regex: String, -} - -#[derive(Debug, Deserialize)] -#[serde(deny_unknown_fields)] -struct GeneratedCoveragePolicy { - typescript: String, - binding_crates: String, - wasm_glue: String, -} - -#[derive(Debug, Deserialize)] -#[serde(deny_unknown_fields)] -struct CoverageScope { - paths: Vec<String>, - threshold: f64, - #[serde(default = "default_true")] - branches_applicable: bool, - branch_reason: Option<String>, -} - -#[derive(Debug, Deserialize)] -#[serde(deny_unknown_fields)] -struct CoverageExclusion { - paths: Vec<String>, - reason: String, -} - -const fn default_true() -> bool { - true -} - -#[derive(Debug, Deserialize)] -struct LlvmCovReport { - data: Vec<LlvmCovData>, -} - -#[derive(Debug, Deserialize)] -struct LlvmCovData { - files: Vec<LlvmCovFile>, - #[serde(default)] - functions: Vec<LlvmCovFunction>, - totals: LlvmCovSummary, -} - -#[derive(Debug, Deserialize)] -struct LlvmCovFunction { - count: u64, - filenames: Vec<String>, - regions: Vec<Vec<u64>>, - #[serde(default)] - branches: Vec<Vec<u64>>, -} - -#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)] -struct FunctionKey { - filenames: Vec<String>, - definition: RegionKey, -} - -#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)] -struct RegionKey { - line_start: u64, - column_start: u64, - line_end: u64, - column_end: u64, - kind: u64, -} - -#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)] -struct BranchKey { - line_start: u64, - column_start: u64, - line_end: u64, - column_end: u64, - kind: u64, -} - -#[derive(Debug)] -struct CoverageSource { - raw: String, - cfg_test_lines: Vec<bool>, - coverage_off_lines: Vec<bool>, -} - -#[derive(Debug, Deserialize)] -struct LlvmCovFile { - filename: String, - summary: LlvmCovSummary, -} - -#[derive(Debug, Deserialize)] -struct LlvmCovSummary { - lines: LlvmCovMetric, - functions: LlvmCovMetric, - regions: LlvmCovMetric, - branches: LlvmCovMetric, -} - -#[derive(Debug, Deserialize)] -struct LlvmCovMetric { - count: u64, - covered: u64, - percent: f64, -} - -#[derive(Debug, Default)] -struct MetricAccumulator { - count: u64, - covered: u64, -} - -impl MetricAccumulator { - fn add(&mut self, metric: &LlvmCovMetric) { - self.count += metric.count; - self.covered += metric.covered; - } - - fn metric(&self) -> LlvmCovMetric { - LlvmCovMetric { - count: self.count, - covered: self.covered, - percent: metric_percent(self.count, self.covered), - } - } -} - -#[derive(Debug, Default)] -struct SummaryAccumulator { - lines: MetricAccumulator, - functions: MetricAccumulator, - regions: MetricAccumulator, - branches: MetricAccumulator, - matched_files: usize, -} - -impl SummaryAccumulator { - fn add(&mut self, summary: &LlvmCovSummary) { - self.lines.add(&summary.lines); - self.functions.add(&summary.functions); - self.regions.add(&summary.regions); - self.branches.add(&summary.branches); - self.matched_files += 1; - } - - fn summary(&self) -> LlvmCovSummary { - LlvmCovSummary { - lines: self.lines.metric(), - functions: self.functions.metric(), - regions: self.regions.metric(), - branches: self.branches.metric(), - } - } -} - -pub(crate) fn validate_contract(contract: &CoverageContract) -> Result<(), String> { - validate_non_empty(&contract.toolchain.rust, "toolchain.rust")?; - validate_non_empty(&contract.toolchain.coverage_rust, "toolchain.coverage_rust")?; - validate_non_empty(&contract.toolchain.wasm_target, "toolchain.wasm_target")?; - validate_non_empty(&contract.report.output, "report.output")?; - validate_non_empty( - &contract.report.ignore_filename_regex, - "report.ignore_filename_regex", - )?; - validate_non_empty(&contract.generated.typescript, "generated.typescript")?; - validate_non_empty( - &contract.generated.binding_crates, - "generated.binding_crates", - )?; - validate_non_empty(&contract.generated.wasm_glue, "generated.wasm_glue")?; - if contract.scopes.is_empty() { - return Err("contracts/coverage.toml scopes must not be empty".to_owned()); - } - for (name, scope) in &contract.scopes { - validate_non_empty(name, "scope name")?; - validate_threshold(scope.threshold, &format!("scopes.{name}.threshold"))?; - if scope.paths.is_empty() { - return Err(format!("scopes.{name}.paths must not be empty")); - } - for path in &scope.paths { - validate_non_empty(path, &format!("scopes.{name}.paths entry"))?; - } - if scope.branches_applicable { - if scope.branch_reason.is_some() { - return Err(format!( - "scopes.{name}.branch_reason requires branches_applicable = false" - )); - } - } else { - validate_non_empty( - scope.branch_reason.as_deref().unwrap_or_default(), - &format!("scopes.{name}.branch_reason"), - )?; - } - } - if contract.exclusions.is_empty() { - return Err("contracts/coverage.toml exclusions must not be empty".to_owned()); - } - for (name, exclusion) in &contract.exclusions { - validate_non_empty(name, "exclusion name")?; - validate_non_empty(&exclusion.reason, &format!("exclusions.{name}.reason"))?; - if exclusion.paths.is_empty() { - return Err(format!("exclusions.{name}.paths must not be empty")); - } - for path in &exclusion.paths { - validate_non_empty(path, &format!("exclusions.{name}.paths entry"))?; - } - } - Ok(()) -} - -fn validate_threshold(threshold: f64, field: &str) -> Result<(), String> { - if (0.0..=100.0).contains(&threshold) { - Ok(()) - } else { - Err(format!( - "contracts/coverage.toml {field} must be between 0 and 100" - )) - } -} - -fn validate_non_empty(value: &str, field: &str) -> Result<(), String> { - if value.trim().is_empty() { - Err(format!("contracts/coverage.toml {field} must not be empty")) - } else { - Ok(()) - } -} - -pub(crate) fn evaluate_report( - root: &Path, - report_path: &Path, - contract: &CoverageContract, -) -> Result<(), String> { - let raw = fs::read_to_string(report_path) - .map_err(|error| format!("failed to read {}: {error}", report_path.display()))?; - let report = serde_json::from_str::<LlvmCovReport>(&raw) - .map_err(|error| format!("failed to parse {}: {error}", report_path.display()))?; - let data = report - .data - .first() - .ok_or_else(|| format!("{} did not include coverage data", report_path.display()))?; - validate_metric( - "total executable lines", - &data.totals.lines, - contract.policy.require_executable_lines, - )?; - validate_metric( - "total functions", - &data.totals.functions, - contract.policy.require_functions, - )?; - validate_metric( - "total regions", - &data.totals.regions, - contract.policy.require_regions, - )?; - validate_metric( - "total branches", - &data.totals.branches, - contract.policy.require_branches, - )?; - let mut failures = Vec::new(); - for (scope_name, scope) in &contract.scopes { - let scope_summary = match scope_summary(root, data, scope) { - Ok(summary) => summary, - Err(error) => { - failures.push(format!("coverage scope {scope_name}: {error}")); - continue; - } - }; - collect_scope_metric_failure( - &mut failures, - scope_name, - "executable lines", - &scope_summary.lines, - scope.threshold, - contract.policy.require_executable_lines, - ); - collect_scope_metric_failure( - &mut failures, - scope_name, - "functions", - &scope_summary.functions, - scope.threshold, - contract.policy.require_functions, - ); - collect_scope_metric_failure( - &mut failures, - scope_name, - "regions", - &scope_summary.regions, - scope.threshold, - contract.policy.require_regions, - ); - if scope.branches_applicable { - collect_scope_metric_failure( - &mut failures, - scope_name, - "branches", - &scope_summary.branches, - scope.threshold, - contract.policy.require_branches, - ); - } else if scope_summary.branches.count != 0 { - failures.push(format!( - "coverage scope {scope_name}: declared branches inapplicable but measured {} branch records", - scope_summary.branches.count - )); - } - } - if !contract.policy.enforce { - println!( - "coverage policy parsed and measured; enforcement disabled in {}", - report_path.display() - ); - return Ok(()); - } - if !failures.is_empty() { - return Err(failures.join("\n")); - } - println!("coverage policy passed using {}", report_path.display()); - Ok(()) -} - -fn scope_summary( - root: &Path, - data: &LlvmCovData, - scope: &CoverageScope, -) -> Result<LlvmCovSummary, String> { - let mut accumulator = SummaryAccumulator::default(); - for file in &data.files { - let filename = report_filename(root, &file.filename); - if scope - .paths - .iter() - .any(|pattern| path_matches(pattern, &filename)) - { - accumulator.add(&file.summary); - } - } - if accumulator.matched_files == 0 { - return Err(format!( - "matched no report files for {}", - scope.paths.join(", ") - )); - } - let mut summary = accumulator.summary(); - if !data.functions.is_empty() { - let (lines, functions, regions, branches) = normalized_source_metrics(root, data, scope)?; - summary.lines = lines; - summary.functions = functions; - summary.regions = regions; - if branches.count > 0 { - summary.branches = branches; - } - } - Ok(summary) -} - -fn normalized_source_metrics( - root: &Path, - data: &LlvmCovData, - scope: &CoverageScope, -) -> Result<(LlvmCovMetric, LlvmCovMetric, LlvmCovMetric, LlvmCovMetric), String> { - let mut groups = BTreeMap::<FunctionKey, Vec<&LlvmCovFunction>>::new(); - for function in &data.functions { - if function.filenames.is_empty() || function.regions.is_empty() { - continue; - } - let Some(definition) = function - .regions - .first() - .and_then(|region| region_key(region)) - else { - continue; - }; - groups - .entry(FunctionKey { - filenames: function.filenames.clone(), - definition, - }) - .or_default() - .push(function); - } - let mut function_count = 0_u64; - let mut function_covered = 0_u64; - let mut all_regions = BTreeMap::<(String, RegionKey), bool>::new(); - let mut lines = BTreeMap::<(String, u64), bool>::new(); - let mut branches = BTreeMap::<(String, BranchKey), (bool, bool)>::new(); - let mut source_cache = BTreeMap::<PathBuf, Option<CoverageSource>>::new(); - for variants in groups.values() { - let primary_definition = variants.iter().find_map(|function| { - let region = function.regions.first()?; - let filename = region_filename(function, region)?; - let relative = report_filename(root, filename); - if scope.paths.iter().any(|path| path_matches(path, &relative)) { - Some((filename, region[0])) - } else { - None - } - }); - let Some((primary, definition_line)) = primary_definition else { - continue; - }; - if variants.iter().all(|function| { - function.regions.first().is_some_and(|region| { - region - .first() - .is_some_and(|line| is_ignorable_source_line(primary, *line, &mut source_cache)) - }) - }) { - continue; - } - if is_authored_function_line(primary, definition_line, &mut source_cache) { - function_count += 1; - if variants.iter().any(|function| function.count > 0) { - function_covered += 1; - } - } - let mut regions = BTreeMap::<(String, RegionKey), bool>::new(); - for function in variants { - for region in &function.regions { - let filename = region_filename(function, region) - .expect("grouped coverage functions always retain a source filename"); - let relative = report_filename(root, filename); - if !scope.paths.iter().any(|path| path_matches(path, &relative)) { - continue; - } - let Some(key) = region_key(region) else { - continue; - }; - let covered = region.get(4).is_some_and(|count| *count > 0); - regions - .entry((filename.to_owned(), key)) - .and_modify(|existing| *existing |= covered) - .or_insert(covered); - } - } - for ((filename, region), covered) in regions { - if is_ignorable_source_line(&filename, region.line_start, &mut source_cache) { - continue; - } - let filename = normalized_path_string(Path::new(&filename)); - all_regions - .entry((filename.clone(), region.clone())) - .and_modify(|existing| *existing |= covered) - .or_insert(covered); - if region.kind == 0 { - for line in region.line_start..=region.line_end { - if !is_ignorable_source_line(&filename, line, &mut source_cache) { - lines - .entry((filename.clone(), line)) - .and_modify(|existing| *existing |= covered) - .or_insert(covered); - } - } - } - } - for function in variants { - for branch in &function.branches { - let filename = branch_filename(function, branch) - .expect("grouped coverage functions always retain a source filename"); - let relative = report_filename(root, filename); - if !scope.paths.iter().any(|path| path_matches(path, &relative)) { - continue; - } - let Some(key) = branch_key(branch) else { - continue; - }; - if is_ignorable_branch(filename, &key, &mut source_cache) { - continue; - } - let true_covered = branch.get(4).is_some_and(|count| *count > 0); - let false_covered = branch.get(5).is_some_and(|count| *count > 0); - let filename = normalized_path_string(Path::new(filename)); - branches - .entry((filename, key)) - .and_modify(|covered| { - covered.0 |= true_covered; - covered.1 |= false_covered; - }) - .or_insert((true_covered, false_covered)); - } - } - } - let line_count = lines.len() as u64; - let line_covered = lines.values().filter(|covered| **covered).count() as u64; - let branch_count = (branches.len() * 2) as u64; - let branch_covered = branches - .values() - .map(|covered| u64::from(covered.0) + u64::from(covered.1)) - .sum::<u64>(); - let region_count = all_regions.len() as u64; - let region_covered = all_regions.values().filter(|covered| **covered).count() as u64; - Ok(( - LlvmCovMetric { - count: line_count, - covered: line_covered, - percent: metric_percent(line_count, line_covered), - }, - LlvmCovMetric { - count: function_count, - covered: function_covered, - percent: metric_percent(function_count, function_covered), - }, - LlvmCovMetric { - count: region_count, - covered: region_covered, - percent: metric_percent(region_count, region_covered), - }, - LlvmCovMetric { - count: branch_count, - covered: branch_covered, - percent: metric_percent(branch_count, branch_covered), - }, - )) -} - -fn region_filename<'a>(function: &'a LlvmCovFunction, region: &[u64]) -> Option<&'a str> { - region - .get(5) - .and_then(|index| function.filenames.get(*index as usize)) - .or_else(|| function.filenames.first()) - .map(String::as_str) -} - -fn branch_filename<'a>(function: &'a LlvmCovFunction, branch: &[u64]) -> Option<&'a str> { - branch - .get(6) - .and_then(|index| function.filenames.get(*index as usize)) - .or_else(|| function.filenames.first()) - .map(String::as_str) -} - -fn is_authored_function_line( - filename: &str, - line: u64, - cache: &mut BTreeMap<PathBuf, Option<CoverageSource>>, -) -> bool { - let path = PathBuf::from(filename); - if !cache.contains_key(&path) { - let _ = is_ignorable_source_line(filename, line, cache); - } - cache - .get(&path) - .and_then(Option::as_ref) - .and_then(|source| source.raw.lines().nth(line.saturating_sub(1) as usize)) - .is_some_and(|source_line| source_line.contains("fn ")) -} - -fn region_key(region: &[u64]) -> Option<RegionKey> { - Some(RegionKey { - line_start: *region.first()?, - column_start: *region.get(1)?, - line_end: *region.get(2)?, - column_end: *region.get(3)?, - kind: *region.get(7)?, - }) -} - -fn branch_key(branch: &[u64]) -> Option<BranchKey> { - Some(BranchKey { - line_start: *branch.first()?, - column_start: *branch.get(1)?, - line_end: *branch.get(2)?, - column_end: *branch.get(3)?, - kind: *branch.get(8)?, - }) -} - -fn is_ignorable_source_line( - filename: &str, - line: u64, - cache: &mut BTreeMap<PathBuf, Option<CoverageSource>>, -) -> bool { - let path = PathBuf::from(filename); - let source = cache.entry(path.clone()).or_insert_with(|| { - fs::read_to_string(path).ok().map(|raw| CoverageSource { - raw: raw.clone(), - cfg_test_lines: annotated_source_lines(&raw, "cfg(test)"), - coverage_off_lines: annotated_source_lines( - &raw, - "cfg_attr(coverage_nightly, coverage(off))", - ), - }) - }); - let Some(source) = source else { - return false; - }; - line.checked_sub(1) - .map(|index| { - let index = index as usize; - source.cfg_test_lines.get(index).copied().unwrap_or(false) - || source - .coverage_off_lines - .get(index) - .copied() - .unwrap_or(false) - }) - .unwrap_or(false) -} - -fn is_ignorable_branch( - filename: &str, - branch: &BranchKey, - cache: &mut BTreeMap<PathBuf, Option<CoverageSource>>, -) -> bool { - if is_ignorable_source_line(filename, branch.line_start, cache) { - return true; - } - if branch.line_start != branch.line_end { - return false; - } - let path = PathBuf::from(filename); - let Some(Some(source)) = cache.get(&path) else { - return false; - }; - let Some(line) = source - .raw - .lines() - .nth(branch.line_start.saturating_sub(1) as usize) - else { - return false; - }; - let start = branch.column_start.saturating_sub(1) as usize; - let end = branch.column_end.saturating_sub(1) as usize; - let slice = line.get(start..end); - (branch.column_end == branch.column_start + 1 && slice == Some("?")) - || line.contains("unreachable!()") - || (line.contains("assert!(matches!(") && slice == Some("matches!")) -} - -fn annotated_source_lines(source: &str, marker: &str) -> Vec<bool> { - let mut pending = false; - let mut depth = None; - let mut lines = Vec::with_capacity(source.lines().count()); - for line in source.lines() { - let trimmed = line.trim(); - let mut annotated = depth.is_some(); - let marker_matches = if marker == "cfg(test)" { - trimmed.starts_with("#[cfg(test)]") || trimmed.starts_with("#[cfg(all(test,") - } else { - trimmed.contains(marker) - }; - if depth.is_none() && marker_matches { - pending = true; - annotated = true; - } else if depth.is_none() && pending { - annotated = true; - let content = - !trimmed.is_empty() && !trimmed.starts_with("//") && !trimmed.starts_with("#["); - if content { - let delta = brace_delta(trimmed); - if delta > 0 { - depth = Some(0_i64); - pending = false; - } else if trimmed.contains('{') || trimmed.ends_with(';') { - pending = false; - } - } - } - lines.push(annotated); - if let Some(current) = depth.as_mut() { - *current += brace_delta(trimmed); - if *current <= 0 { - depth = None; - } - } - } - lines -} - -fn brace_delta(line: &str) -> i64 { - line.bytes().filter(|byte| *byte == b'{').count() as i64 - - line.bytes().filter(|byte| *byte == b'}').count() as i64 -} - -fn report_filename(root: &Path, filename: &str) -> String { - let path = normalize_path(Path::new(filename)); - let root = normalize_path(root); - let relative = path.strip_prefix(&root).unwrap_or(&path); - normalized_path_string(relative) -} - -fn normalize_path(path: &Path) -> PathBuf { - let mut normalized = PathBuf::new(); - for component in path.components() { - match component { - Component::CurDir => {} - Component::ParentDir => { - let _ = normalized.pop(); - } - Component::Prefix(_) | Component::RootDir | Component::Normal(_) => { - normalized.push(component.as_os_str()); - } - } - } - normalized -} - -fn normalized_path_string(path: &Path) -> String { - normalize_path(path).to_string_lossy().replace('\\', "/") -} - -fn path_matches(pattern: &str, path: &str) -> bool { - if let Some(prefix) = pattern.strip_suffix("/**") { - path == prefix || path.starts_with(&format!("{prefix}/")) - } else { - path == pattern - } -} - -fn collect_scope_metric_failure( - failures: &mut Vec<String>, - scope_name: &str, - metric_name: &str, - metric: &LlvmCovMetric, - threshold: f64, - required: bool, -) { - if let Err(error) = validate_metric(metric_name, metric, required) { - failures.push(format!("coverage scope {scope_name}: {error}")); - } - if let Err(error) = enforce_metric(metric_name, metric, threshold) { - failures.push(format!("coverage scope {scope_name}: {error}")); - } -} - -fn validate_metric(name: &str, metric: &LlvmCovMetric, required: bool) -> Result<(), String> { - if required && metric.count == 0 { - return Err(format!( - "coverage report did not include required {name} records" - )); - } - if metric.covered > metric.count { - return Err(format!("coverage report has invalid {name} counts")); - } - Ok(()) -} - -fn enforce_metric(name: &str, metric: &LlvmCovMetric, threshold: f64) -> Result<(), String> { - if metric.percent < threshold { - return Err(format!( - "coverage {name} {:.3}% ({}/{}) is below required {:.1}%", - metric.percent, metric.covered, metric.count, threshold - )); - } - Ok(()) -} - -fn metric_percent(count: u64, covered: u64) -> f64 { - if count == 0 { - 0.0 - } else { - covered as f64 * 100.0 / count as f64 - } -} - -#[cfg(test)] -#[path = "coverage_policy_tests.rs"] -mod tests; diff --git a/tools/sdk_xtask_import/src/coverage_policy_tests.rs b/tools/sdk_xtask_import/src/coverage_policy_tests.rs @@ -1,661 +0,0 @@ -use std::{ - fs, - path::{Path, PathBuf}, - time::{SystemTime, UNIX_EPOCH}, -}; - -use super::{ - BranchKey, CoverageContract, LlvmCovFunction, LlvmCovMetric, LlvmCovReport, - annotated_source_lines, brace_delta, branch_filename, branch_key, enforce_metric, - evaluate_report, is_authored_function_line, is_ignorable_branch, is_ignorable_source_line, - metric_percent, normalized_source_metrics, path_matches, region_filename, region_key, - report_filename, validate_contract, validate_metric, -}; - -const CONTRACT: &str = r#" -[policy] -enforce = true -require_regions = true -require_functions = true -require_executable_lines = true -require_branches = true - -[toolchain] -rust = "1.97.1" -coverage_rust = "nightly" -wasm_target = "wasm32-unknown-unknown" - -[report] -output = "target/sdk-coverage/summary.json" -ignore_filename_regex = "generated" - -[generated] -typescript = "generated TypeScript is checked elsewhere" -binding_crates = "generated binding crates are checked elsewhere" -wasm_glue = "wasm glue is checked through package validation" - -[scopes.xtask_policy] -paths = ["tools/xtask/src/coverage_policy.rs"] -threshold = 90.0 - -[exclusions.generated] -paths = ["packages/*/src/generated/**"] -reason = "generated output is checked through reproducibility" -"#; - -#[derive(Clone, Copy)] -struct Metrics { - lines: (u64, u64, f64), - functions: (u64, u64, f64), - regions: (u64, u64, f64), - branches: (u64, u64, f64), -} - -fn covered() -> Metrics { - Metrics { - lines: (100, 100, 100.0), - functions: (50, 50, 100.0), - regions: (200, 200, 100.0), - branches: (80, 80, 100.0), - } -} - -fn contract(raw: &str) -> CoverageContract { - toml::from_str::<CoverageContract>(raw).expect("contract parses") -} - -fn test_root(name: &str) -> PathBuf { - let stamp = SystemTime::now() - .duration_since(UNIX_EPOCH) - .expect("time") - .as_nanos(); - let root = std::env::temp_dir().join(format!( - "radroots_sdk_coverage_policy_{name}_{}_{}", - std::process::id(), - stamp - )); - let _ = fs::remove_dir_all(&root); - fs::create_dir_all(&root).expect("create root"); - root -} - -fn metric_json(metric: (u64, u64, f64)) -> String { - format!( - r#"{{"count":{},"covered":{},"percent":{}}}"#, - metric.0, metric.1, metric.2 - ) -} - -fn summary_json(metrics: Metrics) -> String { - format!( - r#"{{"lines":{},"functions":{},"regions":{},"branches":{}}}"#, - metric_json(metrics.lines), - metric_json(metrics.functions), - metric_json(metrics.regions), - metric_json(metrics.branches) - ) -} - -fn report_json(filename: &str, file_metrics: Metrics, totals: Metrics) -> String { - format!( - r#"{{"data":[{{"files":[{{"filename":"{}","summary":{}}}],"totals":{}}}]}}"#, - filename, - summary_json(file_metrics), - summary_json(totals) - ) -} - -fn write_report(root: &Path, raw: &str) -> PathBuf { - let report_path = root.join("summary.json"); - fs::write(&report_path, raw).expect("write report"); - report_path -} - -fn scope_file(root: &Path) -> String { - root.join("tools/xtask/src/coverage_policy.rs") - .display() - .to_string() -} - -#[test] -fn validates_contract_shape() { - validate_contract(&contract(CONTRACT)).expect("contract validates"); -} - -#[test] -fn rejects_blank_contract_fields() { - let cases = [ - ("rust = \"1.97.1\"", "rust = \" \"", "toolchain.rust"), - ( - "coverage_rust = \"nightly\"", - "coverage_rust = \" \"", - "toolchain.coverage_rust", - ), - ( - "wasm_target = \"wasm32-unknown-unknown\"", - "wasm_target = \" \"", - "toolchain.wasm_target", - ), - ( - "output = \"target/sdk-coverage/summary.json\"", - "output = \" \"", - "report.output", - ), - ( - "ignore_filename_regex = \"generated\"", - "ignore_filename_regex = \" \"", - "report.ignore_filename_regex", - ), - ( - "typescript = \"generated TypeScript is checked elsewhere\"", - "typescript = \" \"", - "generated.typescript", - ), - ( - "binding_crates = \"generated binding crates are checked elsewhere\"", - "binding_crates = \" \"", - "generated.binding_crates", - ), - ( - "wasm_glue = \"wasm glue is checked through package validation\"", - "wasm_glue = \" \"", - "generated.wasm_glue", - ), - ( - "paths = [\"tools/xtask/src/coverage_policy.rs\"]", - "paths = [\" \"]", - "scopes.xtask_policy.paths entry", - ), - ( - "reason = \"generated output is checked through reproducibility\"", - "reason = \" \"", - "exclusions.generated.reason", - ), - ( - "paths = [\"packages/*/src/generated/**\"]", - "paths = [\" \"]", - "exclusions.generated.paths entry", - ), - ]; - - for (from, to, expected) in cases { - let raw = CONTRACT.replace(from, to); - let error = validate_contract(&contract(&raw)).expect_err("invalid contract"); - assert!(error.contains(expected), "{error}"); - } -} - -#[test] -fn rejects_contract_collection_errors() { - let mut no_scopes = contract(CONTRACT); - no_scopes.scopes.clear(); - assert_eq!( - validate_contract(&no_scopes).unwrap_err(), - "contracts/coverage.toml scopes must not be empty" - ); - - let mut no_exclusions = contract(CONTRACT); - no_exclusions.exclusions.clear(); - assert_eq!( - validate_contract(&no_exclusions).unwrap_err(), - "contracts/coverage.toml exclusions must not be empty" - ); - - let cases = [ - ( - CONTRACT.replace("[scopes.xtask_policy]", "[scopes.\"\"]"), - "scope name", - ), - ( - CONTRACT.replace("[exclusions.generated]", "[exclusions.\"\"]"), - "exclusion name", - ), - ( - CONTRACT.replace("threshold = 90.0", "threshold = 101.0"), - "scopes.xtask_policy.threshold", - ), - ( - CONTRACT.replace( - "threshold = 90.0", - "threshold = 90.0\nbranch_reason = \"not applicable\"", - ), - "branch_reason requires branches_applicable = false", - ), - ( - CONTRACT.replace( - "threshold = 90.0", - "threshold = 90.0\nbranches_applicable = false", - ), - "scopes.xtask_policy.branch_reason", - ), - ( - CONTRACT.replace( - "paths = [\"tools/xtask/src/coverage_policy.rs\"]", - "paths = []", - ), - "scopes.xtask_policy.paths must not be empty", - ), - ( - CONTRACT.replace("paths = [\"packages/*/src/generated/**\"]", "paths = []"), - "exclusions.generated.paths must not be empty", - ), - ]; - - for (raw, expected) in cases { - let error = validate_contract(&contract(&raw)).expect_err("invalid contract"); - assert!(error.contains(expected), "{error}"); - } -} - -#[test] -fn matches_recursive_scope_paths() { - assert!(path_matches( - "crates/sdk/src/**", - "crates/sdk/src/adapters/radrootsd.rs" - )); - assert!(path_matches("crates/sdk/src/**", "crates/sdk/src")); - assert!(path_matches( - "tools/xtask/src/coverage_policy.rs", - "tools/xtask/src/coverage_policy.rs" - )); - assert!(!path_matches( - "crates/sdk/src/**", - "crates/sql_wasm_runtime/src/lib.rs" - )); -} - -#[test] -fn accepts_passing_reports_and_rejects_undercovered_scopes() { - let root = test_root("passing_and_undercovered"); - let filename = scope_file(&root); - let passing_report = report_json(&filename, covered(), covered()); - let report_path = write_report(&root, &passing_report); - evaluate_report(&root, &report_path, &contract(CONTRACT)).expect("passing report"); - - let mut undercovered = covered(); - undercovered.lines = (100, 89, 89.0); - let failing_report = report_json(&filename, undercovered, covered()); - fs::write(&report_path, failing_report).expect("write failing report"); - let error = evaluate_report(&root, &report_path, &contract(CONTRACT)) - .expect_err("undercovered report rejected"); - assert!(error.contains("coverage scope xtask_policy"), "{error}"); - fs::remove_dir_all(root).expect("cleanup"); -} - -#[test] -fn disabled_enforcement_accepts_measured_undercoverage() { - let root = test_root("disabled"); - let filename = scope_file(&root); - let mut undercovered = covered(); - undercovered.lines = (100, 0, 0.0); - undercovered.functions = (50, 0, 0.0); - undercovered.regions = (200, 0, 0.0); - undercovered.branches = (80, 0, 0.0); - let report_path = write_report(&root, &report_json(&filename, undercovered, undercovered)); - let raw = CONTRACT.replace("enforce = true", "enforce = false"); - evaluate_report(&root, &report_path, &contract(&raw)).expect("disabled policy passes"); - fs::remove_dir_all(root).expect("cleanup"); -} - -#[test] -fn branch_inapplicability_is_explicit_and_rejects_measured_branches() { - let root = test_root("branch_inapplicability"); - let filename = scope_file(&root); - let raw = CONTRACT.replace( - "threshold = 90.0", - "threshold = 90.0\nbranches_applicable = false\nbranch_reason = \"straight-line source\"", - ); - let mut no_branches = covered(); - no_branches.branches = (0, 0, 0.0); - let report_path = write_report(&root, &report_json(&filename, no_branches, covered())); - evaluate_report(&root, &report_path, &contract(&raw)).expect("no-branch scope passes"); - - fs::write(&report_path, report_json(&filename, covered(), covered())) - .expect("write measured branches"); - let error = evaluate_report(&root, &report_path, &contract(&raw)) - .expect_err("measured branch drift rejected"); - assert!(error.contains("declared branches inapplicable"), "{error}"); - fs::remove_dir_all(root).expect("cleanup"); -} - -#[test] -fn duplicate_harness_variants_are_merged_and_test_modules_are_excluded() { - let root = test_root("duplicate_harness_variants"); - let filename = scope_file(&root); - let source_path = Path::new(&filename); - fs::create_dir_all(source_path.parent().expect("source parent")).expect("create source parent"); - fs::write( - source_path, - "pub fn production() -> bool { true }\n\n#[cfg(test)]\nmod tests {\n fn helper() -> bool { false }\n}\n", - ) - .expect("write source"); - let report = format!( - r#"{{"data":[{{"files":[{{"filename":"{filename}","summary":{summary}}}],"functions":[{{"count":0,"filenames":["{filename}"],"regions":[[1,1,1,37,0,0,0,0]]}},{{"count":1,"filenames":["{filename}"],"regions":[[1,1,1,37,1,0,0,0]]}},{{"count":0,"filenames":["{filename}"],"regions":[[5,5,5,34,0,0,0,0]]}}],"totals":{summary}}}]}}"#, - summary = summary_json(covered()), - ); - let report_path = write_report(&root, &report); - - evaluate_report(&root, &report_path, &contract(CONTRACT)) - .expect("normalized production coverage passes"); - fs::remove_dir_all(root).expect("cleanup"); -} - -#[test] -fn normalized_details_fail_closed_and_merge_every_supported_record_shape() { - let root = test_root("normalized_record_shapes"); - let filename = scope_file(&root); - let source_path = Path::new(&filename); - fs::create_dir_all(source_path.parent().expect("source parent")).expect("source parent"); - fs::write( - source_path, - "pub fn production(flag: bool) -> bool { if flag { true } else { false } }\nfn uncovered() {}\nconst VALUE: bool = true;\n#[cfg(test)]\nmod tests { fn ignored() {} }\n", - ) - .expect("source"); - let outside = root.join("outside.rs").display().to_string(); - fs::write(&outside, "fn outside() {}\n").expect("outside source"); - let summary = summary_json(covered()); - let report = format!( - r#"{{"data":[{{"files":[],"functions":[ - {{"count":0,"filenames":[],"regions":[],"branches":[]}}, - {{"count":0,"filenames":["{filename}"],"regions":[[1,1,1]],"branches":[]}}, - {{"count":0,"filenames":["{outside}"],"regions":[[1,1,1,16,0,0,0,0]],"branches":[]}}, - {{"count":0,"filenames":["{filename}"],"regions":[[2,1,2,18,0,0,0,0]],"branches":[]}}, - {{"count":1,"filenames":["{filename}"],"regions":[[3,1,3,25,1,0,0,0]],"branches":[]}}, - {{"count":1,"filenames":["{filename}","{outside}"],"regions":[[1,1,1,76,1,0,0,0],[1,1,5,36,1,0,0,0],[2,1,2,18,0,0,0,1],[4,1,4,13,0,0,0,0],[1,1,1,2,0,1,0,0],[1]],"branches":[[1,40,1,47,1,0,0,0,4],[2,1,2,3,0,1,0,0,4],[4,1,4,3,0,0,0,0,4],[99,1,99,3,0,0,0,0,4],[1,1,1,2,0,0,1,0,4],[1]]}}, - {{"count":0,"filenames":["{filename}","{outside}"],"regions":[[1,1,1,76,0,0,0,0],[2,1,2,18,1,0,0,0]],"branches":[[1,40,1,47,0,1,0,0,4],[2,1,2,3,1,0,0,0,4]]}} - ],"totals":{summary}}}]}}"#, - ); - let parsed = serde_json::from_str::<LlvmCovReport>(&report).expect("detail report"); - let policy = contract(CONTRACT); - let scope = policy.scopes.get("xtask_policy").expect("scope"); - let (lines, functions, regions, branches) = - normalized_source_metrics(&root, &parsed.data[0], scope).expect("normalized details"); - assert!(lines.count >= 2); - assert_eq!(functions.count, 2); - assert_eq!(functions.covered, 1); - assert!(regions.count >= 2); - assert_eq!(branches.count, 6); - assert_eq!(branches.covered, 4); - fs::remove_dir_all(root).expect("cleanup"); -} - -#[test] -fn rejects_unreadable_malformed_and_empty_reports() { - let root = test_root("bad_reports"); - let missing = root.join("missing.json"); - assert!(evaluate_report(&root, &missing, &contract(CONTRACT)).is_err()); - - let malformed = write_report(&root, "{"); - assert!(evaluate_report(&root, &malformed, &contract(CONTRACT)).is_err()); - - fs::write(&malformed, r#"{"data":[]}"#).expect("write empty report"); - assert!(evaluate_report(&root, &malformed, &contract(CONTRACT)).is_err()); - fs::remove_dir_all(root).expect("cleanup"); -} - -#[test] -fn rejects_required_total_metric_failures() { - let root = test_root("total_metrics"); - let filename = scope_file(&root); - let mut totals = covered(); - let cases = [ - Metrics { - lines: (0, 0, 0.0), - ..totals - }, - { - totals = covered(); - totals.functions = (0, 0, 0.0); - totals - }, - { - totals = covered(); - totals.regions = (0, 0, 0.0); - totals - }, - { - totals = covered(); - totals.branches = (0, 0, 0.0); - totals - }, - { - totals = covered(); - totals.lines = (1, 2, 200.0); - totals - }, - ]; - - let report_path = root.join("summary.json"); - for total_metrics in cases { - fs::write( - &report_path, - report_json(&filename, covered(), total_metrics), - ) - .expect("write report"); - assert!(evaluate_report(&root, &report_path, &contract(CONTRACT)).is_err()); - } - fs::remove_dir_all(root).expect("cleanup"); -} - -#[test] -fn rejects_scope_metric_validation_and_missing_scope_files() { - let root = test_root("scope_metrics"); - let filename = scope_file(&root); - let other_filename = root - .join("tools/xtask/src/coverage.rs") - .display() - .to_string(); - let report_path = root.join("summary.json"); - - fs::write( - &report_path, - report_json(&other_filename, covered(), covered()), - ) - .expect("write unmatched report"); - let error = evaluate_report(&root, &report_path, &contract(CONTRACT)) - .expect_err("unmatched scope rejected"); - assert!(error.contains("matched no report files"), "{error}"); - - let mut invalid = covered(); - invalid.lines = (0, 0, 0.0); - fs::write(&report_path, report_json(&filename, invalid, covered())).expect("write report"); - assert!(evaluate_report(&root, &report_path, &contract(CONTRACT)).is_err()); - - invalid = covered(); - invalid.functions = (1, 2, 200.0); - fs::write(&report_path, report_json(&filename, invalid, covered())).expect("write report"); - assert!(evaluate_report(&root, &report_path, &contract(CONTRACT)).is_err()); - fs::remove_dir_all(root).expect("cleanup"); -} - -#[test] -fn metric_helpers_cover_edges() { - let valid = LlvmCovMetric { - count: 10, - covered: 10, - percent: 100.0, - }; - validate_metric("lines", &valid, true).expect("metric validates"); - enforce_metric("lines", &valid, 100.0).expect("metric passes"); - - let missing = LlvmCovMetric { - count: 0, - covered: 0, - percent: 0.0, - }; - assert!(validate_metric("lines", &missing, true).is_err()); - assert!(enforce_metric("lines", &missing, 100.0).is_err()); - - let invalid = LlvmCovMetric { - count: 1, - covered: 2, - percent: 200.0, - }; - assert!(validate_metric("lines", &invalid, true).is_err()); - assert_eq!(metric_percent(0, 0), 0.0); - assert_eq!(metric_percent(4, 2), 50.0); -} - -#[test] -fn detail_key_and_path_helpers_cover_valid_and_short_records() { - assert!(region_key(&[1, 2, 3, 4, 5, 6, 7, 8]).is_some()); - assert!(region_key(&[1, 2, 3]).is_none()); - assert!(branch_key(&[1, 2, 3, 4, 5, 6, 7, 8, 9]).is_some()); - assert!(branch_key(&[1, 2, 3]).is_none()); - assert_eq!(brace_delta("fn value() { if true { 1 } else { 0 } }"), 0); - assert_eq!(brace_delta("{{"), 2); - assert_eq!(brace_delta("}"), -1); - - let root = Path::new("/workspace/sdk"); - assert_eq!( - report_filename(root, "/workspace/sdk/crates/sdk/src/lib.rs"), - "crates/sdk/src/lib.rs" - ); - assert_eq!( - report_filename(root, "/elsewhere/lib.rs"), - "/elsewhere/lib.rs" - ); - assert_eq!( - report_filename( - root, - "/workspace/sdk/crates/sdk/src/adapters/../../tests/unit.rs" - ), - "crates/sdk/tests/unit.rs" - ); -} - -#[test] -fn detail_filename_helpers_honor_record_indexes_and_safe_fallbacks() { - let function = LlvmCovFunction { - count: 1, - filenames: vec!["primary.rs".to_owned(), "expanded.rs".to_owned()], - regions: Vec::new(), - branches: Vec::new(), - }; - assert_eq!( - region_filename(&function, &[1, 1, 1, 2, 1, 1, 0, 0]), - Some("expanded.rs") - ); - assert_eq!( - region_filename(&function, &[1, 1, 1, 2, 1, 99, 0, 0]), - Some("primary.rs") - ); - assert_eq!(region_filename(&function, &[1]), Some("primary.rs")); - assert_eq!( - branch_filename(&function, &[1, 1, 1, 2, 1, 0, 1, 0, 4]), - Some("expanded.rs") - ); - assert_eq!( - branch_filename(&function, &[1, 1, 1, 2, 1, 0, 99, 0, 4]), - Some("primary.rs") - ); - assert_eq!(branch_filename(&function, &[1]), Some("primary.rs")); - - let no_filenames = LlvmCovFunction { - count: 0, - filenames: Vec::new(), - regions: Vec::new(), - branches: Vec::new(), - }; - assert_eq!(region_filename(&no_filenames, &[1]), None); - assert_eq!(branch_filename(&no_filenames, &[1]), None); -} - -#[test] -fn authored_and_annotated_source_helpers_cover_marker_shapes() { - let root = test_root("source_helpers"); - let source = root.join("source.rs"); - fs::write( - &source, - "pub fn production() {}\n#[cfg(test)]\nmod tests {\n fn helper() {}\n}\n#[cfg(all(test, feature = \"x\"))]\nfn gated() {}\n#[cfg_attr(coverage_nightly, coverage(off))]\nfn excluded() {}\n", - ) - .expect("source"); - let filename = source.display().to_string(); - let mut cache = std::collections::BTreeMap::new(); - - assert!(is_authored_function_line(&filename, 1, &mut cache)); - assert!(!is_authored_function_line(&filename, 2, &mut cache)); - assert!(!is_ignorable_source_line(&filename, 1, &mut cache)); - assert!(is_ignorable_source_line(&filename, 2, &mut cache)); - assert!(is_ignorable_source_line(&filename, 4, &mut cache)); - assert!(is_ignorable_source_line(&filename, 7, &mut cache)); - assert!(is_ignorable_source_line(&filename, 9, &mut cache)); - assert!(!is_ignorable_source_line(&filename, 0, &mut cache)); - assert!(!is_ignorable_source_line( - root.join("missing.rs").to_str().expect("path"), - 1, - &mut cache - )); - - let marked = annotated_source_lines( - "#[cfg(test)]\n// note\nfn test() {}\nfn live() {}", - "cfg(test)", - ); - assert_eq!(marked, vec![true, true, true, false]); - let coverage_off = annotated_source_lines( - "#[cfg_attr(coverage_nightly, coverage(off))]\n#[inline]\nfn excluded() {\n if true {\n work();\n }\n}\nfn live() {}", - "cfg_attr(coverage_nightly, coverage(off))", - ); - assert_eq!( - coverage_off, - vec![true, true, true, true, true, true, true, false] - ); - assert_eq!( - annotated_source_lines("#[cfg(test)]\nfn declaration();\nfn live() {}", "cfg(test)"), - vec![true, true, false] - ); - fs::remove_dir_all(root).expect("cleanup"); -} - -#[test] -fn branch_filter_only_excludes_explicit_synthetic_constructs() { - let root = test_root("branch_helpers"); - let source = root.join("source.rs"); - fs::write( - &source, - "fn value() {\n let _ = call()?;\n unreachable!();\n assert!(matches!(value, Some(_)));\n if live { work(); }\n}\n", - ) - .expect("source"); - let filename = source.display().to_string(); - let mut cache = std::collections::BTreeMap::new(); - assert!(!is_ignorable_source_line(&filename, 1, &mut cache)); - - let branch = |line_start, column_start, line_end, column_end| BranchKey { - line_start, - column_start, - line_end, - column_end, - kind: 4, - }; - assert!(is_ignorable_branch( - &filename, - &branch(2, 16, 2, 17), - &mut cache - )); - assert!(is_ignorable_branch( - &filename, - &branch(3, 2, 3, 10), - &mut cache - )); - assert!(is_ignorable_branch( - &filename, - &branch(4, 10, 4, 18), - &mut cache - )); - assert!(!is_ignorable_branch( - &filename, - &branch(5, 2, 5, 9), - &mut cache - )); - assert!(!is_ignorable_branch( - &filename, - &branch(5, 2, 6, 1), - &mut cache - )); - assert!(!is_ignorable_branch( - root.join("missing.rs").to_str().expect("path"), - &branch(1, 1, 1, 2), - &mut cache - )); - fs::remove_dir_all(root).expect("cleanup"); -} diff --git a/tools/sdk_xtask_import/src/dto_roots.rs b/tools/sdk_xtask_import/src/dto_roots.rs @@ -1,969 +0,0 @@ -use std::collections::{BTreeMap, BTreeSet}; - -use dto_bindgen_backend_ts::{ - DtoRegistryRenderOptions, DtoTypesModule, TypeScriptDeclaration, TypeScriptModule, - TypeScriptType, render_registry_types, -}; -use dto_bindgen_core::{Registry, RootDescriptor, build_registry}; - -#[derive(Clone, Copy, Debug)] -pub struct DtoPackageRootSet { - pub package_key: &'static str, - roots: fn() -> Vec<RootDescriptor>, -} - -impl DtoPackageRootSet { - pub fn roots(&self) -> Vec<RootDescriptor> { - (self.roots)() - } - - pub fn registry(&self) -> Registry { - build_registry(self.roots()) - } -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub struct ManualDescriptorFamily { - pub package_key: &'static str, - pub source_family: &'static str, - pub reason: &'static str, -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub struct SdkLocalWrapperAllowance { - pub package_key: &'static str, - pub shape_family: &'static str, - pub reason: &'static str, -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -struct DtoExternalOverride { - target_type: &'static str, - import_name: &'static str, - from_package_key: &'static str, - from: &'static str, -} - -const CORE_BINDINGS_PACKAGE_KEY: &str = "core"; -const CORE_BINDINGS_PACKAGE_NAME: &str = "@radroots/core-bindings"; -const EVENT_BINDINGS_PACKAGE_KEY: &str = "event"; -const EVENT_BINDINGS_PACKAGE_NAME: &str = "@radroots/event-bindings"; - -pub const DTO_PACKAGE_ROOTS: &[DtoPackageRootSet] = &[DtoPackageRootSet { - package_key: "core", - roots: core_roots, -}]; - -pub const MANUAL_DESCRIPTOR_FAMILIES: &[ManualDescriptorFamily] = &[ - ManualDescriptorFamily { - package_key: "core", - source_family: "decimal, currency, money, quantity, percent, quantity price, unit, and discount value families", - reason: "custom serde, string-backed newtypes, aliases, and tagged enum wire forms require source-owned manual descriptors", - }, - ManualDescriptorFamily { - package_key: "event", - source_family: "event timestamps, counters, and optional metadata fields", - reason: "large integers and source-specific optional/null policy must be explicit", - }, - ManualDescriptorFamily { - package_key: "event", - source_family: "GeoJSON coordinate arrays", - reason: "fixed-size Rust arrays must preserve tuple semantics in TypeScript", - }, - ManualDescriptorFamily { - package_key: "trade", - source_family: "trade listing roots and package projection count fields", - reason: "core aliases, source-owned event imports, and count-family numeric policy require explicit descriptors", - }, - ManualDescriptorFamily { - package_key: "replica_schema", - source_family: "untagged query wrappers and serde_json value fields", - reason: "schema query shapes are generated and not all source fields map to derive-supported DTOs", - }, -]; - -pub const SDK_LOCAL_WRAPPER_ALLOWANCES: &[SdkLocalWrapperAllowance] = &[ - SdkLocalWrapperAllowance { - package_key: "core", - shape_family: "Currency and Decimal package aliases", - reason: "string-backed source descriptors require canonical named TypeScript package aliases", - }, - SdkLocalWrapperAllowance { - package_key: "replica_schema", - shape_family: "generated query argument wrappers", - reason: "schema operation inputs are generated package shapes rather than source-owned public DTO structs", - }, - SdkLocalWrapperAllowance { - package_key: "trade", - shape_family: "marketplace, query, projection, sort, review, and backoffice DTO shapes", - reason: "these are SDK package contract shapes layered over source-owned trade, events, and core DTOs", - }, -]; - -const EVENT_CORE_EXTERNAL_OVERRIDES: &[DtoExternalOverride] = &[ - core_override("Currency"), - core_override("Decimal"), - core_override("Discount"), - core_override("DiscountScope"), - core_override("DiscountThreshold"), - core_override("DiscountValue"), - core_override("Money"), - core_override("Percent"), - core_override("Quantity"), - core_override("QuantityPrice"), - core_override("Unit"), - core_override("UnitDimension"), -]; - -const TRADE_EXTERNAL_OVERRIDES: &[DtoExternalOverride] = &[ - core_override("Currency"), - core_override("Decimal"), - core_override("Discount"), - core_override("DiscountValue"), - core_override("Money"), - core_override("Quantity"), - core_override("QuantityPrice"), - core_override("Unit"), - event_override("RadrootsFarmRef"), - event_override("RadrootsOperationalListing"), - event_override("RadrootsOperationalListingAvailability"), - event_override("RadrootsOperationalListingBin"), - event_override("RadrootsOperationalListingDeliveryMethod"), - event_override("RadrootsOperationalListingImage"), - event_override("RadrootsOperationalListingProduct"), - event_override("RadrootsOperationalListingPublicLocation"), - event_override("RadrootsOperationalListingStatus"), - event_override("RadrootsEventPtr"), - event_override("RadrootsPlotRef"), - event_override("RadrootsResourceAreaRef"), -]; - -const TRADE_REQUIRED_EXTERNAL_PACKAGE_IMPORTS: &[&str] = - &[CORE_BINDINGS_PACKAGE_NAME, EVENT_BINDINGS_PACKAGE_NAME]; -const EVENT_BINDINGS_TYPES_TS: &str = - include_str!("../../../packages/event-bindings/src/generated/types.ts"); - -pub fn package_root_set(package_key: &str) -> Option<&'static DtoPackageRootSet> { - DTO_PACKAGE_ROOTS - .iter() - .find(|root_set| root_set.package_key == package_key) -} - -pub fn core_types_module() -> Result<DtoTypesModule, String> { - let root_set = package_root_set("core").ok_or_else(|| "missing core DTO roots".to_owned())?; - let rendered = - render_registry_types(&root_set.registry(), &DtoRegistryRenderOptions::default())?; - Ok(with_type_aliases_sorted( - rendered, - [ - type_alias("Currency", TypeScriptType::String), - type_alias("Decimal", TypeScriptType::String), - ], - )) -} - -pub fn replica_schema_types_module() -> Result<DtoTypesModule, String> { - render_registry_types( - &radroots_replica_schema_bindings::dto_registry(), - &DtoRegistryRenderOptions::default(), - ) -} - -fn core_roots() -> Vec<RootDescriptor> { - radroots_core_bindings::dto_roots() -} - -fn core_import_options( - mut options: DtoRegistryRenderOptions, -) -> Result<DtoRegistryRenderOptions, String> { - let package_exports = BTreeMap::from([( - CORE_BINDINGS_PACKAGE_KEY, - type_exports(core_types_module()?.body_ts()), - )]); - for override_target in EVENT_CORE_EXTERNAL_OVERRIDES { - validate_external_override_target(*override_target, &package_exports)?; - options = options.with_external_override( - override_target.target_type, - override_target.import_name, - override_target.from, - ); - } - Ok(options) -} - -fn trade_import_options( - registry: &Registry, - mut options: DtoRegistryRenderOptions, -) -> Result<DtoRegistryRenderOptions, String> { - let package_exports = generated_external_package_exports()?; - for override_target in TRADE_EXTERNAL_OVERRIDES { - validate_external_override_target(*override_target, &package_exports)?; - if let Ok(type_id) = registry.type_id_by_export_name(override_target.target_type) { - options = options.with_external_type( - type_id, - override_target.import_name, - override_target.from, - ); - } - options = options.with_external_override( - override_target.target_type, - override_target.import_name, - override_target.from, - ); - } - - Ok(options) -} - -fn generated_external_package_exports() -> Result<BTreeMap<&'static str, BTreeSet<String>>, String> -{ - Ok(BTreeMap::from([ - ( - CORE_BINDINGS_PACKAGE_KEY, - type_exports(core_types_module()?.body_ts()), - ), - ( - EVENT_BINDINGS_PACKAGE_KEY, - type_exports(EVENT_BINDINGS_TYPES_TS), - ), - ])) -} - -fn validate_external_override_target( - override_target: DtoExternalOverride, - package_exports: &BTreeMap<&'static str, BTreeSet<String>>, -) -> Result<(), String> { - let exports = package_exports - .get(override_target.from_package_key) - .ok_or_else(|| { - format!( - "external DTO override `{}` references unknown package key `{}`", - override_target.target_type, override_target.from_package_key - ) - })?; - if exports.contains(override_target.import_name) { - return Ok(()); - } - Err(format!( - "external DTO override `{}` imports `{}` from `{}`, but package `{}` does not export it", - override_target.target_type, - override_target.import_name, - override_target.from, - override_target.from_package_key - )) -} - -fn validate_external_override_usage( - module: &DtoTypesModule, - overrides: &[DtoExternalOverride], -) -> Result<(), String> { - let imports = imported_type_inventory(module.imports_ts().unwrap_or_default()); - let package_exports = generated_external_package_exports()?; - for package_name in TRADE_REQUIRED_EXTERNAL_PACKAGE_IMPORTS { - if !imports.contains_key(*package_name) { - return Err(format!( - "expected generated DTO imports from `{package_name}` but none were emitted" - )); - } - } - - for override_target in overrides { - if type_exports(module.body_ts()).contains(override_target.target_type) { - return Err(format!( - "external DTO override `{}` from `{}` was emitted locally instead of imported", - override_target.target_type, override_target.from - )); - } - if imports - .get(override_target.from) - .is_some_and(|names| names.contains(override_target.import_name)) - { - validate_external_override_target(*override_target, &package_exports)?; - } - } - Ok(()) -} - -fn imported_type_inventory(imports_ts: &str) -> BTreeMap<String, BTreeSet<String>> { - let mut imports = BTreeMap::new(); - let mut pending_names: Option<Vec<String>> = None; - - for line in imports_ts.lines() { - let line = line.trim(); - if let Some(single) = line.strip_prefix("import type { ") { - if let Some((name, from)) = single.split_once(" } from ") { - insert_import_names(&mut imports, from, name); - } - } else if line == "import type {" { - pending_names = Some(Vec::new()); - } else if let Some(from) = line.strip_prefix("} from ") { - if let Some(names) = pending_names.take() { - for name in names { - insert_import(&mut imports, from, &name); - } - } - } else if let Some(names) = pending_names.as_mut() - && let Some(name) = line.strip_suffix(',') - { - names.push(name.to_owned()); - } - } - - imports -} - -fn with_additional_type_imports( - module: DtoTypesModule, - from: &str, - names: impl IntoIterator<Item = &'static str>, -) -> DtoTypesModule { - let mut imports = imported_type_inventory(module.imports_ts().unwrap_or_default()); - imports - .entry(from.to_owned()) - .or_default() - .extend(names.into_iter().map(str::to_owned)); - let imports_ts = imports - .into_iter() - .map(|(from, names)| { - format!( - "import type {{ {} }} from \"{from}\";", - names.into_iter().collect::<Vec<_>>().join(", ") - ) - }) - .collect::<Vec<_>>() - .join("\n"); - let imports_ts = if imports_ts.is_empty() { - imports_ts - } else { - format!("{imports_ts}\n\n") - }; - DtoTypesModule::new(imports_ts, module.body_ts()) -} - -fn insert_import_names(imports: &mut BTreeMap<String, BTreeSet<String>>, from: &str, names: &str) { - for name in names.split(',') { - insert_import(imports, from, name); - } -} - -fn insert_import(imports: &mut BTreeMap<String, BTreeSet<String>>, from: &str, name: &str) { - let from = from.trim_end_matches(';').trim_matches('"'); - imports - .entry(from.to_owned()) - .or_default() - .insert(name.trim().to_owned()); -} - -fn type_exports(types_ts: &str) -> BTreeSet<String> { - types_ts - .lines() - .filter_map(|line| line.strip_prefix("export type ")) - .filter_map(|rest| rest.split([' ', '<']).next()) - .map(str::to_owned) - .collect() -} - -const fn core_override(export_name: &'static str) -> DtoExternalOverride { - DtoExternalOverride { - target_type: export_name, - import_name: export_name, - from_package_key: CORE_BINDINGS_PACKAGE_KEY, - from: CORE_BINDINGS_PACKAGE_NAME, - } -} - -const fn event_override(export_name: &'static str) -> DtoExternalOverride { - DtoExternalOverride { - target_type: export_name, - import_name: export_name, - from_package_key: EVENT_BINDINGS_PACKAGE_KEY, - from: EVENT_BINDINGS_PACKAGE_NAME, - } -} - -fn with_event_sdk_wrappers(module: DtoTypesModule) -> DtoTypesModule { - let mut declarations = module - .body_ts() - .split("\n\n") - .filter(|declaration| !declaration.trim().is_empty()) - .map(str::to_owned) - .collect::<Vec<_>>(); - declarations.push(type_alias( - "RadrootsOperationalListingProductTagKeys", - TypeScriptType::readonly_tuple( - [ - "key", "title", "category", "summary", "process", "lot", "location", "profile", - "year", - ] - .into_iter() - .map(TypeScriptType::literal_string) - .collect::<Vec<_>>(), - ), - )); - declarations.sort_by(|left, right| declaration_name(left).cmp(declaration_name(right))); - DtoTypesModule::new( - module.imports_ts().unwrap_or_default(), - declarations.join("\n\n"), - ) -} - -fn declaration_name(declaration: &str) -> &str { - declaration - .strip_prefix("export type ") - .and_then(|rest| rest.split([' ', '<']).next()) - .unwrap_or(declaration) -} - -fn with_type_aliases_sorted( - module: DtoTypesModule, - aliases: impl IntoIterator<Item = String>, -) -> DtoTypesModule { - let mut declarations = module - .body_ts() - .split("\n\n") - .filter(|declaration| !declaration.trim().is_empty()) - .map(str::to_owned) - .collect::<Vec<_>>(); - declarations.extend(aliases); - declarations.sort_by(|left, right| declaration_name(left).cmp(declaration_name(right))); - DtoTypesModule::new( - module.imports_ts().unwrap_or_default(), - declarations.join("\n\n"), - ) -} - -fn type_alias(name: impl Into<String>, type_expr: TypeScriptType) -> String { - TypeScriptModule::new("types.ts") - .with_declaration(TypeScriptDeclaration::type_alias(name, type_expr)) - .render_source() - .trim() - .to_owned() -} - -#[cfg(test)] -mod tests { - use std::collections::{BTreeMap, BTreeSet}; - - use dto_bindgen_core::SourceSpan; - - use super::{ - CORE_BINDINGS_PACKAGE_KEY, CORE_BINDINGS_PACKAGE_NAME, DTO_PACKAGE_ROOTS, - DtoExternalOverride, MANUAL_DESCRIPTOR_FAMILIES, SDK_LOCAL_WRAPPER_ALLOWANCES, - event_override, imported_type_inventory, package_root_set, type_exports, - validate_external_override_target, validate_external_override_usage, - with_additional_type_imports, - }; - use dto_bindgen_backend_ts::DtoTypesModule; - - const CORE_BINDINGS_TYPES_TS: &str = - include_str!("../../../packages/core-bindings/src/generated/types.ts"); - const EVENT_BINDINGS_TYPES_TS: &str = - include_str!("../../../packages/event-bindings/src/generated/types.ts"); - const REPLICA_SCHEMA_BINDINGS_TYPES_TS: &str = - include_str!("../../../packages/replica-schema-bindings/src/generated/types.ts"); - const TRADE_BINDINGS_TYPES_TS: &str = - include_str!("../../../packages/trade-bindings/src/generated/types.ts"); - const REPLICA_SCHEMA_MODEL_SOURCES: &[&str] = &[ - include_str!("../../../../lib/crates/replica_schema/src/models/farm.rs"), - include_str!("../../../../lib/crates/replica_schema/src/models/farm_gcs_location.rs"), - include_str!("../../../../lib/crates/replica_schema/src/models/farm_member.rs"), - include_str!("../../../../lib/crates/replica_schema/src/models/farm_member_claim.rs"), - include_str!("../../../../lib/crates/replica_schema/src/models/farm_tag.rs"), - include_str!("../../../../lib/crates/replica_schema/src/models/gcs_location.rs"), - include_str!("../../../../lib/crates/replica_schema/src/models/log_error.rs"), - include_str!("../../../../lib/crates/replica_schema/src/models/media_image.rs"), - include_str!("../../../../lib/crates/replica_schema/src/models/nostr_event_head.rs"), - include_str!("../../../../lib/crates/replica_schema/src/models/nostr_profile.rs"), - include_str!("../../../../lib/crates/replica_schema/src/models/nostr_profile_relay.rs"), - include_str!("../../../../lib/crates/replica_schema/src/models/nostr_relay.rs"), - include_str!("../../../../lib/crates/replica_schema/src/models/plot.rs"), - include_str!("../../../../lib/crates/replica_schema/src/models/plot_gcs_location.rs"), - include_str!("../../../../lib/crates/replica_schema/src/models/plot_tag.rs"), - include_str!("../../../../lib/crates/replica_schema/src/models/trade_product.rs"), - include_str!("../../../../lib/crates/replica_schema/src/models/trade_product_location.rs"), - include_str!("../../../../lib/crates/replica_schema/src/models/trade_product_media.rs"), - ]; - const CORE_TYPE_INVENTORY: &[&str] = &[ - "Currency", - "Decimal", - "Discount", - "DiscountScope", - "DiscountThreshold", - "DiscountValue", - "Money", - "Percent", - "Quantity", - "QuantityPrice", - "Unit", - "UnitDimension", - ]; - const EVENT_TYPE_INVENTORY: &[&str] = &[ - "JobFeedbackStatus", - "JobInputType", - "JobPaymentRequest", - "RadrootsAccountClaim", - "RadrootsAddressableRef", - "RadrootsAppData", - "RadrootsCommercialDomain", - "RadrootsContributionAttestation", - "RadrootsCoop", - "RadrootsCoopLocation", - "RadrootsCoopRef", - "RadrootsDocument", - "RadrootsDocumentSubject", - "RadrootsEventEnvelopeDto", - "RadrootsEventPtr", - "RadrootsEventRef", - "RadrootsEvidenceBounty", - "RadrootsFarm", - "RadrootsFarmPublicLocation", - "RadrootsFarmRef", - "RadrootsFollow", - "RadrootsFollowProfile", - "RadrootsGcsLocation", - "RadrootsGeoChat", - "RadrootsGeoJsonPoint", - "RadrootsGeoJsonPolygon", - "RadrootsGiftWrap", - "RadrootsGiftWrapRecipient", - "RadrootsJobFeedback", - "RadrootsJobInput", - "RadrootsJobParam", - "RadrootsJobRequest", - "RadrootsJobResult", - "RadrootsKnowledgeChangeProposal", - "RadrootsKnowledgeCitationSpan", - "RadrootsKnowledgeClaim", - "RadrootsKnowledgeFieldContext", - "RadrootsKnowledgeFieldReport", - "RadrootsKnowledgeLocation", - "RadrootsKnowledgeLocationPrecision", - "RadrootsKnowledgeNodeRef", - "RadrootsKnowledgeObservation", - "RadrootsKnowledgeObservationValue", - "RadrootsKnowledgeRelation", - "RadrootsKnowledgeReview", - "RadrootsKnowledgeReviewScope", - "RadrootsKnowledgeReviewScore", - "RadrootsKnowledgeReviewTarget", - "RadrootsKnowledgeSource", - "RadrootsList", - "RadrootsListEntry", - "RadrootsListSet", - "RadrootsMessage", - "RadrootsMessageFile", - "RadrootsMessageFileDimensions", - "RadrootsMessageRecipient", - "RadrootsNip01EventWireDto", - "RadrootsOperationalListing", - "RadrootsOperationalListingAvailability", - "RadrootsOperationalListingBin", - "RadrootsOperationalListingDeliveryMethod", - "RadrootsOperationalListingImage", - "RadrootsOperationalListingImageSize", - "RadrootsOperationalListingParseError", - "RadrootsOperationalListingProduct", - "RadrootsOperationalListingProductTagKeys", - "RadrootsOperationalListingPublicLocation", - "RadrootsOperationalListingStatus", - "RadrootsOperationalListingValidationError", - "RadrootsPlot", - "RadrootsPlotLocation", - "RadrootsPlotRef", - "RadrootsPost", - "RadrootsProfileType", - "RadrootsReaction", - "RadrootsRelayDocument", - "RadrootsResourceArea", - "RadrootsResourceAreaLocation", - "RadrootsResourceAreaRef", - "RadrootsResourceHarvestCap", - "RadrootsResourceHarvestProduct", - "RadrootsRightsAssertion", - "RadrootsSeal", - "RadrootsSignedEventDto", - "RadrootsSignedEventVerificationStateDto", - "RadrootsSocialFarmAnchor", - "RadrootsSocialLocation", - "RadrootsSocialMediaDimensions", - "RadrootsSocialMediaMetadata", - "RadrootsSocialMediaThumbnail", - "RadrootsSocialTarget", - "RadrootsVerifiedSignedEventDto", - "RadrootsVerifiedSignedEventVerificationStateDto", - "RadrootsWikiArticle", - "RadrootsWikiArticleVersionRef", - "RadrootsWikiMergeRequest", - "RadrootsWikiRedirect", - ]; - const TRADE_TYPE_INVENTORY: &[&str] = &[ - "RadrootsOperationalListingSubtotal", - "RadrootsOperationalListingTotal", - "RadrootsOperationalListingTradeProjection", - "RadrootsTradeAgreementClaimV1", - "RadrootsTradeAgreementStateV1", - "RadrootsTradeAttestationRecordV1", - "RadrootsTradeAttestationResultV1", - "RadrootsTradeAttestationStateV1", - "RadrootsTradeConflictStateV1", - "RadrootsTradeEvidenceStateV1", - "RadrootsTradeFacetCount", - "RadrootsTradeFulfillmentStateV1", - "RadrootsTradeListingBackofficeOverlay", - "RadrootsTradeListingBackofficeQuery", - "RadrootsTradeListingBackofficeView", - "RadrootsTradeListingBinProjection", - "RadrootsTradeListingFacets", - "RadrootsTradeListingMarketStatus", - "RadrootsTradeListingProjection", - "RadrootsTradeListingQuery", - "RadrootsTradeListingSort", - "RadrootsTradeListingSortField", - "RadrootsTradeMarketplaceListingSummary", - "RadrootsTradeModerationFlag", - "RadrootsTradeModerationSeverity", - "RadrootsTradeModerationStatus", - "RadrootsTradeNegotiationStateV1", - "RadrootsTradePaymentStateV1", - "RadrootsTradePrivateTermsStateV1", - "RadrootsTradeProjectionV1", - "RadrootsTradeReducerIssueV1", - "RadrootsTradeReviewPriority", - "RadrootsTradeReviewQueueEntry", - "RadrootsTradeReviewStatus", - "RadrootsTradeSortDirection", - ]; - - #[test] - fn approved_source_roots_build_registries() { - for root_set in DTO_PACKAGE_ROOTS { - let registry = root_set.registry(); - assert!( - !registry.has_errors(), - "registry for {} has diagnostics: {:?}", - root_set.package_key, - registry.diagnostics - ); - assert!(!registry.roots.is_empty()); - } - } - - #[test] - fn package_roots_are_explicit_not_discovered() { - assert!(package_root_set("core").is_some()); - assert!(package_root_set("event").is_none()); - assert!(package_root_set("trade").is_none()); - } - - #[test] - fn manual_descriptor_catalog_covers_known_review_families() { - assert!( - MANUAL_DESCRIPTOR_FAMILIES - .iter() - .any(|family| family.source_family.contains("GeoJSON")) - ); - assert!(SDK_LOCAL_WRAPPER_ALLOWANCES.iter().any(|allowance| { - allowance - .shape_family - .contains("marketplace, query, projection") - })); - } - - #[test] - fn external_override_target_validation_rejects_missing_generated_export() { - let package_exports = BTreeMap::from([( - CORE_BINDINGS_PACKAGE_KEY, - BTreeSet::from(["ExistingType".to_owned()]), - )]); - let error = validate_external_override_target( - DtoExternalOverride { - target_type: "MissingType", - import_name: "MissingType", - from_package_key: CORE_BINDINGS_PACKAGE_KEY, - from: CORE_BINDINGS_PACKAGE_NAME, - }, - &package_exports, - ) - .expect_err("missing target package export must fail"); - - assert_eq!( - error, - "external DTO override `MissingType` imports `MissingType` from `@radroots/core-bindings`, but package `core` does not export it" - ); - } - - #[test] - fn external_override_usage_rejects_absent_required_package_import() { - let module = DtoTypesModule::new("", "export type RadrootsTradeListing = string;"); - let error = validate_external_override_usage(&module, &[]) - .expect_err("missing required package import must fail"); - - assert_eq!( - error, - "expected generated DTO imports from `@radroots/core-bindings` but none were emitted" - ); - } - - #[test] - fn external_override_usage_rejects_local_emission_for_imported_type() { - let module = DtoTypesModule::new( - "import type { Decimal } from \"@radroots/core-bindings\";\n\nimport type { RadrootsFarmRef } from \"@radroots/event-bindings\";\n\n", - "export type RadrootsFarmRef = { pubkey: string, d_tag: string, };", - ); - let error = validate_external_override_usage(&module, &[event_override("RadrootsFarmRef")]) - .expect_err("local emission of imported type must fail"); - - assert_eq!( - error, - "external DTO override `RadrootsFarmRef` from `@radroots/event-bindings` was emitted locally instead of imported" - ); - } - - #[test] - fn generated_import_inventory_parses_single_and_multiline_imports() { - let imports = imported_type_inventory( - "import type { One, Four } from \"@radroots/one\";\nimport type {\n Two,\n Three,\n} from \"@radroots/many\";\n\n", - ); - - assert_eq!( - imports.get("@radroots/one").expect("single import package"), - &BTreeSet::from(["Four".to_owned(), "One".to_owned()]) - ); - assert_eq!( - imports.get("@radroots/many").expect("multi import package"), - &BTreeSet::from(["Three".to_owned(), "Two".to_owned()]) - ); - } - - #[test] - fn additional_type_imports_merge_with_generated_package_imports() { - let module = DtoTypesModule::new( - "import type { Decimal, Money } from \"@radroots/core-bindings\";\n\n", - "export type Listing = { discounts: Array<Discount>, };", - ); - let merged = with_additional_type_imports(module, CORE_BINDINGS_PACKAGE_NAME, ["Discount"]); - - assert_eq!( - merged.imports_ts(), - Some("import type { Decimal, Discount, Money } from \"@radroots/core-bindings\";\n\n") - ); - } - - #[test] - fn generated_type_export_inventory_parses_type_aliases() { - assert_eq!( - type_exports("export type Alpha = string;\nexport type Beta<T> = T;\n"), - BTreeSet::from(["Alpha".to_owned(), "Beta".to_owned()]) - ); - } - - #[test] - fn core_type_inventory_matches_current_package_surface() { - let actual = type_inventory(CORE_BINDINGS_TYPES_TS); - - assert_eq!(actual, CORE_TYPE_INVENTORY); - assert!(!CORE_BINDINGS_TYPES_TS.contains("RadrootsCore")); - } - - #[test] - fn event_type_inventory_matches_current_package_surface() { - let actual = type_inventory(EVENT_BINDINGS_TYPES_TS); - - assert_eq!(actual, EVENT_TYPE_INVENTORY); - assert!(!EVENT_BINDINGS_TYPES_TS.contains("RadrootsOrder")); - } - - #[test] - fn event_generated_types_expose_current_signed_event_dto_shapes() { - let wire = type_declaration(EVENT_BINDINGS_TYPES_TS, "RadrootsNip01EventWireDto"); - let envelope = type_declaration(EVENT_BINDINGS_TYPES_TS, "RadrootsEventEnvelopeDto"); - let signed = type_declaration(EVENT_BINDINGS_TYPES_TS, "RadrootsSignedEventDto"); - let verified = type_declaration(EVENT_BINDINGS_TYPES_TS, "RadrootsVerifiedSignedEventDto"); - - assert!(wire.contains("pubkey: string")); - assert!(wire.contains("extra: { [key: string]: unknown }")); - assert!(envelope.contains("author: string")); - assert!(!envelope.contains("pubkey")); - assert!(!envelope.contains("extra")); - assert!(signed.contains("state: RadrootsSignedEventVerificationStateDto")); - assert!(signed.contains("envelope: RadrootsEventEnvelopeDto")); - assert!(signed.contains("wire: RadrootsNip01EventWireDto")); - assert!(signed.contains("raw_json: string")); - assert!(verified.contains("state: RadrootsVerifiedSignedEventVerificationStateDto")); - assert!(verified.contains("signed_event: RadrootsSignedEventDto")); - assert!( - !EVENT_BINDINGS_TYPES_TS.contains("export type RadrootsEventEnvelope ="), - "event package must not export the retired raw envelope type name" - ); - } - - #[test] - fn trade_type_inventory_matches_current_package_surface() { - let actual = type_inventory(TRADE_BINDINGS_TYPES_TS); - - assert_eq!(actual, TRADE_TYPE_INVENTORY); - } - - #[test] - fn replica_schema_generated_types_preserve_source_schema_contracts() { - let actual = type_inventory(REPLICA_SCHEMA_BINDINGS_TYPES_TS); - let trade_product_filter = type_declaration( - REPLICA_SCHEMA_BINDINGS_TYPES_TS, - "ITradeProductFieldsFilter", - ); - let trade_product_partial = type_declaration( - REPLICA_SCHEMA_BINDINGS_TYPES_TS, - "ITradeProductFieldsPartial", - ); - - assert!(actual.contains(&"Farm")); - assert!(actual.contains(&"GcsLocation")); - assert!(actual.contains(&"NostrEventHead")); - assert!(actual.contains(&"ReplicaStoreJsonValue")); - assert!(actual.contains(&"ITradeProductFieldsPartial")); - assert!(!actual.contains(&"NostrEventState")); - assert!(REPLICA_SCHEMA_BINDINGS_TYPES_TS.contains( - "export type ReplicaStoreJsonValue = null | boolean | number | string | Array<ReplicaStoreJsonValue> | { [key: string]: ReplicaStoreJsonValue };" - )); - assert!( - REPLICA_SCHEMA_BINDINGS_TYPES_TS - .contains("export type IFarmFindOneResolve = ReplicaSchemaResult<Farm | null>;") - ); - assert!(actual.contains(&"ReplicaSchemaResult")); - assert!(actual.contains(&"ReplicaSchemaResultList")); - assert!(actual.contains(&"ReplicaSchemaResultPass")); - assert!(actual.contains(&"ReplicaSchemaError")); - assert!(trade_product_filter.contains("year?: bigint")); - assert!(trade_product_filter.contains("qty_avail?: bigint")); - assert!(trade_product_partial.contains("year?: ReplicaStoreJsonValue | null")); - assert!(trade_product_partial.contains("qty_avail?: ReplicaStoreJsonValue | null")); - } - - #[test] - fn replica_schema_generated_types_match_source_public_inventory() { - let actual = type_inventory(REPLICA_SCHEMA_BINDINGS_TYPES_TS) - .into_iter() - .collect::<BTreeSet<_>>(); - let missing = source_public_schema_type_inventory() - .into_iter() - .filter(|name| !actual.contains(name)) - .collect::<Vec<_>>(); - - assert!( - missing.is_empty(), - "missing generated replica schema exports: {missing:?}" - ); - } - - #[test] - fn trade_package_imports_source_owned_support_types() { - assert!(TRADE_BINDINGS_TYPES_TS.contains("from \"@radroots/core-bindings\"")); - assert!(TRADE_BINDINGS_TYPES_TS.contains("from \"@radroots/event-bindings\"")); - - let imports = imported_type_inventory(TRADE_BINDINGS_TYPES_TS); - assert!( - imports - .get("@radroots/event-bindings") - .is_some_and(|names| names.contains("RadrootsFarmRef")) - ); - assert!( - imports - .get("@radroots/event-bindings") - .is_some_and(|names| names.contains("RadrootsOperationalListing")) - ); - assert!( - imports - .get("@radroots/event-bindings") - .is_some_and(|names| names.contains("RadrootsOperationalListingBin")) - ); - - for duplicate in [ - "export type RadrootsOperationalListing = ", - "export type RadrootsFarmRef = ", - "export type RadrootsOrderEconomics = ", - "export type RadrootsOrderInventoryCommitment = ", - "export type RadrootsCommercialMessagePayload = ", - "export type RadrootsCommercialMessageType = ", - "export type RadrootsOrderStatus = ", - "export type RadrootsTradeOrderWorkflowMessage = ", - "export type RadrootsOrderWorkflowProjection = ", - "export type RadrootsTradeMarketplaceOrderSummary = ", - "export type RadrootsTradeOrderQuery = ", - ] { - assert!(!TRADE_BINDINGS_TYPES_TS.contains(duplicate)); - } - - let projection = type_declaration(TRADE_BINDINGS_TYPES_TS, "RadrootsTradeProjectionV1"); - assert!(projection.contains("agreement_state: RadrootsTradeAgreementStateV1")); - assert!(projection.contains("negotiation_state: RadrootsTradeNegotiationStateV1")); - assert!(projection.contains("agreement_claims: Array<RadrootsTradeAgreementClaimV1>")); - assert!(projection.contains("issues: Array<RadrootsTradeReducerIssueV1>")); - assert!(projection.contains("projection_digest: string")); - for stale_counter in [ - "root_event_id", - "last_message_type", - "last_discount_request", - "last_discount_offer", - "accepted_discount", - "last_discount_decline_reason", - "has_requested_discounts", - "question_count", - "answer_count", - "discount_request_count", - "discount_offer_count", - "discount_accept_count", - "discount_decline_count", - ] { - assert!(!projection.contains(stale_counter)); - } - } - - fn type_inventory(types_ts: &str) -> Vec<&str> { - types_ts - .lines() - .filter_map(|line| line.strip_prefix("export type ")) - .map(|rest| rest.split([' ', '<']).next().expect("type name")) - .collect() - } - - fn source_public_schema_type_inventory() -> Vec<&'static str> { - let mut names = BTreeSet::new(); - - for source in REPLICA_SCHEMA_MODEL_SOURCES { - for line in source.lines() { - if let Some(name) = public_rust_type_name(line) - && !name.ends_with("Ts") - { - names.insert(name); - } - } - } - - names.into_iter().collect() - } - - fn public_rust_type_name(line: &'static str) -> Option<&'static str> { - let line = line.trim_start(); - - ["pub struct ", "pub enum ", "pub type "] - .into_iter() - .find_map(|prefix| { - line.strip_prefix(prefix).map(|rest| { - rest.split(|char: char| !(char == '_' || char.is_ascii_alphanumeric())) - .next() - .expect("type name") - }) - }) - } - - fn type_declaration<'a>(types_ts: &'a str, name: &str) -> &'a str { - types_ts - .lines() - .find(|line| line.starts_with(&format!("export type {name} = "))) - .unwrap_or_else(|| panic!("missing type declaration for {name}")) - } - - fn span() -> SourceSpan { - SourceSpan::new("tools/xtask/src/dto_roots.rs", 1, 1) - } -} diff --git a/tools/sdk_xtask_import/src/fs.rs b/tools/sdk_xtask_import/src/fs.rs @@ -1,61 +0,0 @@ -use std::{ - fs, - path::{Path, PathBuf}, -}; - -pub fn workspace_root() -> Result<PathBuf, String> { - let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); - manifest_dir - .parent() - .and_then(Path::parent) - .map(Path::to_path_buf) - .ok_or_else(|| { - format!( - "cannot resolve workspace root from {}", - manifest_dir.display() - ) - }) -} - -#[allow(dead_code)] -pub fn write_if_changed(path: &Path, contents: &str) -> Result<bool, String> { - if let Ok(existing) = fs::read_to_string(path) - && existing == contents - { - return Ok(false); - } - if let Some(parent) = path.parent() { - fs::create_dir_all(parent) - .map_err(|error| format!("failed to create {}: {error}", parent.display()))?; - } - fs::write(path, contents) - .map_err(|error| format!("failed to write {}: {error}", path.display()))?; - Ok(true) -} - -pub fn write_bytes_if_changed(path: &Path, contents: &[u8]) -> Result<bool, String> { - if let Ok(existing) = fs::read(path) - && existing == contents - { - return Ok(false); - } - if let Some(parent) = path.parent() { - fs::create_dir_all(parent) - .map_err(|error| format!("failed to create {}: {error}", parent.display()))?; - } - fs::write(path, contents) - .map_err(|error| format!("failed to write {}: {error}", path.display()))?; - Ok(true) -} - -#[cfg(test)] -mod tests { - use super::workspace_root; - - #[test] - fn resolves_workspace_root() { - let root = workspace_root().expect("workspace root resolves"); - assert!(root.join("Cargo.toml").is_file()); - assert!(root.join("packages").is_dir()); - } -} diff --git a/tools/sdk_xtask_import/src/generate.rs b/tools/sdk_xtask_import/src/generate.rs @@ -1,35 +0,0 @@ -use crate::{ - fs::workspace_root, output::package_outputs, package_matrix::validate_package_matrix, - package_metadata, -}; - -pub fn generate_all() -> Result<(), String> { - generate_ts()?; - generate_package_metadata() -} - -pub fn generate_ts() -> Result<(), String> { - validate_package_matrix()?; - let root = workspace_root()?; - for output in package_outputs()? { - for generated_file in output.files() { - let path = root - .join(output.spec.package_dir) - .join(generated_file.relative_path); - crate::fs::write_if_changed(&path, &generated_file.contents)?; - } - let provenance_file = output.provenance_file(); - crate::fs::write_if_changed( - &root.join(&provenance_file.relative_path), - &provenance_file.contents, - )?; - println!("generated TypeScript package {}", output.spec.package_name); - } - Ok(()) -} - -pub fn generate_package_metadata() -> Result<(), String> { - validate_package_matrix()?; - let root = workspace_root()?; - package_metadata::generate_package_metadata(&root) -} diff --git a/tools/sdk_xtask_import/src/main.rs b/tools/sdk_xtask_import/src/main.rs @@ -1,281 +0,0 @@ -mod api_qualification; -mod architecture; -mod bindings; -mod check; -mod contracts; -mod coverage; -mod coverage_policy; -#[allow(dead_code)] -mod dto_roots; -mod fs; -mod generate; -mod manifest; -mod output; -mod package_matrix; -mod package_metadata; -mod portable_qualification; -mod release_graph; -mod release_qualification; -mod smoke; -mod supply_chain_qualification; -mod target_qualification; -mod ts; -mod wasm; -mod wasm_declarations; - -enum CommandAction<'a> { - Architecture, - ArchitectureCi, - CheckApiBoundaries, - CheckDependencyBoundaries, - GenerateAll, - GenerateBindings(&'a [String]), - GenerateTs, - GenerateWasm(&'a [String]), - GeneratePackageMetadata, - Coverage(&'a [String]), - QualifyFeatures, - QualifyGraph, - QualifyApi, - QualifyPortable, - QualifySupplyChain, - QualifyTargets, - Check, - Smoke(&'a [String]), -} - -fn main() { - if let Err(error) = run(std::env::args().skip(1)) { - eprintln!("{error}"); - std::process::exit(1); - } -} - -fn run(args: impl IntoIterator<Item = String>) -> Result<(), String> { - let args = args.into_iter().collect::<Vec<_>>(); - match command_action(&args)? { - CommandAction::Architecture => architecture::validate(&fs::workspace_root()?), - CommandAction::ArchitectureCi => { - let root = fs::workspace_root()?; - architecture::validate_ci(&root)?; - check::architecture_ci(&root) - } - CommandAction::CheckApiBoundaries => { - architecture::validate_api_boundaries(&fs::workspace_root()?) - } - CommandAction::CheckDependencyBoundaries => { - architecture::validate_dependency_boundaries(&fs::workspace_root()?) - } - CommandAction::GenerateAll => generate::generate_all(), - CommandAction::GenerateBindings(rest) => bindings::generate(rest), - CommandAction::GenerateTs => generate::generate_ts(), - CommandAction::GenerateWasm(rest) => wasm::generate(rest), - CommandAction::GeneratePackageMetadata => generate::generate_package_metadata(), - CommandAction::Coverage(rest) => coverage::run(rest), - CommandAction::QualifyFeatures => { - release_qualification::run_feature_matrix(&fs::workspace_root()?) - } - CommandAction::QualifyGraph => release_graph::run(&fs::workspace_root()?), - CommandAction::QualifyApi => api_qualification::run(&fs::workspace_root()?), - CommandAction::QualifyPortable => portable_qualification::run(&fs::workspace_root()?), - CommandAction::QualifySupplyChain => { - supply_chain_qualification::run(&fs::workspace_root()?) - } - CommandAction::QualifyTargets => target_qualification::run(&fs::workspace_root()?), - CommandAction::Check => check::check(), - CommandAction::Smoke(rest) => smoke::run(rest), - } -} - -fn command_action(args: &[String]) -> Result<CommandAction<'_>, String> { - match args { - [command] if command == "architecture" => Ok(CommandAction::Architecture), - [command] if command == "architecture-ci" => Ok(CommandAction::ArchitectureCi), - [command] if command == "check-api-boundaries" => Ok(CommandAction::CheckApiBoundaries), - [command] if command == "check-dependency-boundaries" => { - Ok(CommandAction::CheckDependencyBoundaries) - } - [command] if command == "generate" => Ok(CommandAction::GenerateAll), - [command, target] if command == "generate" && target == "ts" => { - Ok(CommandAction::GenerateTs) - } - [command, target, rest @ ..] if command == "generate" && target == "wasm" => { - Ok(CommandAction::GenerateWasm(rest)) - } - [command, target, rest @ ..] if command == "generate" && target == "bindings" => { - Ok(CommandAction::GenerateBindings(rest)) - } - [command, target] if command == "generate" && target == "package-metadata" => { - Ok(CommandAction::GeneratePackageMetadata) - } - [command, rest @ ..] if command == "coverage" => Ok(CommandAction::Coverage(rest)), - [command, target] if command == "release" && target == "qualify-features" => { - Ok(CommandAction::QualifyFeatures) - } - [command, target] if command == "release" && target == "qualify-graph" => { - Ok(CommandAction::QualifyGraph) - } - [command, target] if command == "release" && target == "qualify-api" => { - Ok(CommandAction::QualifyApi) - } - [command, target] if command == "release" && target == "qualify-portable" => { - Ok(CommandAction::QualifyPortable) - } - [command, target] if command == "release" && target == "qualify-supply-chain" => { - Ok(CommandAction::QualifySupplyChain) - } - [command, target] if command == "release" && target == "qualify-targets" => { - Ok(CommandAction::QualifyTargets) - } - [command] if command == "check" => Ok(CommandAction::Check), - [command, rest @ ..] if command == "smoke" => Ok(CommandAction::Smoke(rest)), - [] => Err(usage()), - _ => Err(usage()), - } -} - -fn usage() -> String { - "usage: cargo xtask architecture | cargo xtask architecture-ci | cargo xtask check-api-boundaries | cargo xtask check-dependency-boundaries | cargo xtask generate | cargo xtask generate ts | cargo xtask generate wasm [--package <key>] | cargo xtask generate bindings <swift|kotlin> | cargo xtask generate package-metadata | cargo xtask check | cargo xtask smoke facade-rust-local | sdk-rust-local | front-doors-rust-local | cargo xtask coverage run | cargo xtask coverage check | cargo xtask release qualify-features | cargo xtask release qualify-graph | cargo xtask release qualify-api | cargo xtask release qualify-portable | cargo xtask release qualify-supply-chain | cargo xtask release qualify-targets" - .to_owned() -} - -#[cfg(test)] -mod tests { - use super::{CommandAction, command_action}; - - #[test] - fn accepts_architecture() { - let args = ["architecture".to_owned()]; - assert!(matches!( - command_action(&args).expect("action"), - CommandAction::Architecture - )); - } - - #[test] - fn accepts_architecture_ci() { - let args = ["architecture-ci".to_owned()]; - assert!(matches!( - command_action(&args).expect("action"), - CommandAction::ArchitectureCi - )); - } - - #[test] - fn accepts_api_boundary_check() { - let args = ["check-api-boundaries".to_owned()]; - assert!(matches!( - command_action(&args).expect("action"), - CommandAction::CheckApiBoundaries - )); - } - - #[test] - fn accepts_dependency_boundary_check() { - let args = ["check-dependency-boundaries".to_owned()]; - assert!(matches!( - command_action(&args).expect("action"), - CommandAction::CheckDependencyBoundaries - )); - } - - #[test] - fn accepts_generate_ts() { - let args = ["generate".to_owned(), "ts".to_owned()]; - assert!(matches!( - command_action(&args).expect("action"), - CommandAction::GenerateTs - )); - } - - #[test] - fn accepts_generate_all() { - let args = ["generate".to_owned()]; - assert!(matches!( - command_action(&args).expect("action"), - CommandAction::GenerateAll - )); - } - - #[test] - fn accepts_generate_wasm() { - let args = ["generate".to_owned(), "wasm".to_owned()]; - assert!(matches!( - command_action(&args).expect("action"), - CommandAction::GenerateWasm(rest) if rest.is_empty() - )); - } - - #[test] - fn accepts_generate_bindings() { - let args = [ - "generate".to_owned(), - "bindings".to_owned(), - "swift".to_owned(), - ]; - assert!(matches!( - command_action(&args).expect("action"), - CommandAction::GenerateBindings(rest) if rest == ["swift"] - )); - } - - #[test] - fn accepts_generate_package_metadata() { - let args = ["generate".to_owned(), "package-metadata".to_owned()]; - assert!(matches!( - command_action(&args).expect("action"), - CommandAction::GeneratePackageMetadata - )); - } - - #[test] - fn accepts_check() { - let args = ["check".to_owned()]; - assert!(matches!( - command_action(&args).expect("action"), - CommandAction::Check - )); - } - - #[test] - fn accepts_smoke() { - let args = ["smoke".to_owned(), "sdk-rust-local".to_owned()]; - assert!(matches!( - command_action(&args).expect("action"), - CommandAction::Smoke(rest) if rest == ["sdk-rust-local"] - )); - } - - #[test] - fn accepts_coverage_run() { - let args = ["coverage".to_owned(), "run".to_owned()]; - assert!(matches!( - command_action(&args).expect("action"), - CommandAction::Coverage(rest) if rest == ["run"] - )); - } - - #[test] - fn accepts_supply_chain_qualification() { - let args = ["release".to_owned(), "qualify-supply-chain".to_owned()]; - assert!(matches!( - command_action(&args).expect("action"), - CommandAction::QualifySupplyChain - )); - } - - #[test] - fn accepts_release_graph_qualification() { - let args = ["release".to_owned(), "qualify-graph".to_owned()]; - assert!(matches!( - command_action(&args).expect("action"), - CommandAction::QualifyGraph - )); - } - - #[test] - fn rejects_unknown_command() { - let args = ["generate".to_owned(), "swift".to_owned()]; - assert!(command_action(&args).is_err()); - } -} diff --git a/tools/sdk_xtask_import/src/manifest.rs b/tools/sdk_xtask_import/src/manifest.rs @@ -1,53 +0,0 @@ -use serde_json::json; - -use crate::package_matrix::PackageSpec; - -pub fn manifest_relative_path(spec: PackageSpec) -> String { - format!("contracts/provenance/typescript/{}.json", spec.key) -} - -pub fn package_manifest(spec: PackageSpec) -> serde_json::Value { - json!({ - "package": spec.package_name, - "crate": spec.crate_name, - "version": "0.1.0", - "license": "MIT OR Apache-2.0", - "repository": "https://github.com/radrootslabs/sdk", - "repository_directory": spec.package_dir, - "generator": "radroots_sdk_xtask", - "generated": false - }) -} - -#[cfg(test)] -mod tests { - use crate::{ - manifest::{manifest_relative_path, package_manifest}, - package_matrix::package_specs, - }; - - #[test] - fn manifest_path_is_outside_package_source() { - assert_eq!( - manifest_relative_path(package_specs()[0]), - "contracts/provenance/typescript/core.json" - ); - } - - #[test] - fn manifest_records_package_and_crate() { - let manifest = package_manifest(package_specs()[0]); - assert_eq!(manifest["package"], package_specs()[0].package_name); - assert_eq!(manifest["crate"], package_specs()[0].crate_name); - assert_eq!(manifest["version"], "0.1.0"); - assert_eq!(manifest["license"], "MIT OR Apache-2.0"); - assert_eq!( - manifest["repository"], - "https://github.com/radrootslabs/sdk" - ); - assert_eq!( - manifest["repository_directory"], - package_specs()[0].package_dir - ); - } -} diff --git a/tools/sdk_xtask_import/src/output.rs b/tools/sdk_xtask_import/src/output.rs @@ -1,493 +0,0 @@ -use dto_bindgen_backend_ts::{DtoTypesModule, TypeScriptImport, TypeScriptModule}; -use sha2::{Digest, Sha256}; - -use crate::{ - dto_roots, - manifest::manifest_relative_path, - manifest::package_manifest, - package_matrix::{PackageSpec, package_specs}, - ts::{generated_constants_file, generated_header, generated_kinds_file, generated_types_file}, -}; - -pub struct PackageOutput { - pub spec: PackageSpec, - pub types_ts: Option<TsSource>, - pub types_imports: Vec<TypeScriptImport>, - pub constants_ts: Option<TsSource>, - pub kinds_ts: Option<TsSource>, -} - -pub struct GeneratedFile { - pub relative_path: String, - pub contents: String, -} - -pub enum TsSource { - DtoRegistry(DtoTypesModule), - Module(TypeScriptModule), - CanonicalNativeSnapshot { contents: &'static str }, -} - -impl TsSource { - fn canonical_native_snapshot( - contents: &'static str, - sha256: &'static str, - source_packages: &'static [&'static str], - ) -> Result<Self, String> { - let actual = format!("{:x}", Sha256::digest(contents.as_bytes())); - if actual != sha256 { - return Err(format!( - "canonical native type snapshot drifted: expected {sha256}, found {actual}; regenerate from and review against {}", - source_packages.join(", ") - )); - } - Ok(Self::CanonicalNativeSnapshot { contents }) - } - - fn render(&self) -> String { - match self { - Self::DtoRegistry(module) => module.body_ts().to_owned(), - Self::Module(module) => module.render_source(), - Self::CanonicalNativeSnapshot { contents } => (*contents).to_owned(), - } - } - - fn imports(&self) -> Option<&str> { - match self { - Self::DtoRegistry(module) => module.imports_ts(), - Self::Module(_) | Self::CanonicalNativeSnapshot { .. } => None, - } - } -} - -const EVENT_BINDINGS_TYPES_TS: &str = - include_str!("../../../packages/event-bindings/src/generated/types.ts"); -const EVENT_BINDINGS_TYPES_SHA256: &str = - "fe4496950852715c573ff2abc7df8edede7161de75d9c68c3f4d6836e67f829a"; -const TRADE_BINDINGS_TYPES_TS: &str = - include_str!("../../../packages/trade-bindings/src/generated/types.ts"); -const TRADE_BINDINGS_TYPES_SHA256: &str = - "49560c572206095bd7de5cfd377c234a7f136e4affc13d53516ae2c4b8998e87"; -const EVENT_NATIVE_SOURCES: &[&str] = &["radroots_event", "radroots_core"]; -const TRADE_NATIVE_SOURCES: &[&str] = &["radroots_trade", "radroots_event", "radroots_core"]; - -impl PackageOutput { - pub fn files(&self) -> Vec<GeneratedFile> { - let mut files = Vec::new(); - if let Some(types_ts) = &self.types_ts { - let imports = combined_imports( - structured_imports_ts(&self.types_imports).as_deref(), - types_ts.imports(), - ); - files.push(GeneratedFile { - relative_path: format!("src/generated/{}", generated_types_file()), - contents: render_ts(types_ts, imports.as_deref()), - }); - } - if let Some(constants_ts) = &self.constants_ts { - files.push(GeneratedFile { - relative_path: format!("src/generated/{}", generated_constants_file()), - contents: render_ts(constants_ts, None), - }); - } - if let Some(kinds_ts) = &self.kinds_ts { - files.push(GeneratedFile { - relative_path: format!("src/generated/{}", generated_kinds_file()), - contents: render_ts(kinds_ts, None), - }); - } - files - } - - pub fn provenance_file(&self) -> GeneratedFile { - GeneratedFile { - relative_path: manifest_relative_path(self.spec), - contents: render_manifest(self), - } - } -} - -pub fn package_outputs() -> Result<Vec<PackageOutput>, String> { - Ok(vec![ - PackageOutput { - spec: spec_by_key("core"), - types_ts: Some(TsSource::DtoRegistry(dto_roots::core_types_module()?)), - types_imports: Vec::new(), - constants_ts: None, - kinds_ts: None, - }, - PackageOutput { - spec: spec_by_key("event"), - types_ts: Some(TsSource::canonical_native_snapshot( - EVENT_BINDINGS_TYPES_TS, - EVENT_BINDINGS_TYPES_SHA256, - EVENT_NATIVE_SOURCES, - )?), - types_imports: Vec::new(), - constants_ts: Some(TsSource::Module(radroots_event_bindings::constants_module())), - kinds_ts: Some(TsSource::Module(radroots_event_bindings::kinds_module())), - }, - PackageOutput { - spec: spec_by_key("identity"), - types_ts: None, - types_imports: Vec::new(), - constants_ts: Some(TsSource::Module( - radroots_identity_bindings::constants_module(), - )), - kinds_ts: None, - }, - PackageOutput { - spec: spec_by_key("replica_schema"), - types_ts: Some(TsSource::DtoRegistry( - dto_roots::replica_schema_types_module()?, - )), - types_imports: Vec::new(), - constants_ts: None, - kinds_ts: None, - }, - PackageOutput { - spec: spec_by_key("trade"), - types_ts: Some(TsSource::canonical_native_snapshot( - TRADE_BINDINGS_TYPES_TS, - TRADE_BINDINGS_TYPES_SHA256, - TRADE_NATIVE_SOURCES, - )?), - types_imports: Vec::new(), - constants_ts: None, - kinds_ts: None, - }, - ]) -} - -fn spec_by_key(key: &str) -> PackageSpec { - package_specs() - .iter() - .copied() - .find(|spec| spec.key == key) - .unwrap_or_else(|| panic!("missing package spec for {key}")) -} - -fn render_ts(source: &TsSource, imports: Option<&str>) -> String { - if matches!(source, TsSource::CanonicalNativeSnapshot { .. }) { - return source.render(); - } - let body = source.render(); - let imports = imports.unwrap_or(""); - let mut rendered = format!("{}{}{}", generated_header(), imports, body.trim_start()); - if !rendered.ends_with('\n') { - rendered.push('\n'); - } - rendered -} - -fn combined_imports(first: Option<&str>, second: Option<&str>) -> Option<String> { - match (first, second) { - (Some(first), Some(second)) => Some(format!("{first}{second}")), - (Some(first), None) => Some(first.to_owned()), - (None, Some(second)) => Some(second.to_owned()), - (None, None) => None, - } -} - -fn structured_imports_ts(imports: &[TypeScriptImport]) -> Option<String> { - if imports.is_empty() { - return None; - } - Some( - imports - .iter() - .cloned() - .fold(TypeScriptModule::new("types.ts"), |module, import| { - module.with_import(import) - }) - .render_source(), - ) -} - -fn render_manifest(output: &PackageOutput) -> String { - let spec = output.spec; - let mut value = package_manifest(spec); - value["generated"] = serde_json::Value::Bool(true); - value["outputs"] = serde_json::Value::Object( - output - .files() - .into_iter() - .map(|file| { - ( - file.relative_path, - serde_json::Value::String(format!( - "{:x}", - Sha256::digest(file.contents.as_bytes()) - )), - ) - }) - .collect(), - ); - if matches!(spec.key, "event" | "trade") { - let (sha256, source_packages) = match spec.key { - "event" => (EVENT_BINDINGS_TYPES_SHA256, EVENT_NATIVE_SOURCES), - "trade" => (TRADE_BINDINGS_TYPES_SHA256, TRADE_NATIVE_SOURCES), - _ => unreachable!(), - }; - value["types_source"] = - serde_json::Value::String("canonical_native_type_snapshot".to_owned()); - value["types_sha256"] = serde_json::Value::String(sha256.to_owned()); - value["types_source_packages"] = serde_json::Value::Array( - source_packages - .iter() - .map(|package| serde_json::Value::String((*package).to_owned())) - .collect(), - ); - } - format!( - "{}\n", - serde_json::to_string_pretty(&value).expect("manifest json serializes") - ) -} - -#[cfg(test)] -mod tests { - use super::{PackageOutput, TsSource, package_outputs, render_ts}; - use crate::package_matrix::package_specs; - use dto_bindgen_backend_ts::{ - DtoTypesModule, TypeScriptDeclaration, TypeScriptImport, TypeScriptModule, TypeScriptType, - }; - - const TRADE_BINDINGS_TYPES_TS: &str = - include_str!("../../../packages/trade-bindings/src/generated/types.ts"); - const REPLICA_SCHEMA_BINDINGS_TYPES_TS: &str = - include_str!("../../../packages/replica-schema-bindings/src/generated/types.ts"); - const EVENT_BINDINGS_CONSTANTS_TS: &str = - include_str!("../../../packages/event-bindings/src/generated/constants.ts"); - const EVENT_BINDINGS_KINDS_TS: &str = - include_str!("../../../packages/event-bindings/src/generated/kinds.ts"); - const IDENTITY_BINDINGS_CONSTANTS_TS: &str = - include_str!("../../../packages/identity-bindings/src/generated/constants.ts"); - - #[test] - fn renders_sdk_header() { - let output = render_ts(&test_module(), None); - assert!(output.starts_with("// @generated by cargo xtask generate ts")); - assert!(output.contains("export type A = string;")); - } - - #[test] - fn renders_import_prelude_after_header() { - let output = render_ts(&test_module(), Some("import type { B } from \"b\";\n\n")); - assert!(output.starts_with( - "// @generated by cargo xtask generate ts\n// Do not edit by hand.\nimport type" - )); - assert!(output.contains("export type A = string;")); - } - - #[test] - fn renders_module_sources() { - let output = render_ts(&test_module(), None); - assert_eq!( - output, - "// @generated by cargo xtask generate ts\n// Do not edit by hand.\nexport type A = string;\n" - ); - } - - #[test] - fn canonical_native_snapshot_rejects_digest_drift() { - let error = match TsSource::canonical_native_snapshot( - "drifted snapshot\n", - super::EVENT_BINDINGS_TYPES_SHA256, - super::EVENT_NATIVE_SOURCES, - ) { - Ok(_) => panic!("digest drift must fail closed"), - Err(error) => error, - }; - - assert!(error.contains("canonical native type snapshot drifted")); - assert!(error.contains(super::EVENT_BINDINGS_TYPES_SHA256)); - assert!(error.contains("radroots_event")); - } - - #[test] - fn includes_core_and_schema_outputs() { - let package_names = package_outputs() - .expect("package outputs") - .into_iter() - .map(|output| output.spec.package_name) - .collect::<Vec<_>>(); - assert!(package_names.contains(&"@radroots/core-bindings")); - assert!(package_names.contains(&"@radroots/event-bindings")); - assert!(package_names.contains(&"@radroots/identity-bindings")); - assert!(package_names.contains(&"@radroots/replica-schema-bindings")); - assert!(package_names.contains(&"@radroots/trade-bindings")); - } - - #[test] - fn dto_registry_source_uses_generated_package_files() { - let output = PackageOutput { - spec: package_specs()[0], - types_ts: Some(TsSource::DtoRegistry(DtoTypesModule::new( - "import type { ExternalThing } from \"@radroots/external-bindings\";\n\n", - "export type SyntheticThing = { external: ExternalThing, };", - ))), - types_imports: vec![TypeScriptImport::type_only( - ["LocalPrelude"], - "@radroots/local", - )], - constants_ts: None, - kinds_ts: None, - }; - let files = output.files(); - let types = files - .iter() - .find(|file| file.relative_path == "src/generated/types.ts") - .expect("types file"); - let manifest = output.provenance_file(); - - assert_eq!( - types.contents, - "// @generated by cargo xtask generate ts\n// Do not edit by hand.\nimport type { LocalPrelude } from \"@radroots/local\";\nimport type { ExternalThing } from \"@radroots/external-bindings\";\n\nexport type SyntheticThing = { external: ExternalThing, };\n" - ); - assert_eq!( - manifest.relative_path, - "contracts/provenance/typescript/core.json" - ); - assert!(manifest.contents.contains("\"generated\": true")); - assert!( - !files - .iter() - .any(|file| file.relative_path == "src/index.ts") - ); - } - - #[test] - fn package_outputs_do_not_generate_package_indices() { - for output in package_outputs().expect("package outputs") { - assert!( - !output - .files() - .iter() - .any(|file| file.relative_path == "src/index.ts"), - "{} index must remain handwritten source", - output.spec.package_name - ); - } - } - - #[test] - fn trade_output_uses_canonical_native_snapshot_and_matches_checked_in_types() { - let output = package_outputs() - .expect("package outputs") - .into_iter() - .find(|output| output.spec.key == "trade") - .expect("trade output"); - - assert!(matches!( - output.types_ts, - Some(TsSource::CanonicalNativeSnapshot { .. }) - )); - assert!(output.types_imports.is_empty()); - - let types = output - .files() - .into_iter() - .find(|file| file.relative_path == "src/generated/types.ts") - .expect("types file"); - - assert_eq!(types.contents, TRADE_BINDINGS_TYPES_TS); - } - - #[test] - fn event_output_uses_canonical_native_snapshot_and_matches_checked_in_types() { - let output = package_outputs() - .expect("package outputs") - .into_iter() - .find(|output| output.spec.key == "event") - .expect("event output"); - - assert!(matches!( - output.types_ts, - Some(TsSource::CanonicalNativeSnapshot { .. }) - )); - let types = output - .files() - .into_iter() - .find(|file| file.relative_path == "src/generated/types.ts") - .expect("types file"); - assert_eq!(types.contents, super::EVENT_BINDINGS_TYPES_TS); - - let manifest = output.provenance_file(); - assert!(manifest.contents.contains("canonical_native_type_snapshot")); - assert!(manifest.contents.contains("radroots_event")); - assert!(manifest.contents.contains("radroots_core")); - } - - #[test] - fn replica_schema_output_uses_dto_registry_and_matches_checked_in_types() { - let output = package_outputs() - .expect("package outputs") - .into_iter() - .find(|output| output.spec.key == "replica_schema") - .expect("replica_schema output"); - - assert!(matches!(output.types_ts, Some(TsSource::DtoRegistry(_)))); - assert!(output.types_imports.is_empty()); - - let types = output - .files() - .into_iter() - .find(|file| file.relative_path == "src/generated/types.ts") - .expect("types file"); - - assert_eq!(types.contents, REPLICA_SCHEMA_BINDINGS_TYPES_TS); - } - - #[test] - fn events_constants_and_kinds_use_modules_and_match_checked_in_files() { - let output = package_outputs() - .expect("package outputs") - .into_iter() - .find(|output| output.spec.key == "event") - .expect("event output"); - - assert!(matches!(output.constants_ts, Some(TsSource::Module(_)))); - assert!(matches!(output.kinds_ts, Some(TsSource::Module(_)))); - - let files = output.files(); - let constants = files - .iter() - .find(|file| file.relative_path == "src/generated/constants.ts") - .expect("constants file"); - let kinds = files - .iter() - .find(|file| file.relative_path == "src/generated/kinds.ts") - .expect("kinds file"); - - assert_eq!(constants.contents, EVENT_BINDINGS_CONSTANTS_TS); - assert_eq!(kinds.contents, EVENT_BINDINGS_KINDS_TS); - } - - #[test] - fn identity_constants_use_module_and_match_checked_in_file() { - let output = package_outputs() - .expect("package outputs") - .into_iter() - .find(|output| output.spec.key == "identity") - .expect("identity output"); - - assert!(matches!(output.constants_ts, Some(TsSource::Module(_)))); - - let constants = output - .files() - .into_iter() - .find(|file| file.relative_path == "src/generated/constants.ts") - .expect("constants file"); - - assert_eq!(constants.contents, IDENTITY_BINDINGS_CONSTANTS_TS); - } - - fn test_module() -> TsSource { - TsSource::Module( - TypeScriptModule::new("src/generated/test.ts").with_declaration( - TypeScriptDeclaration::type_alias("A", TypeScriptType::String), - ), - ) - } -} diff --git a/tools/sdk_xtask_import/src/package_metadata.rs b/tools/sdk_xtask_import/src/package_metadata.rs @@ -1,181 +0,0 @@ -use std::{fs, path::Path}; - -use crate::{ - fs::{write_bytes_if_changed, write_if_changed}, - package_matrix::{package_specs, wasm_package_specs}, -}; - -pub(crate) const PACKAGE_README_FILE: &str = "README.md"; -pub(crate) const PACKAGE_LICENSE_FILES: [&str; 2] = ["LICENSE-MIT", "LICENSE-APACHE"]; -pub(crate) const PACKAGE_FILES: [&str; 4] = [ - "dist", - PACKAGE_README_FILE, - PACKAGE_LICENSE_FILES[0], - PACKAGE_LICENSE_FILES[1], -]; - -pub(crate) fn generate_package_metadata(root: &Path) -> Result<(), String> { - for spec in package_specs() { - write_package_metadata(root, &root.join(spec.package_dir))?; - println!("generated package metadata {}", spec.package_name); - } - for spec in wasm_package_specs() { - write_package_metadata(root, &root.join(spec.package_dir))?; - println!("generated package metadata {}", spec.package_name); - } - Ok(()) -} - -pub(crate) fn check_package_distribution_metadata( - root: &Path, - package_dir: &Path, - package_json_path: &Path, - json: &serde_json::Value, -) -> Result<(), String> { - let package_name = package_name(json, package_json_path)?; - let description = package_description(json, package_json_path)?; - let readme_path = package_dir.join(PACKAGE_README_FILE); - let expected_readme = package_readme(package_name, description); - check_text_file(&readme_path, &expected_readme, "stale package README")?; - for file_name in PACKAGE_LICENSE_FILES { - let source_path = root.join(file_name); - let package_path = package_dir.join(file_name); - let expected = fs::read(&source_path) - .map_err(|error| format!("failed to read {}: {error}", source_path.display()))?; - let actual = fs::read(&package_path) - .map_err(|error| format!("failed to read {}: {error}", package_path.display()))?; - if actual != expected { - return Err(format!( - "stale package license metadata: {}", - package_path.display() - )); - } - } - Ok(()) -} - -pub(crate) fn package_description<'a>( - json: &'a serde_json::Value, - package_json_path: &Path, -) -> Result<&'a str, String> { - let description = json - .get("description") - .and_then(serde_json::Value::as_str) - .ok_or_else(|| { - format!( - "package.json missing description: {}", - package_json_path.display() - ) - })?; - if description.trim().is_empty() || description.trim() != description { - return Err(format!( - "package.json description must be non-empty and trimmed: {}", - package_json_path.display() - )); - } - Ok(description) -} - -pub(crate) fn package_readme(package_name: &str, description: &str) -> String { - format!( - "# {package_name}\n\n{description}\n\nThis package publishes generated ESM JavaScript, TypeScript declarations, and any runtime artifacts from the Radroots SDK build pipeline. Runtime files are distributed from `dist/`; source and provenance metadata are kept outside the npm package payload.\n\n## License\n\nLicensed under either MIT or Apache-2.0, at your option. See `LICENSE-MIT` and `LICENSE-APACHE`.\n" - ) -} - -fn write_package_metadata(root: &Path, package_dir: &Path) -> Result<bool, String> { - let package_json_path = package_dir.join("package.json"); - let json = read_package_json(&package_json_path)?; - let package_name = package_name(&json, &package_json_path)?; - let description = package_description(&json, &package_json_path)?; - let mut changed = write_if_changed( - &package_dir.join(PACKAGE_README_FILE), - &package_readme(package_name, description), - )?; - for file_name in PACKAGE_LICENSE_FILES { - let source_path = root.join(file_name); - let contents = fs::read(&source_path) - .map_err(|error| format!("failed to read {}: {error}", source_path.display()))?; - changed |= write_bytes_if_changed(&package_dir.join(file_name), &contents)?; - } - Ok(changed) -} - -fn read_package_json(path: &Path) -> Result<serde_json::Value, String> { - let raw = fs::read_to_string(path) - .map_err(|error| format!("failed to read {}: {error}", path.display()))?; - serde_json::from_str::<serde_json::Value>(&raw) - .map_err(|error| format!("failed to parse {}: {error}", path.display())) -} - -fn package_name<'a>( - json: &'a serde_json::Value, - package_json_path: &Path, -) -> Result<&'a str, String> { - json.get("name") - .and_then(serde_json::Value::as_str) - .ok_or_else(|| format!("package.json missing name: {}", package_json_path.display())) -} - -fn check_text_file(path: &Path, expected: &str, label: &str) -> Result<(), String> { - let actual = fs::read_to_string(path) - .map_err(|error| format!("failed to read {}: {error}", path.display()))?; - if actual != expected { - return Err(format!("{label}: {}", path.display())); - } - Ok(()) -} - -#[cfg(test)] -mod tests { - use std::{ - fs, - path::PathBuf, - time::{SystemTime, UNIX_EPOCH}, - }; - - use super::{package_readme, write_package_metadata}; - - #[test] - fn package_metadata_generation_is_deterministic() { - let root = test_root("metadata_generation"); - let package_dir = root.join("packages/example"); - fs::create_dir_all(&package_dir).expect("create package"); - fs::write(root.join("LICENSE-MIT"), "MIT license\n").expect("write MIT license"); - fs::write(root.join("LICENSE-APACHE"), "Apache license\n").expect("write Apache license"); - fs::write( - package_dir.join("package.json"), - r#"{ - "name": "@radroots/example", - "description": "Example package" -}"#, - ) - .expect("write package json"); - - assert!(write_package_metadata(&root, &package_dir).expect("first generation")); - assert_eq!( - fs::read_to_string(package_dir.join("README.md")).expect("read README"), - package_readme("@radroots/example", "Example package") - ); - assert_eq!( - fs::read_to_string(package_dir.join("LICENSE-MIT")).expect("read MIT"), - "MIT license\n" - ); - assert!(!write_package_metadata(&root, &package_dir).expect("second generation")); - - let _ = fs::remove_dir_all(root); - } - - fn test_root(name: &str) -> PathBuf { - let stamp = SystemTime::now() - .duration_since(UNIX_EPOCH) - .expect("system time after epoch") - .as_nanos(); - let root = std::env::temp_dir().join(format!( - "radroots_sdk_xtask_package_metadata_{name}_{}_{}", - std::process::id(), - stamp - )); - let _ = fs::remove_dir_all(&root); - root - } -} diff --git a/tools/sdk_xtask_import/src/portable_qualification.rs b/tools/sdk_xtask_import/src/portable_qualification.rs @@ -1,58 +0,0 @@ -use std::{fs, path::Path}; - -use serde::Deserialize; - -#[derive(Debug, Deserialize)] -struct PortableMatrix { - schema_version: u32, - no_std_target: String, - no_std_cross_compiler: String, - no_std_cross_cflags: String, - wasm_target: String, - portable_public_packages: Vec<String>, - std_only_public_packages: Vec<String>, -} - -pub fn run(workspace_root: &Path) -> Result<(), String> { - load(workspace_root).map(|_| ()) -} - -fn load(workspace_root: &Path) -> Result<PortableMatrix, String> { - let path = workspace_root.join("contracts/releases/portable_matrix.toml"); - let raw = fs::read_to_string(&path) - .map_err(|error| format!("failed to read {}: {error}", path.display()))?; - let matrix = toml::from_str::<PortableMatrix>(&raw) - .map_err(|error| format!("failed to parse {}: {error}", path.display()))?; - if matrix.schema_version != 1 - || matrix.no_std_target != "thumbv7em-none-eabihf" - || matrix.no_std_cross_compiler != "zig cc" - || matrix.no_std_cross_cflags != "-target thumb-freestanding-eabihf -mcpu=cortex_m4" - || matrix.wasm_target != "wasm32-unknown-unknown" - || !matrix.portable_public_packages.is_empty() - || matrix.std_only_public_packages != ["radroots", "radroots_sdk"] - { - return Err( - "SDK portability contract must classify both public front doors as std-only".to_owned(), - ); - } - Ok(matrix) -} - -#[cfg(test)] -mod tests { - use super::load; - - #[test] - fn current_contract_rejects_nominal_front_door_portability() { - let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) - .parent() - .and_then(std::path::Path::parent) - .expect("workspace root"); - let matrix = load(root).expect("portable matrix"); - assert!(matrix.portable_public_packages.is_empty()); - assert_eq!( - matrix.std_only_public_packages, - ["radroots", "radroots_sdk"] - ); - } -} diff --git a/tools/sdk_xtask_import/src/release_graph.rs b/tools/sdk_xtask_import/src/release_graph.rs @@ -1,687 +0,0 @@ -use std::{ - collections::{BTreeMap, BTreeSet}, - fs, - path::Path, - process::Command, -}; - -use serde::Deserialize; - -const ARCHITECTURE_RELATIVE: &str = "docs/specs/radroots_crates_release_v1.toml"; -const SPEC_ID: &str = "radroots.crates.release.v1"; -const RELEASE_VERSION: &str = "0.1.0-alpha"; -const REGISTRY_KINDS: &[&str] = &["build", "normal"]; -const ALL_KINDS: &[&str] = &["build", "dev", "normal"]; - -#[derive(Debug, Deserialize)] -struct Architecture { - spec_id: String, - package_count: usize, - repositories: ArchitectureRepositories, - package: Vec<ArchitecturePackage>, -} - -#[derive(Debug, Deserialize)] -struct ArchitectureRepositories { - lib: ArchitectureRepository, - sdk: ArchitectureRepository, -} - -#[derive(Debug, Deserialize)] -struct ArchitectureRepository { - url: String, - packages: Vec<String>, -} - -#[derive(Debug, Deserialize)] -struct ArchitecturePackage { - name: String, - publish_order_hint: usize, - #[serde(default)] - required_radroots_dependencies: Vec<String>, - #[serde(default)] - optional_radroots_dependencies: Vec<String>, - #[serde(flatten)] - _other: BTreeMap<String, toml::Value>, -} - -#[derive(Debug, Deserialize)] -struct CargoMetadata { - packages: Vec<CargoPackage>, - workspace_members: Vec<String>, - resolve: Option<CargoResolve>, -} - -#[derive(Debug, Deserialize)] -struct CargoPackage { - id: String, - name: String, - version: String, - source: Option<String>, - manifest_path: String, - #[serde(default)] - dependencies: Vec<CargoDependency>, -} - -#[derive(Debug, Deserialize)] -struct CargoDependency { - name: String, - rename: Option<String>, - #[serde(default)] - kind: Option<String>, - #[serde(default)] - features: Vec<String>, - #[serde(default)] - target: Option<String>, - #[serde(default = "default_true")] - uses_default_features: bool, -} - -#[derive(Debug, Deserialize)] -struct CargoResolve { - nodes: Vec<CargoNode>, -} - -#[derive(Debug, Deserialize)] -struct CargoNode { - id: String, - #[serde(default)] - deps: Vec<CargoNodeDependency>, -} - -#[derive(Debug, Deserialize)] -struct CargoNodeDependency { - name: String, - pkg: String, - #[serde(default)] - dep_kinds: Vec<CargoDependencyKind>, -} - -#[derive(Debug, Deserialize)] -struct CargoDependencyKind { - #[serde(default)] - kind: Option<String>, - #[serde(default)] - target: Option<String>, -} - -fn default_true() -> bool { - true -} - -pub fn run(workspace_root: &Path) -> Result<(), String> { - let architecture = load_architecture(workspace_root)?; - let metadata = load_metadata(workspace_root)?; - let order = validate(&architecture, &metadata)?; - println!("resolved publication order:"); - for (index, package) in order.iter().enumerate() { - println!("{:02} {package}", index + 1); - } - Ok(()) -} - -fn load_architecture(workspace_root: &Path) -> Result<Architecture, String> { - let path = workspace_root.join(ARCHITECTURE_RELATIVE); - let raw = - fs::read_to_string(&path).map_err(|error| format!("read {}: {error}", path.display()))?; - toml::from_str(&raw).map_err(|error| format!("parse {}: {error}", path.display())) -} - -fn load_metadata(workspace_root: &Path) -> Result<CargoMetadata, String> { - let output = Command::new("cargo") - .args([ - "metadata", - "--format-version", - "1", - "--locked", - "--all-features", - ]) - .current_dir(workspace_root) - .output() - .map_err(|error| format!("run cargo metadata: {error}"))?; - if !output.status.success() { - return Err(format!( - "cargo metadata failed while resolving the release graph: {}", - String::from_utf8_lossy(&output.stderr).trim() - )); - } - serde_json::from_slice(&output.stdout) - .map_err(|error| format!("parse cargo metadata release graph: {error}")) -} - -fn validate(architecture: &Architecture, metadata: &CargoMetadata) -> Result<Vec<String>, String> { - let hints = validate_architecture(architecture)?; - let approved = hints.keys().copied().collect::<BTreeSet<_>>(); - let packages = unique_packages(metadata)?; - let nodes = unique_nodes(metadata)?; - let workspace_members = metadata - .workspace_members - .iter() - .map(String::as_str) - .collect::<BTreeSet<_>>(); - let facade_is_local = metadata.workspace_members.iter().any(|id| { - packages - .get(id.as_str()) - .is_some_and(|package| package.name == "radroots") - }); - - let mut selected = BTreeSet::new(); - for name in &approved { - let matching = packages - .values() - .filter(|package| package.name == **name) - .copied() - .collect::<Vec<_>>(); - if matching.len() > 1 { - return Err(format!( - "release package {name} resolves to multiple package identities" - )); - } - let Some(package) = matching.first().copied() else { - if facade_is_local { - return Err(format!( - "complete SDK release graph is missing approved package {name}" - )); - } - continue; - }; - if facade_is_local || workspace_members.contains(package.id.as_str()) { - selected.insert(*name); - } - if package.version != RELEASE_VERSION { - return Err(format!( - "release package {name} resolved version {} instead of {RELEASE_VERSION}", - package.version - )); - } - validate_package_source(architecture, package, &workspace_members)?; - if !nodes.contains_key(package.id.as_str()) { - return Err(format!( - "release package {name} has no Cargo resolve node; manifest={}", - package.manifest_path - )); - } - } - if selected.is_empty() { - return Err("release graph selected no approved workspace packages".to_owned()); - } - if facade_is_local && selected.len() != architecture.package_count { - return Err(format!( - "complete SDK release graph selected {} packages instead of {}", - selected.len(), - architecture.package_count - )); - } - - let mut dependency_sets = selected - .iter() - .map(|name| (*name, BTreeSet::new())) - .collect::<BTreeMap<_, _>>(); - let mut violations = Vec::new(); - - for owner_name in &selected { - let owner = packages - .values() - .find(|package| package.name == **owner_name) - .copied() - .ok_or_else(|| format!("selected release package {owner_name} disappeared"))?; - let node = nodes - .get(owner.id.as_str()) - .copied() - .ok_or_else(|| format!("release package {owner_name} has no resolve node"))?; - for edge in &node.deps { - let dependency = packages - .get(edge.pkg.as_str()) - .copied() - .ok_or_else(|| format!("resolved dependency {} has no package record", edge.pkg))?; - if edge.dep_kinds.is_empty() { - return Err(format!( - "resolved edge {} -> {} has no dependency kind", - owner.name, dependency.name - )); - } - for edge_kind in &edge.dep_kinds { - let kind = normalized_kind(edge_kind.kind.as_deref()); - if !ALL_KINDS.contains(&kind) { - return Err(format!( - "resolved edge {} -> {} uses unsupported dependency kind {kind}", - owner.name, dependency.name - )); - } - let declaration = resolve_declaration(owner, dependency, edge_kind)?; - if is_radroots_family(&dependency.name) - && !approved.contains(dependency.name.as_str()) - { - violations.push(format_edge( - owner, - dependency, - edge, - edge_kind, - declaration, - "public-to-private edge", - )); - } - if REGISTRY_KINDS.contains(&kind) && selected.contains(dependency.name.as_str()) { - dependency_sets - .get_mut(owner_name) - .expect("selected owner dependency set") - .insert(dependency.name.as_str()); - } - } - } - } - if !violations.is_empty() { - violations.sort(); - return Err(format!( - "release graph contains forbidden edge(s):\n{}", - violations.join("\n") - )); - } - - validate_declared_architecture_edges(architecture, &selected, &dependency_sets)?; - let order = dependency_first_order(&selected, &dependency_sets, &hints)?; - let expected = selected.iter().copied().collect::<Vec<_>>(); - let mut expected = expected; - expected.sort_by_key(|name| hints[name]); - if order != expected { - return Err(format!( - "Cargo-derived publication order [{}] differs from architecture order [{}]", - order.join(", "), - expected.join(", ") - )); - } - Ok(order.into_iter().map(str::to_owned).collect()) -} - -fn validate_package_source( - architecture: &Architecture, - package: &CargoPackage, - workspace_members: &BTreeSet<&str>, -) -> Result<(), String> { - if workspace_members.contains(package.id.as_str()) { - return if package.source.is_none() { - Ok(()) - } else { - Err(format!( - "workspace release package {} must resolve locally; source={:?}", - package.name, package.source - )) - }; - } - - let repository = [ - &architecture.repositories.lib, - &architecture.repositories.sdk, - ] - .into_iter() - .find(|repository| repository.packages.iter().any(|name| name == &package.name)) - .ok_or_else(|| { - format!( - "release package {} has no repository allocation", - package.name - ) - })?; - let source = package.source.as_deref().ok_or_else(|| { - format!( - "external release package {} must resolve from an exact Git revision", - package.name - ) - })?; - validate_exact_git_source(source, &repository.url).map_err(|reason| { - format!( - "external release package {} has invalid source {source}: {reason}", - package.name - ) - }) -} - -fn validate_exact_git_source(source: &str, repository_url: &str) -> Result<(), &'static str> { - let canonical_url = repository_url - .trim_end_matches('/') - .trim_end_matches(".git"); - let prefix = format!("git+{canonical_url}.git?rev="); - let revision_and_commit = source - .strip_prefix(&prefix) - .ok_or("repository URL or exact-revision query does not match its allocation")?; - let (revision, commit) = revision_and_commit - .split_once('#') - .ok_or("resolved commit fragment is absent")?; - if revision.len() != 40 - || commit.len() != 40 - || !revision.bytes().all(|byte| byte.is_ascii_hexdigit()) - || !commit.bytes().all(|byte| byte.is_ascii_hexdigit()) - { - return Err("revision and resolved commit must be full hexadecimal object IDs"); - } - if revision != commit { - return Err("resolved commit does not equal the requested exact revision"); - } - Ok(()) -} - -fn validate_architecture(architecture: &Architecture) -> Result<BTreeMap<&str, usize>, String> { - if architecture.spec_id != SPEC_ID { - return Err(format!("architecture spec_id must be {SPEC_ID}")); - } - if architecture.package_count != 19 || architecture.package.len() != 19 { - return Err("architecture must define exactly 19 release packages".to_owned()); - } - let mut hints = BTreeMap::new(); - let mut seen_hints = BTreeSet::new(); - for package in &architecture.package { - if hints - .insert(package.name.as_str(), package.publish_order_hint) - .is_some() - { - return Err(format!("duplicate architecture package {}", package.name)); - } - if !seen_hints.insert(package.publish_order_hint) { - return Err(format!( - "duplicate publish_order_hint {}", - package.publish_order_hint - )); - } - } - if seen_hints != (1..=19).collect::<BTreeSet<_>>() { - return Err("publish_order_hint values must be the exact range 1..=19".to_owned()); - } - Ok(hints) -} - -fn unique_packages(metadata: &CargoMetadata) -> Result<BTreeMap<&str, &CargoPackage>, String> { - let mut packages = BTreeMap::new(); - for package in &metadata.packages { - if packages.insert(package.id.as_str(), package).is_some() { - return Err(format!("duplicate Cargo package id {}", package.id)); - } - } - Ok(packages) -} - -fn unique_nodes(metadata: &CargoMetadata) -> Result<BTreeMap<&str, &CargoNode>, String> { - let resolve = metadata - .resolve - .as_ref() - .ok_or_else(|| "cargo metadata did not include a resolved release graph".to_owned())?; - let mut nodes = BTreeMap::new(); - for node in &resolve.nodes { - if nodes.insert(node.id.as_str(), node).is_some() { - return Err(format!("duplicate Cargo resolve node {}", node.id)); - } - } - Ok(nodes) -} - -fn resolve_declaration<'a>( - owner: &'a CargoPackage, - dependency: &CargoPackage, - edge_kind: &CargoDependencyKind, -) -> Result<&'a CargoDependency, String> { - let kind = normalized_kind(edge_kind.kind.as_deref()); - let candidates = owner - .dependencies - .iter() - .filter(|candidate| { - candidate.name == dependency.name - && normalized_kind(candidate.kind.as_deref()) == kind - && candidate.target == edge_kind.target - }) - .collect::<Vec<_>>(); - match candidates.as_slice() { - [declaration] => Ok(*declaration), - [] => Err(format!( - "resolved edge {} -> {} kind={kind} target={} has no matching declaration", - owner.name, - dependency.name, - target_label(edge_kind.target.as_deref()) - )), - _ => Err(format!( - "resolved edge {} -> {} kind={kind} target={} has ambiguous declarations", - owner.name, - dependency.name, - target_label(edge_kind.target.as_deref()) - )), - } -} - -fn format_edge( - owner: &CargoPackage, - dependency: &CargoPackage, - edge: &CargoNodeDependency, - edge_kind: &CargoDependencyKind, - declaration: &CargoDependency, - reason: &str, -) -> String { - let features = declaration - .features - .iter() - .cloned() - .collect::<BTreeSet<_>>() - .into_iter() - .collect::<Vec<_>>() - .join(","); - format!( - "{reason}: {} -> {}; alias={}; declaration_alias={}; kind={}; target={}; features=[{}]; default_features={}; owner_manifest={}; dependency_manifest={}", - owner.name, - dependency.name, - edge.name, - declaration.rename.as_deref().unwrap_or(&declaration.name), - normalized_kind(edge_kind.kind.as_deref()), - target_label(edge_kind.target.as_deref()), - features, - declaration.uses_default_features, - owner.manifest_path, - dependency.manifest_path, - ) -} - -fn validate_declared_architecture_edges<'a>( - architecture: &'a Architecture, - selected: &BTreeSet<&'a str>, - resolved: &BTreeMap<&'a str, BTreeSet<&'a str>>, -) -> Result<(), String> { - for package in &architecture.package { - if !selected.contains(package.name.as_str()) { - continue; - } - for optional in &package.optional_radroots_dependencies { - if !selected.contains(optional.as_str()) { - return Err(format!( - "{} optional architecture dependency {optional} is outside the selected public graph", - package.name - )); - } - } - let declared = package - .required_radroots_dependencies - .iter() - .map(String::as_str) - .filter(|dependency| selected.contains(dependency)) - .collect::<BTreeSet<_>>(); - let actual = resolved - .get(package.name.as_str()) - .ok_or_else(|| format!("missing resolved dependency set for {}", package.name))?; - if !declared.is_subset(actual) { - let missing = declared.difference(actual).copied().collect::<Vec<_>>(); - return Err(format!( - "{} architecture dependency declarations are absent from the all-feature Cargo graph: {}", - package.name, - missing.join(", ") - )); - } - } - Ok(()) -} - -fn dependency_first_order<'a>( - selected: &BTreeSet<&'a str>, - dependency_sets: &BTreeMap<&'a str, BTreeSet<&'a str>>, - hints: &BTreeMap<&'a str, usize>, -) -> Result<Vec<&'a str>, String> { - let mut remaining = dependency_sets.clone(); - let mut order = Vec::with_capacity(selected.len()); - while !remaining.is_empty() { - let next = remaining - .iter() - .filter(|(_, dependencies)| dependencies.iter().all(|name| order.contains(name))) - .map(|(name, _)| *name) - .min_by_key(|name| hints[name]); - let Some(next) = next else { - let blocked = remaining - .iter() - .map(|(name, dependencies)| { - format!( - "{name}->[{}]", - dependencies.iter().copied().collect::<Vec<_>>().join(",") - ) - }) - .collect::<Vec<_>>() - .join("; "); - return Err(format!( - "release graph contains a publication cycle: {blocked}" - )); - }; - remaining.remove(next); - order.push(next); - } - Ok(order) -} - -fn is_radroots_family(name: &str) -> bool { - name == "radroots" || name.starts_with("radroots_") -} - -fn normalized_kind(kind: Option<&str>) -> &str { - kind.unwrap_or("normal") -} - -fn target_label(target: Option<&str>) -> &str { - target.unwrap_or("all") -} - -#[cfg(test)] -mod tests { - use super::{Architecture, CargoMetadata, validate, validate_exact_git_source}; - - const ARCHITECTURE: &str = include_str!("../../../docs/specs/radroots_crates_release_v1.toml"); - - #[test] - fn current_architecture_contract_has_a_complete_order() { - let architecture = toml::from_str::<Architecture>(ARCHITECTURE).expect("architecture"); - let hints = super::validate_architecture(&architecture).expect("valid order"); - assert_eq!(hints.len(), 19); - assert_eq!(hints["radroots_core"], 1); - assert_eq!(hints["radroots"], 19); - } - - #[test] - fn exact_cross_repository_git_sources_require_the_requested_commit() { - const REVISION: &str = "f7f456d906d91aadbb9fac4850afaa8c5723c3e9"; - let source = - format!("git+https://github.com/radrootslabs/lib.git?rev={REVISION}#{REVISION}"); - validate_exact_git_source(&source, "https://github.com/radrootslabs/lib") - .expect("exact source"); - - let drifted = format!( - "git+https://github.com/radrootslabs/lib.git?rev={REVISION}#{}", - "1".repeat(40) - ); - assert!( - validate_exact_git_source(&drifted, "https://github.com/radrootslabs/lib").is_err() - ); - assert!( - validate_exact_git_source( - "registry+https://github.com/rust-lang/crates.io-index", - "https://github.com/radrootslabs/lib" - ) - .is_err() - ); - } - - #[test] - fn metadata_without_resolve_graph_fails_closed() { - let architecture = toml::from_str::<Architecture>(ARCHITECTURE).expect("architecture"); - let metadata: CargoMetadata = - serde_json::from_str(r#"{"packages":[],"workspace_members":[],"resolve":null}"#) - .expect("metadata"); - let error = validate(&architecture, &metadata).expect_err("missing graph"); - assert!( - error.contains("selected no approved workspace packages") - || error.contains("resolved release graph") - ); - } - - #[test] - fn public_to_private_target_edge_reports_complete_provenance() { - let architecture = toml::from_str::<Architecture>(ARCHITECTURE).expect("architecture"); - let metadata: CargoMetadata = serde_json::from_str( - r#"{ - "packages": [ - { - "id": "core", - "name": "radroots_core", - "version": "0.1.0-alpha", - "source": null, - "manifest_path": "/capsule/crates/core/Cargo.toml", - "dependencies": [{ - "name": "radroots_private_runtime", - "rename": "private_alias", - "kind": "build", - "features": ["danger"], - "target": "cfg(unix)", - "uses_default_features": false - }] - }, - { - "id": "private", - "name": "radroots_private_runtime", - "version": "0.1.0-alpha", - "source": null, - "manifest_path": "/capsule/crates/private/Cargo.toml", - "dependencies": [] - } - ], - "workspace_members": ["core"], - "resolve": {"nodes": [ - {"id": "core", "deps": [{ - "name": "private_alias", - "pkg": "private", - "dep_kinds": [{"kind": "build", "target": "cfg(unix)"}] - }]}, - {"id": "private", "deps": []} - ]} - }"#, - ) - .expect("metadata"); - let error = validate(&architecture, &metadata).expect_err("private edge"); - assert!(error.contains("public-to-private edge")); - assert!(error.contains("alias=private_alias")); - assert!(error.contains("kind=build")); - assert!(error.contains("target=cfg(unix)")); - assert!(error.contains("features=[danger]")); - assert!(error.contains("default_features=false")); - } - - #[test] - fn registry_graph_cycle_fails_closed() { - let architecture = toml::from_str::<Architecture>(ARCHITECTURE).expect("architecture"); - let metadata: CargoMetadata = serde_json::from_str( - r#"{ - "packages": [ - {"id":"core","name":"radroots_core","version":"0.1.0-alpha","source":null,"manifest_path":"/core/Cargo.toml","dependencies":[{"name":"radroots_identity","rename":null,"kind":null,"features":[],"target":null,"uses_default_features":true}]}, - {"id":"identity","name":"radroots_identity","version":"0.1.0-alpha","source":null,"manifest_path":"/identity/Cargo.toml","dependencies":[{"name":"radroots_core","rename":null,"kind":null,"features":[],"target":null,"uses_default_features":true}]} - ], - "workspace_members":["core","identity"], - "resolve":{"nodes":[ - {"id":"core","deps":[{"name":"radroots_identity","pkg":"identity","dep_kinds":[{"kind":null,"target":null}]}]}, - {"id":"identity","deps":[{"name":"radroots_core","pkg":"core","dep_kinds":[{"kind":null,"target":null}]}]} - ]} - }"#, - ) - .expect("metadata"); - let error = validate(&architecture, &metadata).expect_err("cycle"); - assert!(error.contains("publication cycle")); - assert!(error.contains("radroots_core")); - assert!(error.contains("radroots_identity")); - } -} diff --git a/tools/sdk_xtask_import/src/release_qualification.rs b/tools/sdk_xtask_import/src/release_qualification.rs @@ -1,135 +0,0 @@ -use std::{fs, path::Path, process::Command}; - -use serde::Deserialize; - -#[derive(Debug, Deserialize)] -struct Architecture { - repositories: Repositories, - package: Vec<Package>, -} - -#[derive(Debug, Deserialize)] -struct Repositories { - sdk: Repository, -} - -#[derive(Debug, Deserialize)] -struct Repository { - packages: Vec<String>, -} - -#[derive(Debug, Deserialize)] -struct Package { - name: String, - features: Vec<String>, -} - -#[derive(Debug, PartialEq, Eq)] -struct CargoInvocation { - args: Vec<String>, -} - -pub fn run_feature_matrix(workspace_root: &Path) -> Result<(), String> { - for invocation in feature_matrix(workspace_root)? { - eprintln!("cargo {}", invocation.args.join(" ")); - let status = Command::new("cargo") - .args(&invocation.args) - .current_dir(workspace_root) - .status() - .map_err(|error| format!("failed to start cargo: {error}"))?; - if !status.success() { - return Err(format!( - "public feature qualification failed: cargo {}", - invocation.args.join(" ") - )); - } - } - Ok(()) -} - -fn feature_matrix(workspace_root: &Path) -> Result<Vec<CargoInvocation>, String> { - let path = workspace_root.join("docs/specs/radroots_crates_release_v1.toml"); - let raw = fs::read_to_string(&path) - .map_err(|error| format!("failed to read {}: {error}", path.display()))?; - let architecture = toml::from_str::<Architecture>(&raw) - .map_err(|error| format!("failed to parse {}: {error}", path.display()))?; - let mut packages = architecture - .package - .into_iter() - .filter(|package| { - architecture - .repositories - .sdk - .packages - .contains(&package.name) - }) - .collect::<Vec<_>>(); - packages.sort_by(|left, right| left.name.cmp(&right.name)); - if packages.len() != 2 { - return Err(format!( - "SDK feature qualification requires exactly 2 public packages, found {}", - packages.len() - )); - } - - let mut invocations = Vec::new(); - for package in packages { - invocations.push(check_invocation(&package.name, None, true)); - invocations.push(check_invocation(&package.name, None, false)); - for feature in package.features { - invocations.push(check_invocation(&package.name, Some(&feature), true)); - } - invocations.push(CargoInvocation { - args: vec![ - "check".to_owned(), - "-p".to_owned(), - package.name, - "--all-targets".to_owned(), - "--all-features".to_owned(), - "--locked".to_owned(), - ], - }); - } - Ok(invocations) -} - -fn check_invocation( - package: &str, - feature: Option<&str>, - no_default_features: bool, -) -> CargoInvocation { - let mut args = vec!["check".to_owned(), "-p".to_owned(), package.to_owned()]; - if no_default_features { - args.push("--lib".to_owned()); - args.push("--no-default-features".to_owned()); - } else { - args.push("--all-targets".to_owned()); - } - if let Some(feature) = feature { - args.push("--features".to_owned()); - args.push(feature.to_owned()); - } - args.push("--locked".to_owned()); - CargoInvocation { args } -} - -#[cfg(test)] -mod tests { - use super::feature_matrix; - - #[test] - fn current_catalog_generates_both_front_door_feature_matrices() { - let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) - .parent() - .and_then(std::path::Path::parent) - .expect("workspace root"); - let matrix = feature_matrix(root).expect("feature matrix"); - assert!(matrix.len() > 20); - for package in ["radroots", "radroots_sdk"] { - assert!(matrix.iter().any(|entry| { - entry.args.windows(2).any(|pair| pair == ["-p", package]) - && entry.args.iter().any(|arg| arg == "--all-features") - })); - } - } -} diff --git a/tools/sdk_xtask_import/src/smoke.rs b/tools/sdk_xtask_import/src/smoke.rs @@ -1,236 +0,0 @@ -use std::{fs, path::Path, process::Command}; - -use crate::{ - fs::workspace_root, - wasm::{ResolvedRustToolchain, resolve_rust_toolchain}, -}; - -pub fn run(args: &[String]) -> Result<(), String> { - match args { - [target] if target == "sdk-rust-local" => sdk_rust_local(), - [target] if target == "facade-rust-local" => facade_rust_local(), - [target] if target == "front-doors-rust-local" => { - facade_rust_local()?; - sdk_rust_local() - } - _ => Err( - "usage: cargo xtask smoke facade-rust-local | sdk-rust-local | front-doors-rust-local" - .to_owned(), - ), - } -} - -fn facade_rust_local() -> Result<(), String> { - let root = workspace_root()?; - let facade_path = root.join("crates/radroots"); - let lib_root = sibling_lib_root(&root)?; - let toolchain = resolve_rust_toolchain(&root)?; - run_clean_consumer( - "facade", - &render_facade_consumer_manifest(&facade_path, &lib_root)?, - FACADE_CONSUMER_MAIN, - &toolchain, - ) -} - -fn sdk_rust_local() -> Result<(), String> { - let root = workspace_root()?; - let sdk_path = root.join("crates/sdk"); - let lib_root = sibling_lib_root(&root)?; - let toolchain = resolve_rust_toolchain(&root)?; - run_clean_consumer( - "SDK", - &render_sdk_consumer_manifest(&sdk_path, &lib_root)?, - SDK_CONSUMER_MAIN, - &toolchain, - ) -} - -fn sibling_lib_root(root: &Path) -> Result<std::path::PathBuf, String> { - Ok(root - .parent() - .ok_or_else(|| format!("{} has no repository parent", root.display()))? - .join("lib")) -} - -fn run_clean_consumer( - label: &str, - manifest: &str, - main: &str, - toolchain: &ResolvedRustToolchain, -) -> Result<(), String> { - let tempdir = - tempfile::tempdir().map_err(|error| format!("failed to create smoke tempdir: {error}"))?; - fs::write(tempdir.path().join("Cargo.toml"), manifest) - .map_err(|error| format!("failed to write {label} smoke manifest: {error}"))?; - let src_dir = tempdir.path().join("src"); - fs::create_dir_all(&src_dir) - .map_err(|error| format!("failed to create {}: {error}", src_dir.display()))?; - fs::write(src_dir.join("main.rs"), main) - .map_err(|error| format!("failed to write {label} smoke consumer: {error}"))?; - let status = smoke_cargo_run_command(tempdir.path(), toolchain) - .status() - .map_err(|error| format!("failed to run {label} smoke consumer: {error}"))?; - if status.success() { - Ok(()) - } else { - Err(format!( - "{label} Rust local smoke failed with status {status}" - )) - } -} - -fn render_facade_consumer_manifest(facade_path: &Path, lib_root: &Path) -> Result<String, String> { - let facade_path = serde_json::to_string(&facade_path.to_string_lossy()) - .map_err(|error| format!("failed to render facade path: {error}"))?; - let mut manifest = format!( - r#"[package] -name = "radroots_facade_host_smoke" -version = "0.1.0" -edition = "2024" -publish = false - -[dependencies] -radroots = {{ path = {facade_path} }} - -[patch.crates-io] -"#, - ); - append_lib_patches(&mut manifest, lib_root)?; - Ok(manifest) -} - -fn render_sdk_consumer_manifest(sdk_path: &Path, lib_root: &Path) -> Result<String, String> { - let sdk_path = serde_json::to_string(&sdk_path.to_string_lossy()) - .map_err(|error| format!("failed to render SDK path: {error}"))?; - let mut manifest = format!( - r#"[package] -name = "radroots_sdk_host_smoke" -version = "0.1.0" -edition = "2024" -publish = false - -[dependencies] -radroots_sdk = {{ path = {sdk_path}, default-features = true }} - -[patch.crates-io] -"#, - ); - append_lib_patches(&mut manifest, lib_root)?; - Ok(manifest) -} - -fn append_lib_patches(manifest: &mut String, lib_root: &Path) -> Result<(), String> { - for (package, relative_path) in [ - ("radroots_blossom", "crates/blossom"), - ("radroots_core", "crates/core"), - ("radroots_event", "crates/event"), - ("radroots_event_codec", "crates/event_codec"), - ("radroots_geonames", "crates/geonames"), - ("radroots_identity", "crates/identity"), - ("radroots_nostr", "crates/nostr"), - ("radroots_nostr_connect", "crates/nostr_connect"), - ("radroots_protocol", "crates/protocol"), - ("radroots_secrets", "crates/secrets"), - ("radroots_signing", "crates/signing"), - ("radroots_storage", "crates/storage"), - ("radroots_storage_sqlite", "crates/storage_sqlite"), - ("radroots_sync", "crates/sync"), - ("radroots_trade", "crates/trade"), - ("radroots_transport", "crates/transport"), - ("radroots_transport_nostr", "crates/transport_nostr"), - ] { - let path = serde_json::to_string(&lib_root.join(relative_path).to_string_lossy()) - .map_err(|error| format!("failed to render {package} patch path: {error}"))?; - manifest.push_str(&format!("{package} = {{ path = {path} }}\n")); - } - Ok(()) -} - -fn smoke_cargo_run_command(path: &Path, toolchain: &ResolvedRustToolchain) -> Command { - let mut command = Command::new(&toolchain.cargo); - command.arg("run").arg("--quiet").current_dir(path); - toolchain.apply_to_command(&mut command); - command -} - -const SDK_CONSUMER_MAIN: &str = r#"use radroots_sdk::{ClientBuilder, error::ErrorKind}; - -fn main() { - let error = match ClientBuilder::new().build() { - Ok(_) => panic!("empty SDK builder must fail closed"), - Err(error) => error, - }; - assert_eq!(error.kind(), ErrorKind::MissingStorage); -} -"#; - -const FACADE_CONSUMER_MAIN: &str = r#"fn main() -> radroots::Result<()> { - let client = radroots::client::memory().build()?; - assert!(!client.is_closed()); - assert!(radroots::client::local_only().is_local_only()); - Ok(()) -} -"#; - -#[cfg(test)] -mod tests { - use std::path::{Path, PathBuf}; - - use crate::wasm::ResolvedRustToolchain; - - use super::{ - render_facade_consumer_manifest, render_sdk_consumer_manifest, run, smoke_cargo_run_command, - }; - - #[test] - fn retired_sdk_knowledge_smoke_is_not_a_command_surface() { - assert!(run(&["knowledge-rust-local".to_owned()]).is_err()); - } - - #[test] - fn sdk_smoke_consumer_uses_final_api_and_local_lower_package_patches() { - let manifest = render_sdk_consumer_manifest( - Path::new("/tmp/radroots_sdk"), - Path::new("/tmp/radroots_lib"), - ) - .expect("manifest"); - - assert!(manifest.contains("name = \"radroots_sdk_host_smoke\"")); - assert!(manifest.contains("radroots_sdk = { path = \"/tmp/radroots_sdk\"")); - assert!( - manifest.contains("radroots_storage = { path = \"/tmp/radroots_lib/crates/storage\" }") - ); - assert!(!manifest.contains("runtime =")); - assert!(!manifest.contains("signer-adapters")); - } - - #[test] - fn facade_smoke_consumer_uses_curated_front_door() { - let manifest = render_facade_consumer_manifest( - Path::new("/tmp/radroots_facade"), - Path::new("/tmp/radroots_lib"), - ) - .expect("manifest"); - - assert!(manifest.contains("name = \"radroots_facade_host_smoke\"")); - assert!(manifest.contains("radroots = { path = \"/tmp/radroots_facade\" }")); - assert!(!manifest.contains("radroots_sdk = { path")); - } - - #[test] - fn smoke_command_uses_resolved_cargo_path() { - let cargo = PathBuf::from("/tmp/rust-toolchain/bin/cargo"); - let toolchain = ResolvedRustToolchain { - channel: "1.97.0".to_owned(), - rustc: PathBuf::from("/tmp/rust-toolchain/bin/rustc"), - cargo: cargo.clone(), - bin_dir: PathBuf::from("/tmp/rust-toolchain/bin"), - }; - - let command = smoke_cargo_run_command(Path::new("/tmp/smoke"), &toolchain); - - assert_eq!(command.get_program(), cargo.as_os_str()); - assert_eq!(command.get_args().next(), Some(std::ffi::OsStr::new("run"))); - } -} diff --git a/tools/sdk_xtask_import/src/supply_chain_qualification.rs b/tools/sdk_xtask_import/src/supply_chain_qualification.rs @@ -1,729 +0,0 @@ -use std::collections::{BTreeMap, BTreeSet}; -use std::fs; -use std::path::{Path, PathBuf}; -use std::process::Command; - -use serde::{Deserialize, Serialize}; -use serde_json::{Value, json}; -use sha2::{Digest, Sha256}; - -const CONTRACT_PATH: &str = "contracts/releases/supply_chain.toml"; -const DENY_PATH: &str = "deny.toml"; -const SBOM_FILENAME: &str = "radroots-release-v1-sbom.json"; - -#[derive(Debug, Deserialize)] -struct Contract { - schema_version: u16, - spec_id: String, - package_version: String, - tools: Tools, - sbom: Sbom, - advisory_exception: Vec<AdvisoryException>, - package: Vec<Package>, -} - -#[derive(Debug, Deserialize)] -struct Tools { - cargo_deny: String, - cargo_cyclonedx: String, -} - -#[derive(Debug, Deserialize)] -struct Sbom { - format: String, - spec_version: String, - target: String, - all_features: bool, - source_date_epoch: u64, -} - -#[derive(Clone, Debug, Deserialize, Serialize)] -struct AdvisoryException { - id: String, - package: String, - affected_version: String, - introduced_by: String, - classification: String, - mitigation: String, - remove_when: String, -} - -#[derive(Debug, Deserialize)] -struct Package { - name: String, - manifest_path: String, -} - -#[derive(Debug, Deserialize)] -struct Metadata { - packages: Vec<MetadataPackage>, -} - -#[derive(Debug, Deserialize)] -struct MetadataPackage { - name: String, - version: String, - manifest_path: PathBuf, -} - -struct GeneratedSboms(Vec<PathBuf>); - -impl Drop for GeneratedSboms { - fn drop(&mut self) { - for path in &self.0 { - let _ = fs::remove_file(path); - } - } -} - -pub fn run(root: &Path) -> Result<(), String> { - let contract = load(root)?; - validate(root, &contract, 2)?; - verify_tools(&contract)?; - let metadata = load_metadata(root)?; - qualify_dependencies(root, &contract)?; - let sbom_hashes = qualify_sboms(root, &contract, &metadata)?; - let provenance = build_provenance(root, &contract, &sbom_hashes)?; - validate_provenance(&provenance, &contract)?; - eprintln!( - "qualified {} package supply chains; provenance sha256={}", - contract.package.len(), - sha256(&serde_json::to_vec(&provenance).map_err(|error| error.to_string())?) - ); - Ok(()) -} - -fn load(root: &Path) -> Result<Contract, String> { - let path = root.join(CONTRACT_PATH); - let raw = fs::read_to_string(&path) - .map_err(|error| format!("failed to read {}: {error}", path.display()))?; - toml::from_str(&raw).map_err(|error| format!("failed to parse {}: {error}", path.display())) -} - -fn validate(root: &Path, contract: &Contract, expected_packages: usize) -> Result<(), String> { - if contract.schema_version != 1 - || contract.spec_id != "radroots.crates.release.v1" - || contract.package_version != "0.1.0-alpha" - || contract.tools.cargo_deny != "0.19.8" - || contract.tools.cargo_cyclonedx != "0.5.9" - || contract.sbom.format != "json" - || contract.sbom.spec_version != "1.5" - || contract.sbom.target != "all" - || !contract.sbom.all_features - || contract.sbom.source_date_epoch != 0 - { - return Err("invalid supply-chain qualification contract".to_owned()); - } - - let names = contract - .package - .iter() - .map(|package| package.name.as_str()) - .collect::<BTreeSet<_>>(); - let manifests = contract - .package - .iter() - .map(|package| package.manifest_path.as_str()) - .collect::<BTreeSet<_>>(); - if names.len() != expected_packages - || names.len() != contract.package.len() - || manifests.len() != contract.package.len() - { - return Err(format!( - "supply-chain contract requires exactly {expected_packages} unique packages and manifests" - )); - } - for package in &contract.package { - let path = root.join(&package.manifest_path); - let raw = fs::read_to_string(&path) - .map_err(|error| format!("failed to read {}: {error}", path.display()))?; - let manifest: toml::Value = toml::from_str(&raw) - .map_err(|error| format!("failed to parse {}: {error}", path.display()))?; - let package_table = manifest - .get("package") - .and_then(toml::Value::as_table) - .ok_or_else(|| format!("{} has no package table", path.display()))?; - let name = package_table.get("name").and_then(toml::Value::as_str); - let version = package_table.get("version").and_then(toml::Value::as_str); - let inherits_workspace_version = package_table - .get("version") - .and_then(toml::Value::as_table) - .and_then(|version| version.get("workspace")) - .and_then(toml::Value::as_bool) - == Some(true); - if name != Some(package.name.as_str()) - || version != Some(contract.package_version.as_str()) && !inherits_workspace_version - { - return Err(format!( - "{} does not declare {} {}", - path.display(), - package.name, - contract.package_version - )); - } - } - - validate_exceptions(root, contract) -} - -fn validate_exceptions(root: &Path, contract: &Contract) -> Result<(), String> { - let expected = BTreeSet::from([ - "CARGO-YANKED-SPIN-0.9.8".to_owned(), - "RUSTSEC-2024-0384".to_owned(), - "RUSTSEC-2024-0421".to_owned(), - ]); - let actual = contract - .advisory_exception - .iter() - .map(|exception| exception.id.clone()) - .collect::<BTreeSet<_>>(); - if actual != expected || actual.len() != contract.advisory_exception.len() { - return Err("supply-chain advisory exceptions are not the exact approved set".to_owned()); - } - for exception in &contract.advisory_exception { - if exception.package.is_empty() - || exception.affected_version.is_empty() - || exception.introduced_by.is_empty() - || exception.classification.is_empty() - || exception.mitigation.is_empty() - || exception.remove_when.is_empty() - { - return Err(format!("advisory exception {} is incomplete", exception.id)); - } - let exact = match exception.id.as_str() { - "RUSTSEC-2024-0384" => { - exception.package == "instant" - && exception.affected_version == "0.1.13" - && exception.classification == "unmaintained" - && exception.remove_when == "nostr >=0.45.0 stable" - } - "RUSTSEC-2024-0421" => { - exception.package == "idna" - && exception.affected_version == "0.5.0" - && exception.classification == "vulnerability" - && exception.remove_when == "nostr >=0.45.0 stable" - } - "CARGO-YANKED-SPIN-0.9.8" => { - exception.package == "spin" - && exception.affected_version == "0.9.8" - && exception.classification == "yanked" - && exception.remove_when - == "sqlx no longer resolves flume 0.12.0 with spin 0.9.8" - } - _ => false, - }; - if !exact { - return Err(format!( - "advisory exception {} differs from its exact approved policy", - exception.id - )); - } - } - - let lock_raw = fs::read_to_string(root.join("Cargo.lock")) - .map_err(|error| format!("failed to read Cargo.lock: {error}"))?; - let lock: toml::Value = toml::from_str(&lock_raw) - .map_err(|error| format!("failed to parse Cargo.lock: {error}"))?; - let locked_packages = lock - .get("package") - .and_then(toml::Value::as_array) - .ok_or_else(|| "Cargo.lock contains no packages".to_owned())?; - for exception in &contract.advisory_exception { - let present = locked_packages.iter().any(|package| { - package.get("name").and_then(toml::Value::as_str) == Some(exception.package.as_str()) - && package.get("version").and_then(toml::Value::as_str) - == Some(exception.affected_version.as_str()) - }); - if !present { - return Err(format!( - "advisory exception {} is stale because {} {} is absent from Cargo.lock", - exception.id, exception.package, exception.affected_version - )); - } - } - let patched_url_present = locked_packages.iter().any(|package| { - package.get("name").and_then(toml::Value::as_str) == Some("url") - && package - .get("version") - .and_then(toml::Value::as_str) - .and_then(|version| semver::Version::parse(version).ok()) - .is_some_and(|version| version >= semver::Version::new(2, 5, 4)) - }); - if !patched_url_present { - return Err("the IDNA exception requires url 2.5.4 or newer in Cargo.lock".to_owned()); - } - - let deny_raw = fs::read_to_string(root.join(DENY_PATH)) - .map_err(|error| format!("failed to read {DENY_PATH}: {error}"))?; - let deny: toml::Value = toml::from_str(&deny_raw) - .map_err(|error| format!("failed to parse {DENY_PATH}: {error}"))?; - let ignored = deny - .get("advisories") - .and_then(|value| value.get("ignore")) - .and_then(toml::Value::as_array) - .ok_or_else(|| "deny.toml advisories.ignore is missing".to_owned())? - .iter() - .filter_map(toml::Value::as_str) - .map(str::to_owned) - .collect::<BTreeSet<_>>(); - let expected_ignored = expected - .iter() - .filter(|id| id.starts_with("RUSTSEC-")) - .cloned() - .collect::<BTreeSet<_>>(); - if ignored != expected_ignored { - return Err("deny.toml advisory ignores differ from the governed exceptions".to_owned()); - } - let allowed_git = deny - .get("sources") - .and_then(|value| value.get("allow-git")) - .and_then(toml::Value::as_array) - .ok_or_else(|| "deny.toml sources.allow-git is missing".to_owned())? - .iter() - .filter_map(toml::Value::as_str) - .collect::<BTreeSet<_>>(); - if allowed_git != BTreeSet::from(["https://github.com/radrootslabs/lib.git"]) { - return Err( - "deny.toml Git sources must allow only the allocated Radroots lib repository" - .to_owned(), - ); - } - - let relay_source = root.join("crates/transport_nostr/src/relay.rs"); - if relay_source.exists() { - let source = fs::read_to_string(&relay_source) - .map_err(|error| format!("failed to read {}: {error}", relay_source.display()))?; - if !source.contains("Url::parse(canonical)") { - return Err( - "the IDNA exception requires patched relay URL canonicalization".to_owned(), - ); - } - } - Ok(()) -} - -fn verify_tools(contract: &Contract) -> Result<(), String> { - verify_tool( - &["deny", "--version"], - "cargo-deny", - &contract.tools.cargo_deny, - )?; - verify_tool( - &["cyclonedx", "--version"], - "cargo-cyclonedx", - &contract.tools.cargo_cyclonedx, - ) -} - -fn verify_tool(args: &[&str], name: &str, expected: &str) -> Result<(), String> { - let output = Command::new("cargo") - .args(args) - .output() - .map_err(|error| format!("failed to start {name}: {error}"))?; - if !output.status.success() { - return Err(format!("{name} {expected} is required")); - } - let stdout = String::from_utf8_lossy(&output.stdout); - let installed = stdout - .split_whitespace() - .find_map(|value| semver::Version::parse(value).ok()) - .ok_or_else(|| format!("could not parse {name} version: {stdout}"))?; - let expected = semver::Version::parse(expected) - .map_err(|error| format!("invalid governed {name} version: {error}"))?; - if installed != expected { - return Err(format!("{name} {expected} is required, found {installed}")); - } - Ok(()) -} - -fn load_metadata(root: &Path) -> Result<Metadata, String> { - let output = Command::new("cargo") - .args(["metadata", "--format-version", "1", "--locked", "--no-deps"]) - .current_dir(root) - .output() - .map_err(|error| format!("failed to start cargo metadata: {error}"))?; - if !output.status.success() { - return Err("locked cargo metadata failed".to_owned()); - } - serde_json::from_slice(&output.stdout) - .map_err(|error| format!("failed to parse cargo metadata: {error}")) -} - -fn qualify_dependencies(root: &Path, contract: &Contract) -> Result<(), String> { - let mut saw_governed_yank = false; - for package in &contract.package { - let manifest = root.join(&package.manifest_path); - let common = [ - "deny", - "-L", - "error", - "--manifest-path", - manifest - .to_str() - .ok_or_else(|| "non-UTF-8 package manifest path".to_owned())?, - "--all-features", - "--locked", - "check", - ]; - saw_governed_yank |= qualify_advisories(root, &manifest, &package.name)?; - run_command( - root, - "cargo", - common - .iter() - .copied() - .chain(["bans", "licenses", "sources"]) - .collect::<Vec<_>>(), - &format!("dependency policy failed for {}", package.name), - )?; - } - if !saw_governed_yank { - return Err("the governed spin 0.9.8 yank is stale and must be removed".to_owned()); - } - Ok(()) -} - -fn qualify_advisories(root: &Path, manifest: &Path, package: &str) -> Result<bool, String> { - let output = Command::new("cargo") - .args([ - "deny", - "--format", - "json", - "--log-level", - "warn", - "--manifest-path", - manifest - .to_str() - .ok_or_else(|| "non-UTF-8 package manifest path".to_owned())?, - "--all-features", - "--locked", - "check", - "--allow", - "advisory-not-detected", - "advisories", - ]) - .current_dir(root) - .output() - .map_err(|error| format!("failed to start cargo-deny: {error}"))?; - let stdout = String::from_utf8_lossy(&output.stdout); - let stderr = String::from_utf8_lossy(&output.stderr); - if !output.status.success() { - return Err(format!( - "advisory qualification failed for {package}: {stdout}{stderr}" - )); - } - - let mut saw_governed_yank = false; - for line in stdout.lines().chain(stderr.lines()) { - let Ok(message) = serde_json::from_str::<Value>(line) else { - continue; - }; - if message.get("type").and_then(Value::as_str) != Some("diagnostic") { - continue; - } - let fields = message - .get("fields") - .and_then(Value::as_object) - .ok_or_else(|| "cargo-deny emitted a malformed diagnostic".to_owned())?; - let code = fields - .get("code") - .and_then(Value::as_str) - .unwrap_or_default(); - let krate = fields - .get("graphs") - .and_then(Value::as_array) - .and_then(|graphs| graphs.first()) - .and_then(|graph| graph.get("Krate")); - let exact_governed_yank = code == "yanked" - && krate - .and_then(|krate| krate.get("name")) - .and_then(Value::as_str) - == Some("spin") - && krate - .and_then(|krate| krate.get("version")) - .and_then(Value::as_str) - == Some("0.9.8"); - if exact_governed_yank { - saw_governed_yank = true; - } else { - return Err(format!( - "unapproved cargo-deny diagnostic for {package}: {line}" - )); - } - } - Ok(saw_governed_yank) -} - -fn qualify_sboms( - root: &Path, - contract: &Contract, - metadata: &Metadata, -) -> Result<BTreeMap<String, String>, String> { - let mut paths = metadata - .packages - .iter() - .map(|package| { - package - .manifest_path - .parent() - .expect("manifest has parent") - .join(SBOM_FILENAME) - }) - .collect::<Vec<_>>(); - paths.sort(); - paths.dedup(); - if let Some(path) = paths.iter().find(|path| path.exists()) { - return Err(format!( - "refusing to overwrite pre-existing SBOM {}", - path.display() - )); - } - let _generated = GeneratedSboms(paths); - - let status = Command::new("cargo") - .args([ - "cyclonedx", - "--quiet", - "--manifest-path", - "Cargo.toml", - "--format", - &contract.sbom.format, - "--all-features", - "--target", - &contract.sbom.target, - "--spec-version", - &contract.sbom.spec_version, - "--license-strict", - "--license-accept-named", - "MIT/Apache-2.0", - "--license-accept-named", - "Apache-2.0/MIT", - "--license-accept-named", - "Apache-2.0 / MIT", - "--override-filename", - "radroots-release-v1-sbom", - ]) - .env( - "SOURCE_DATE_EPOCH", - contract.sbom.source_date_epoch.to_string(), - ) - .current_dir(root) - .status() - .map_err(|error| format!("failed to start cargo-cyclonedx: {error}"))?; - if !status.success() { - return Err("CycloneDX SBOM generation failed".to_owned()); - } - - let by_name = metadata - .packages - .iter() - .map(|package| (package.name.as_str(), package)) - .collect::<BTreeMap<_, _>>(); - let mut hashes = BTreeMap::new(); - for package in &contract.package { - let metadata_package = by_name - .get(package.name.as_str()) - .ok_or_else(|| format!("cargo metadata omitted {}", package.name))?; - if metadata_package.version != contract.package_version { - return Err(format!("{} metadata version drifted", package.name)); - } - let path = metadata_package - .manifest_path - .parent() - .expect("manifest has parent") - .join(SBOM_FILENAME); - let raw = fs::read(&path) - .map_err(|error| format!("failed to read {}: {error}", path.display()))?; - let mut sbom: Value = serde_json::from_slice(&raw) - .map_err(|error| format!("invalid SBOM {}: {error}", path.display()))?; - validate_sbom(&sbom, &package.name, &contract.package_version)?; - normalize_sbom(&mut sbom, root); - hashes.insert( - package.name.clone(), - sha256(&serde_json::to_vec(&sbom).map_err(|error| error.to_string())?), - ); - } - Ok(hashes) -} - -fn validate_sbom(sbom: &Value, name: &str, version: &str) -> Result<(), String> { - let component = sbom - .pointer("/metadata/component") - .and_then(Value::as_object) - .ok_or_else(|| format!("{name} SBOM has no root component"))?; - if sbom.get("bomFormat").and_then(Value::as_str) != Some("CycloneDX") - || sbom.get("specVersion").and_then(Value::as_str) != Some("1.5") - || component.get("name").and_then(Value::as_str) != Some(name) - || component.get("version").and_then(Value::as_str) != Some(version) - || sbom - .get("components") - .and_then(Value::as_array) - .is_none_or(Vec::is_empty) - || sbom - .get("dependencies") - .and_then(Value::as_array) - .is_none_or(Vec::is_empty) - { - return Err(format!( - "{name} SBOM is incomplete or identifies the wrong package" - )); - } - Ok(()) -} - -fn normalize_sbom(value: &mut Value, root: &Path) { - normalize_sbom_value(value, root.to_string_lossy().as_ref()); -} - -fn normalize_sbom_value(value: &mut Value, root: &str) { - match value { - Value::Object(object) => { - object.remove("serialNumber"); - for value in object.values_mut() { - normalize_sbom_value(value, root); - } - } - Value::Array(values) => { - for value in values { - normalize_sbom_value(value, root); - } - } - Value::String(string) if string.contains(root) => { - *string = string.replace(root, "$REPOSITORY"); - } - _ => {} - } -} - -fn build_provenance( - root: &Path, - contract: &Contract, - sbom_hashes: &BTreeMap<String, String>, -) -> Result<Value, String> { - let revision = command_stdout(root, "git", &["rev-parse", "HEAD"])?; - if revision.len() != 40 || !revision.bytes().all(|byte| byte.is_ascii_hexdigit()) { - return Err("Git provenance revision is not a full commit ID".to_owned()); - } - let lock = fs::read(root.join("Cargo.lock")) - .map_err(|error| format!("failed to read Cargo.lock: {error}"))?; - let packages = contract - .package - .iter() - .map(|package| { - json!({ - "name": package.name, - "version": contract.package_version, - "manifestPath": package.manifest_path, - "sbomSha256": sbom_hashes.get(&package.name), - }) - }) - .collect::<Vec<_>>(); - Ok(json!({ - "schemaVersion": 1, - "specId": contract.spec_id, - "source": { - "gitCommit": revision, - "cargoLockSha256": sha256(&lock), - }, - "tools": { - "cargoDeny": contract.tools.cargo_deny, - "cargoCyclonedx": contract.tools.cargo_cyclonedx, - }, - "packages": packages, - "advisoryExceptions": contract.advisory_exception, - })) -} - -fn validate_provenance(provenance: &Value, contract: &Contract) -> Result<(), String> { - if provenance.get("schemaVersion").and_then(Value::as_u64) != Some(1) - || provenance.get("specId").and_then(Value::as_str) != Some(contract.spec_id.as_str()) - || provenance - .get("packages") - .and_then(Value::as_array) - .is_none_or(|packages| packages.len() != contract.package.len()) - || provenance - .pointer("/source/cargoLockSha256") - .and_then(Value::as_str) - .is_none_or(|hash| hash.len() != 64) - { - return Err("generated supply-chain provenance is incomplete".to_owned()); - } - Ok(()) -} - -fn run_command(root: &Path, program: &str, args: Vec<&str>, failure: &str) -> Result<(), String> { - let status = Command::new(program) - .args(args) - .current_dir(root) - .status() - .map_err(|error| format!("failed to start {program}: {error}"))?; - if status.success() { - Ok(()) - } else { - Err(failure.to_owned()) - } -} - -fn command_stdout(root: &Path, program: &str, args: &[&str]) -> Result<String, String> { - let output = Command::new(program) - .args(args) - .current_dir(root) - .output() - .map_err(|error| format!("failed to start {program}: {error}"))?; - if !output.status.success() { - return Err(format!("{program} {} failed", args.join(" "))); - } - String::from_utf8(output.stdout) - .map(|value| value.trim().to_owned()) - .map_err(|error| format!("{program} emitted non-UTF-8 output: {error}")) -} - -fn sha256(bytes: &[u8]) -> String { - format!("{:x}", Sha256::digest(bytes)) -} - -#[cfg(test)] -mod tests { - use super::*; - - fn root() -> PathBuf { - Path::new(env!("CARGO_MANIFEST_DIR")) - .parent() - .and_then(Path::parent) - .expect("workspace root") - .to_path_buf() - } - - #[test] - fn current_contract_is_exact_and_exception_bound() { - let root = root(); - let contract = load(&root).expect("contract"); - validate(&root, &contract, 2).expect("valid contract"); - } - - #[test] - fn sbom_validation_rejects_wrong_identity() { - let sbom = json!({ - "bomFormat": "CycloneDX", - "specVersion": "1.5", - "metadata": {"component": {"name": "wrong", "version": "0.1.0-alpha"}}, - "components": [{}], - "dependencies": [{}], - }); - assert!(validate_sbom(&sbom, "radroots_core", "0.1.0-alpha").is_err()); - } - - #[test] - fn normalization_removes_random_and_absolute_identity() { - let mut sbom = json!({ - "serialNumber": "urn:uuid:random", - "path": "/workspace/crate", - }); - normalize_sbom(&mut sbom, Path::new("/workspace")); - assert!(sbom.get("serialNumber").is_none()); - assert_eq!( - sbom.get("path").and_then(Value::as_str), - Some("$REPOSITORY/crate") - ); - } -} diff --git a/tools/sdk_xtask_import/src/target_qualification.rs b/tools/sdk_xtask_import/src/target_qualification.rs @@ -1,199 +0,0 @@ -use std::{fs, path::Path, process::Command}; - -use serde::Deserialize; - -#[derive(Debug, Deserialize)] -struct TargetMatrix { - schema_version: u32, - msrv_toolchain: String, - current_toolchain: String, - operating_system: Vec<OperatingSystem>, -} - -#[derive(Debug, Deserialize)] -struct OperatingSystem { - name: String, - target: String, - cross_compiler: Option<String>, - cross_cflags: Option<String>, -} - -#[derive(Debug, Deserialize)] -struct Architecture { - repositories: Repositories, -} - -#[derive(Debug, Deserialize)] -struct Repositories { - sdk: Repository, -} - -#[derive(Debug, Deserialize)] -struct Repository { - packages: Vec<String>, -} - -pub fn run(workspace_root: &Path) -> Result<(), String> { - let (matrix, packages) = load(workspace_root)?; - for toolchain in [&matrix.msrv_toolchain, &matrix.current_toolchain] { - run_command( - workspace_root, - "rustup", - &[ - "run", - toolchain, - "cargo", - "check", - "--workspace", - "--all-targets", - "--locked", - ], - )?; - } - for operating_system in matrix.operating_system { - for package in &packages { - run_target_command( - workspace_root, - &operating_system, - &[ - "check", - "-p", - package, - "--lib", - "--target", - &operating_system.target, - "--locked", - ], - )?; - } - } - Ok(()) -} - -fn load(workspace_root: &Path) -> Result<(TargetMatrix, Vec<String>), String> { - let matrix_path = workspace_root.join("contracts/releases/target_matrix.toml"); - let matrix = toml::from_str::<TargetMatrix>(&read(&matrix_path)?) - .map_err(|error| format!("failed to parse {}: {error}", matrix_path.display()))?; - validate(&matrix)?; - - let architecture_path = workspace_root.join("docs/specs/radroots_crates_release_v1.toml"); - let architecture = toml::from_str::<Architecture>(&read(&architecture_path)?) - .map_err(|error| format!("failed to parse {}: {error}", architecture_path.display()))?; - let mut packages = architecture.repositories.sdk.packages; - packages.sort(); - if packages != ["radroots", "radroots_sdk"] { - return Err(format!( - "target qualification requires exactly the two SDK front doors, found {packages:?}" - )); - } - Ok((matrix, packages)) -} - -fn read(path: &Path) -> Result<String, String> { - fs::read_to_string(path).map_err(|error| format!("failed to read {}: {error}", path.display())) -} - -fn validate(matrix: &TargetMatrix) -> Result<(), String> { - if matrix.schema_version != 1 { - return Err(format!( - "target matrix schema_version must be 1, found {}", - matrix.schema_version - )); - } - if matrix.msrv_toolchain != "1.97.1" || matrix.current_toolchain != "stable" { - return Err("target matrix must retain MSRV 1.97.1 and current stable".to_owned()); - } - let expected = [ - ( - "linux", - "x86_64-unknown-linux-gnu", - Some("zig cc"), - Some("-target x86_64-linux-gnu"), - ), - ("macos", "aarch64-apple-darwin", None, None), - ( - "windows", - "x86_64-pc-windows-gnu", - Some("x86_64-w64-mingw32-gcc"), - None, - ), - ]; - if matrix.operating_system.len() != expected.len() - || expected.iter().any(|(name, target, compiler, cflags)| { - !matrix.operating_system.iter().any(|entry| { - entry.name == *name - && entry.target == *target - && entry.cross_compiler.as_deref() == *compiler - && entry.cross_cflags.as_deref() == *cflags - }) - }) - { - return Err( - "target matrix must contain the exact Linux, macOS, and Windows triples".to_owned(), - ); - } - Ok(()) -} - -fn run_target_command( - workspace_root: &Path, - operating_system: &OperatingSystem, - args: &[&str], -) -> Result<(), String> { - eprintln!("cargo {}", args.join(" ")); - let mut command = Command::new("cargo"); - command.args(args).current_dir(workspace_root); - let target_key = operating_system.target.replace('-', "_"); - if let Some(compiler) = operating_system.cross_compiler.as_deref() { - command.env(format!("CC_{target_key}"), compiler); - } - if let Some(cflags) = operating_system.cross_cflags.as_deref() { - command.env(format!("CFLAGS_{target_key}"), cflags); - } - let status = command - .status() - .map_err(|error| format!("failed to start cargo: {error}"))?; - if status.success() { - Ok(()) - } else { - Err(format!( - "target qualification failed: cargo {}", - args.join(" ") - )) - } -} - -fn run_command(workspace_root: &Path, program: &str, args: &[&str]) -> Result<(), String> { - eprintln!("{program} {}", args.join(" ")); - let status = Command::new(program) - .args(args) - .current_dir(workspace_root) - .status() - .map_err(|error| format!("failed to start {program}: {error}"))?; - if status.success() { - Ok(()) - } else { - Err(format!( - "target qualification failed: {program} {}", - args.join(" ") - )) - } -} - -#[cfg(test)] -mod tests { - use super::load; - - #[test] - fn current_contract_selects_exact_toolchains_targets_and_packages() { - let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) - .parent() - .and_then(std::path::Path::parent) - .expect("workspace root"); - let (matrix, packages) = load(root).expect("target matrix"); - assert_eq!(matrix.msrv_toolchain, "1.97.1"); - assert_eq!(matrix.current_toolchain, "stable"); - assert_eq!(matrix.operating_system.len(), 3); - assert_eq!(packages, ["radroots", "radroots_sdk"]); - } -} diff --git a/tools/sdk_xtask_import/src/wasm.rs b/tools/sdk_xtask_import/src/wasm.rs @@ -1,1147 +0,0 @@ -use std::{ - collections::{BTreeMap, BTreeSet}, - env, - ffi::OsStr, - fs, io, - path::{Path, PathBuf}, - process::{Command, Stdio}, -}; - -use serde::{Deserialize, Serialize}; -use sha2::{Digest, Sha256}; - -use crate::{ - check::check_wasm_package_surface, - fs::workspace_root, - package_matrix::{WasmPackageSpec, validate_package_matrix, wasm_package_specs}, - wasm_declarations::write_declaration_files, -}; - -pub(crate) const WASM_TARGET: &str = "wasm32-unknown-unknown"; -const WASM_C_COMPILER_ENV: &str = "CC_wasm32_unknown_unknown"; -const WASM_CFLAGS_ENV: &str = "CFLAGS_wasm32_unknown_unknown"; -const WASM_C_TARGET_ARG: &str = "--target=wasm32-unknown-unknown"; - -pub fn generate(args: &[String]) -> Result<(), String> { - validate_package_matrix()?; - let specs = selected_specs(args)?; - let root = workspace_root()?; - let toolchain = resolve_wasm_toolchain(&root)?; - println!( - "using Rust toolchain {} for {}: rustc={}, cargo={}", - toolchain.channel(), - WASM_TARGET, - toolchain.rustc().display(), - toolchain.cargo().display() - ); - let wasm_c_compiler = if specs - .iter() - .any(|spec| wasm_package_requires_c_compiler(*spec)) - { - let compiler = resolve_wasm_c_compiler()?; - println!( - "using WASM C compiler for {}: {}", - WASM_TARGET, - compiler.path.display() - ); - Some(compiler) - } else { - None - }; - for spec in specs { - let dist_dir = root.join(spec.package_dir).join("dist"); - if dist_dir.exists() { - fs::remove_dir_all(&dist_dir) - .map_err(|error| format!("failed to remove {}: {error}", dist_dir.display()))?; - } - let mut command = Command::new(&toolchain.wasm_pack); - command.current_dir(&root); - for arg in wasm_pack_args(spec) { - command.arg(arg); - } - toolchain.apply_to_command(&mut command); - if let Some(compiler) = wasm_c_compiler.as_ref() - && wasm_package_requires_c_compiler(spec) - { - compiler.apply_to_command(&mut command); - } - let status = command.status().map_err(|error| { - format!( - "failed to start wasm-pack for {} while generating {}: {error}", - spec.key, spec.package_name - ) - })?; - if !status.success() { - return Err(format!( - "wasm-pack failed for {} while generating {} with status {status}; rerun `cargo xtask generate wasm --package {}` after fixing the wasm toolchain", - spec.key, spec.package_name, spec.key - )); - } - remove_wasm_pack_gitignore(&dist_dir, spec)?; - write_declaration_files(&root, spec)?; - write_provenance_manifest(&root, spec)?; - check_wasm_package_surface(&root, spec)?; - println!("generated wasm package {}", spec.package_name); - } - Ok(()) -} - -#[derive(Debug, Deserialize, Eq, PartialEq, Serialize)] -struct WasmProvenanceManifest { - schema_version: u16, - package: String, - package_version: String, - rust_crate: String, - rust_crate_version: String, - target: String, - deterministic_codec: bool, - networking: bool, - signing: bool, - source_sha256: String, - outputs: BTreeMap<String, String>, -} - -fn write_provenance_manifest(root: &Path, spec: WasmPackageSpec) -> Result<(), String> { - let manifest = provenance_manifest(root, spec)?; - let dist_dir = root.join(spec.package_dir).join("dist"); - let path = provenance_manifest_path(&dist_dir, spec); - let mut rendered = serde_json::to_string_pretty(&manifest) - .map_err(|error| format!("failed to render {}: {error}", path.display()))?; - rendered.push('\n'); - fs::write(&path, rendered) - .map_err(|error| format!("failed to write {}: {error}", path.display())) -} - -fn provenance_manifest( - root: &Path, - spec: WasmPackageSpec, -) -> Result<WasmProvenanceManifest, String> { - let package_version = manifest_version(&root.join(spec.package_dir).join("package.json"))?; - let rust_crate_version = manifest_version(&root.join(spec.crate_dir).join("Cargo.toml"))?; - let source_sha256 = source_tree_sha256(root, spec)?; - let dist_dir = root.join(spec.package_dir).join("dist"); - let mut outputs = BTreeMap::new(); - for relative in wasm_output_files(spec) { - let path = dist_dir.join(&relative); - let bytes = fs::read(&path) - .map_err(|error| format!("failed to read {}: {error}", path.display()))?; - outputs.insert(relative, format!("{:x}", Sha256::digest(bytes))); - } - Ok(WasmProvenanceManifest { - schema_version: 1, - package: spec.package_name.to_owned(), - package_version, - rust_crate: spec.crate_name.to_owned(), - rust_crate_version, - target: WASM_TARGET.to_owned(), - deterministic_codec: spec.key == "event_codec", - networking: false, - signing: false, - source_sha256, - outputs, - }) -} - -pub(crate) fn validate_provenance_manifest( - root: &Path, - spec: WasmPackageSpec, -) -> Result<(), String> { - let path = provenance_manifest_path(&root.join(spec.package_dir).join("dist"), spec); - let raw = fs::read_to_string(&path) - .map_err(|error| format!("failed to read {}: {error}", path.display()))?; - let actual: WasmProvenanceManifest = serde_json::from_str(&raw) - .map_err(|error| format!("failed to parse {}: {error}", path.display()))?; - let expected = provenance_manifest(root, spec)?; - if actual != expected { - return Err(format!( - "stale or invalid WASM provenance manifest: {}", - path.display() - )); - } - Ok(()) -} - -fn manifest_version(path: &Path) -> Result<String, String> { - let raw = fs::read_to_string(path) - .map_err(|error| format!("failed to read {}: {error}", path.display()))?; - if path.extension() == Some(OsStr::new("json")) { - let value: serde_json::Value = serde_json::from_str(&raw) - .map_err(|error| format!("failed to parse {}: {error}", path.display()))?; - return value["version"] - .as_str() - .map(str::to_owned) - .ok_or_else(|| format!("{} must define string version", path.display())); - } - let value: toml::Value = raw - .parse() - .map_err(|error| format!("failed to parse {}: {error}", path.display()))?; - value["package"]["version"] - .as_str() - .map(str::to_owned) - .ok_or_else(|| format!("{} must define explicit package.version", path.display())) -} - -fn source_tree_sha256(root: &Path, spec: WasmPackageSpec) -> Result<String, String> { - let crate_dir = root.join(spec.crate_dir); - let mut files = vec![crate_dir.join("Cargo.toml")]; - collect_source_files(&crate_dir.join("src"), &mut files)?; - files.sort(); - let mut hasher = Sha256::new(); - for path in files { - let relative = path - .strip_prefix(root) - .map_err(|error| format!("failed to relativize {}: {error}", path.display()))?; - hasher.update(relative.to_string_lossy().as_bytes()); - hasher.update([0]); - hasher.update( - fs::read(&path) - .map_err(|error| format!("failed to read {}: {error}", path.display()))?, - ); - hasher.update([0]); - } - Ok(format!("{:x}", hasher.finalize())) -} - -fn collect_source_files(dir: &Path, files: &mut Vec<PathBuf>) -> Result<(), String> { - let mut entries = fs::read_dir(dir) - .map_err(|error| format!("failed to read {}: {error}", dir.display()))? - .collect::<Result<Vec<_>, _>>() - .map_err(|error| format!("failed to enumerate {}: {error}", dir.display()))?; - entries.sort_by_key(std::fs::DirEntry::file_name); - for entry in entries { - let path = entry.path(); - if path.is_dir() { - collect_source_files(&path, files)?; - } else if path.is_file() { - files.push(path); - } - } - Ok(()) -} - -fn wasm_output_files(spec: WasmPackageSpec) -> [String; 4] { - [ - format!("{}.js", spec.out_name), - format!("{}.d.ts", spec.out_name), - format!("{}_bg.wasm", spec.out_name), - format!("{}_bg.wasm.d.ts", spec.out_name), - ] -} - -fn provenance_manifest_path(dist_dir: &Path, spec: WasmPackageSpec) -> PathBuf { - dist_dir.join(format!("{}.provenance.json", spec.out_name)) -} - -struct WasmToolchain { - wasm_pack: PathBuf, - rust: ResolvedRustToolchain, -} - -impl WasmToolchain { - fn apply_to_command(&self, command: &mut Command) { - self.rust.apply_to_command(command); - } - - fn rustc(&self) -> &Path { - &self.rust.rustc - } - - fn cargo(&self) -> &Path { - &self.rust.cargo - } - - fn channel(&self) -> &str { - &self.rust.channel - } -} - -pub(crate) struct ResolvedRustToolchain { - pub(crate) channel: String, - pub(crate) rustc: PathBuf, - pub(crate) cargo: PathBuf, - pub(crate) bin_dir: PathBuf, -} - -impl ResolvedRustToolchain { - pub(crate) fn apply_to_command(&self, command: &mut Command) { - prepend_path(command, &self.bin_dir); - command.env("RUSTC", &self.rustc); - command.env("CARGO", &self.cargo); - command.env("RUSTUP_TOOLCHAIN", &self.channel); - } -} - -fn resolve_wasm_toolchain(root: &Path) -> Result<WasmToolchain, String> { - let wasm_pack = resolve_required_path_tool("wasm-pack")?; - let rust = resolve_rust_toolchain(root)?; - Ok(WasmToolchain { wasm_pack, rust }) -} - -pub(crate) fn resolve_rust_toolchain(root: &Path) -> Result<ResolvedRustToolchain, String> { - let toolchain_path = root.join("rust-toolchain.toml"); - let channel = read_rust_toolchain_channel(&toolchain_path)?; - let rustc = rustup_tool_for_channel("rustc", &channel)?; - let cargo = rustup_tool_for_channel("cargo", &channel)?; - let rustc_bin = rustc.parent().ok_or_else(|| { - format!( - "rustup resolved rustc for toolchain {channel} without a parent path: {}", - rustc.display() - ) - })?; - let cargo_bin = cargo.parent().ok_or_else(|| { - format!( - "rustup resolved cargo for toolchain {channel} without a parent path: {}", - cargo.display() - ) - })?; - if rustc_bin != cargo_bin { - return Err(format!( - "rustup resolved mismatched Rust tool paths for toolchain {channel}: rustc={}, cargo={}", - rustc.display(), - cargo.display() - )); - } - let bin_dir = rustc_bin.to_path_buf(); - ensure_wasm_target_installed(&channel)?; - Ok(ResolvedRustToolchain { - channel, - rustc, - cargo, - bin_dir, - }) -} - -fn wasm_pack_args(spec: WasmPackageSpec) -> Vec<&'static str> { - vec![ - "build", - spec.crate_dir, - "--release", - "--target", - "web", - "--out-dir", - spec.out_dir, - "--out-name", - spec.out_name, - "--no-pack", - ] -} - -fn remove_wasm_pack_gitignore(dist_dir: &Path, spec: WasmPackageSpec) -> Result<(), String> { - let ignore_path = dist_dir.join(".gitignore"); - let contents = match fs::read_to_string(&ignore_path) { - Ok(contents) => contents, - Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(()), - Err(error) => { - return Err(format!( - "failed to read wasm-pack ignore file for {}: {}: {error}", - spec.package_name, - ignore_path.display() - )); - } - }; - if contents.trim() != "*" { - return Err(format!( - "unexpected wasm-pack ignore file for {}: {}; refusing to remove it", - spec.package_name, - ignore_path.display() - )); - } - fs::remove_file(&ignore_path).map_err(|error| { - format!( - "failed to remove wasm-pack ignore file for {}: {}: {error}", - spec.package_name, - ignore_path.display() - ) - }) -} - -fn selected_specs(args: &[String]) -> Result<Vec<WasmPackageSpec>, String> { - match args { - [] => Ok(wasm_package_specs().to_vec()), - [flag, key] if flag == "--package" => wasm_package_specs() - .iter() - .copied() - .find(|spec| spec.key == key) - .map(|spec| vec![spec]) - .ok_or_else(|| format!("unknown wasm package: {key}")), - _ => Err("usage: cargo xtask generate wasm [--package <key>]".to_owned()), - } -} - -fn wasm_package_requires_c_compiler(spec: WasmPackageSpec) -> bool { - spec.key == "event_codec" -} - -fn resolve_required_path_tool(name: &str) -> Result<PathBuf, String> { - let path = env::var_os("PATH").ok_or_else(|| { - format!("missing {name}: PATH is not set; install {name} and expose it on PATH") - })?; - resolve_path_tool_from_path(name, &path) -} - -fn resolve_path_tool_from_path(name: &str, path: &std::ffi::OsStr) -> Result<PathBuf, String> { - let matches = executable_matches(name, path); - match matches.as_slice() { - [] => Err(format!( - "missing {name}: install {name} and rerun `cargo xtask generate wasm`" - )), - [tool] => Ok(tool.clone()), - _ => Err(format!( - "ambiguous {name}: found {}; remove duplicate {name} entries from PATH before running `cargo xtask generate wasm`", - matches - .iter() - .map(|path| path.display().to_string()) - .collect::<Vec<_>>() - .join(", ") - )), - } -} - -fn executable_matches(name: &str, path: &std::ffi::OsStr) -> Vec<PathBuf> { - let mut seen = BTreeSet::new(); - let mut matches = Vec::new(); - for dir in env::split_paths(path) { - let candidate = dir.join(name); - if !is_executable_file(&candidate) { - continue; - } - let key = fs::canonicalize(&candidate).unwrap_or_else(|_| candidate.clone()); - if seen.insert(key) { - matches.push(candidate); - } - } - matches -} - -fn is_executable_file(path: &Path) -> bool { - if !path.is_file() { - return false; - } - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - path.metadata() - .map(|metadata| metadata.permissions().mode() & 0o111 != 0) - .unwrap_or(false) - } - #[cfg(not(unix))] - { - true - } -} - -fn read_rust_toolchain_channel(path: &Path) -> Result<String, String> { - let contents = fs::read_to_string(path) - .map_err(|error| format!("failed to read {}: {error}", path.display()))?; - rust_toolchain_channel_from(&contents) -} - -fn rust_toolchain_channel_from(contents: &str) -> Result<String, String> { - let value = contents - .parse::<toml::Value>() - .map_err(|error| format!("failed to parse rust-toolchain.toml: {error}"))?; - let channel = value - .get("toolchain") - .and_then(|toolchain| toolchain.get("channel")) - .and_then(toml::Value::as_str) - .ok_or_else(|| "rust-toolchain.toml must define toolchain.channel".to_owned())? - .trim(); - if channel.is_empty() { - return Err("rust-toolchain.toml toolchain.channel must not be empty".to_owned()); - } - Ok(channel.to_owned()) -} - -fn rustup_tool_for_channel(name: &str, channel: &str) -> Result<PathBuf, String> { - let output = Command::new("rustup") - .arg("which") - .arg("--toolchain") - .arg(channel) - .arg(name) - .output() - .map_err(|error| { - format!( - "failed to resolve {name} for Rust toolchain {channel} with rustup: {error}; install rustup toolchain {channel}" - ) - })?; - if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); - return Err(format!( - "failed to resolve {name} for Rust toolchain {channel}: {}; run `rustup toolchain install {channel}`", - stderr.trim() - )); - } - let path = String::from_utf8(output.stdout) - .map_err(|error| format!("rustup emitted non-UTF-8 {name} path: {error}"))?; - let trimmed = path.trim(); - if trimmed.is_empty() { - return Err(format!( - "rustup returned an empty {name} path for Rust toolchain {channel}" - )); - } - Ok(PathBuf::from(trimmed)) -} - -fn ensure_wasm_target_installed(channel: &str) -> Result<(), String> { - let output = Command::new("rustup") - .args(rustup_target_list_args(channel)) - .output() - .map_err(|error| { - format!( - "failed to verify {WASM_TARGET} target for Rust toolchain {channel} with rustup: {error}; install rustup toolchain {channel}" - ) - })?; - if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); - return Err(format!( - "failed to verify {WASM_TARGET} target for Rust toolchain {channel}: {}; run `rustup target add {WASM_TARGET} --toolchain {channel}`", - stderr.trim() - )); - } - let stdout = String::from_utf8_lossy(&output.stdout); - validate_target_list(&stdout, WASM_TARGET, channel) -} - -fn rustup_target_list_args(channel: &str) -> [&str; 5] { - ["target", "list", "--installed", "--toolchain", channel] -} - -fn validate_target_list(output: &str, target: &str, channel: &str) -> Result<(), String> { - if target_list_contains(output, target) { - Ok(()) - } else { - Err(format!( - "missing Rust target {target} for Rust toolchain {channel}: run `rustup target add {target} --toolchain {channel}`" - )) - } -} - -fn target_list_contains(output: &str, target: &str) -> bool { - output.lines().any(|line| line.trim() == target) -} - -#[derive(Debug)] -struct WasmCCompiler { - path: PathBuf, - cflags: ResolvedWasmCFlags, -} - -impl WasmCCompiler { - fn apply_to_command(&self, command: &mut Command) { - command.env(WASM_C_COMPILER_ENV, &self.path); - command.env(WASM_CFLAGS_ENV, &self.cflags.value); - } -} - -#[derive(Clone, Debug, Eq, PartialEq)] -struct ResolvedWasmCFlags { - value: String, - args: Vec<String>, -} - -fn resolve_wasm_c_compiler() -> Result<WasmCCompiler, String> { - let path = env::var_os("PATH").unwrap_or_default(); - let explicit = env::var_os(WASM_C_COMPILER_ENV); - let cflags = resolve_wasm_cflags(env::var_os(WASM_CFLAGS_ENV).as_deref())?; - let candidates = wasm_c_compiler_candidates(&path); - resolve_wasm_c_compiler_with( - explicit.as_deref(), - &path, - candidates, - cflags, - verify_wasm_c_compiler, - ) -} - -fn resolve_wasm_c_compiler_with<F>( - explicit: Option<&OsStr>, - path: &OsStr, - candidates: Vec<PathBuf>, - cflags: ResolvedWasmCFlags, - verify: F, -) -> Result<WasmCCompiler, String> -where - F: Fn(&Path, &ResolvedWasmCFlags) -> Result<(), String>, -{ - if let Some(explicit) = explicit { - let compiler = resolve_explicit_wasm_c_compiler(explicit, path)?; - verify(&compiler, &cflags).map_err(|error| { - format!( - "{WASM_C_COMPILER_ENV} does not name a WASM-capable C compiler: {}; {error}", - compiler.display() - ) - })?; - return Ok(WasmCCompiler { - path: compiler, - cflags, - }); - } - - let mut checked = Vec::new(); - for candidate in candidates { - if !is_executable_file(&candidate) { - continue; - } - checked.push(candidate.display().to_string()); - if verify(&candidate, &cflags).is_ok() { - return Ok(WasmCCompiler { - path: candidate, - cflags, - }); - } - } - let checked = if checked.is_empty() { - "none".to_owned() - } else { - checked.join(", ") - }; - Err(format!( - "missing suitable WASM C compiler for {WASM_TARGET}: set {WASM_C_COMPILER_ENV} to a clang-style compiler that accepts `{WASM_C_TARGET_ARG}`; checked candidates: {checked}" - )) -} - -fn resolve_wasm_cflags(value: Option<&OsStr>) -> Result<ResolvedWasmCFlags, String> { - let raw = match value { - Some(value) => Some( - value - .to_str() - .ok_or_else(|| format!("{WASM_CFLAGS_ENV} must be valid UTF-8 compiler flags"))?, - ), - None => None, - }; - resolve_wasm_cflags_from_str(raw) -} - -fn resolve_wasm_cflags_from_str(value: Option<&str>) -> Result<ResolvedWasmCFlags, String> { - let raw = value.unwrap_or(""); - let words = parse_cflags_words(raw)?; - let mut has_required_target = false; - for index in 0..words.len() { - let word = &words[index]; - if word == WASM_C_TARGET_ARG { - has_required_target = true; - } else if wasm_cflags_target_conflict(index, &words) { - return Err(format!( - "{WASM_CFLAGS_ENV} contains unsupported target flag `{}`; use `{WASM_C_TARGET_ARG}` or omit the target so xtask can prepend it", - wasm_cflags_target_display(index, &words) - )); - } - } - let value = if has_required_target { - raw.to_owned() - } else { - let trimmed = raw.trim(); - if trimmed.is_empty() { - WASM_C_TARGET_ARG.to_owned() - } else { - format!("{WASM_C_TARGET_ARG} {trimmed}") - } - }; - let args = parse_cflags_words(&value)?; - Ok(ResolvedWasmCFlags { value, args }) -} - -fn wasm_cflags_target_conflict(index: usize, words: &[String]) -> bool { - let word = &words[index]; - word == "--target" - || word == "-target" - || word.starts_with("--target=") - || word.starts_with("-target=") -} - -fn wasm_cflags_target_display(index: usize, words: &[String]) -> String { - let word = &words[index]; - if matches!(word.as_str(), "--target" | "-target") - && let Some(target) = words.get(index + 1) - { - return format!("{word} {target}"); - } - word.clone() -} - -fn parse_cflags_words(value: &str) -> Result<Vec<String>, String> { - let mut words = Vec::new(); - let mut current = String::new(); - let mut quote = CFlagsQuote::None; - let mut in_word = false; - let mut chars = value.chars(); - while let Some(character) = chars.next() { - match quote { - CFlagsQuote::None => match character { - character if character.is_whitespace() => { - if in_word { - words.push(std::mem::take(&mut current)); - in_word = false; - } - } - '\'' => { - in_word = true; - quote = CFlagsQuote::Single; - } - '"' => { - in_word = true; - quote = CFlagsQuote::Double; - } - '\\' => { - in_word = true; - let Some(escaped) = chars.next() else { - return Err(format!("{WASM_CFLAGS_ENV} ends with an unfinished escape")); - }; - current.push(escaped); - } - character => { - in_word = true; - current.push(character); - } - }, - CFlagsQuote::Single => { - if character == '\'' { - quote = CFlagsQuote::None; - } else { - current.push(character); - } - } - CFlagsQuote::Double => { - if character == '"' { - quote = CFlagsQuote::None; - } else if character == '\\' { - let Some(escaped) = chars.next() else { - return Err(format!("{WASM_CFLAGS_ENV} ends with an unfinished escape")); - }; - current.push(escaped); - } else { - current.push(character); - } - } - } - } - match quote { - CFlagsQuote::None => { - if in_word { - words.push(current); - } - Ok(words) - } - CFlagsQuote::Single => Err(format!( - "{WASM_CFLAGS_ENV} contains an unterminated ' quote" - )), - CFlagsQuote::Double => Err(format!( - "{WASM_CFLAGS_ENV} contains an unterminated \" quote" - )), - } -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -enum CFlagsQuote { - None, - Single, - Double, -} - -fn resolve_explicit_wasm_c_compiler(value: &OsStr, path: &OsStr) -> Result<PathBuf, String> { - if value.is_empty() { - return Err(format!( - "{WASM_C_COMPILER_ENV} is set but empty; set it to a compiler path that accepts `{WASM_C_TARGET_ARG}`" - )); - } - let candidate = PathBuf::from(value); - if candidate.components().count() > 1 { - if is_executable_file(&candidate) { - Ok(candidate) - } else { - Err(format!( - "{WASM_C_COMPILER_ENV} is not executable: {}", - candidate.display() - )) - } - } else { - first_executable_match(value, path).ok_or_else(|| { - format!( - "{WASM_C_COMPILER_ENV} command was not found on PATH: {}", - value.to_string_lossy() - ) - }) - } -} - -fn first_executable_match(name: &OsStr, path: &OsStr) -> Option<PathBuf> { - for dir in env::split_paths(path) { - let candidate = dir.join(name); - if is_executable_file(&candidate) { - return Some(candidate); - } - } - None -} - -fn wasm_c_compiler_candidates(path: &OsStr) -> Vec<PathBuf> { - let mut seen = BTreeSet::new(); - let mut candidates = Vec::new(); - for name in ["wasm32-unknown-unknown-clang", "clang"] { - for candidate in executable_matches(name, path) { - let key = fs::canonicalize(&candidate).unwrap_or_else(|_| candidate.clone()); - if seen.insert(key) { - candidates.push(candidate); - } - } - } - candidates -} - -fn verify_wasm_c_compiler(path: &Path, cflags: &ResolvedWasmCFlags) -> Result<(), String> { - let tempdir = tempfile::tempdir() - .map_err(|error| format!("failed to create C probe tempdir: {error}"))?; - let source_path = tempdir.path().join("wasm_probe.c"); - let object_path = tempdir.path().join("wasm_probe.o"); - fs::write( - &source_path, - "int radroots_wasm_probe(void) { return 0; }\n", - ) - .map_err(|error| format!("failed to write C compiler probe: {error}"))?; - let output = Command::new(path) - .args(&cflags.args) - .arg("-c") - .arg(&source_path) - .arg("-o") - .arg(&object_path) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .output() - .map_err(|error| format!("failed to run {}: {error}", path.display()))?; - if output.status.success() && object_path.is_file() { - return Ok(()); - } - let stderr = String::from_utf8_lossy(&output.stderr); - Err(format!( - "{} rejected `{WASM_CFLAGS_ENV}={}` for {}: {}", - path.display(), - cflags.value, - WASM_TARGET, - stderr.trim() - )) -} - -fn prepend_path(command: &mut Command, prefix: &Path) { - let existing = env::var_os("PATH").unwrap_or_default(); - let mut paths = vec![prefix.to_path_buf()]; - paths.extend(env::split_paths(&existing)); - if let Ok(joined) = env::join_paths(paths) { - command.env("PATH", joined); - } -} - -#[cfg(test)] -mod tests { - use std::{ - env, - ffi::OsStr, - fs, - path::{Path, PathBuf}, - process::Command, - time::{SystemTime, UNIX_EPOCH}, - }; - - use crate::package_matrix::wasm_package_specs; - - use super::{ - ResolvedWasmCFlags, WASM_C_COMPILER_ENV, WASM_C_TARGET_ARG, WASM_CFLAGS_ENV, WASM_TARGET, - WasmCCompiler, remove_wasm_pack_gitignore, resolve_path_tool_from_path, - resolve_wasm_c_compiler_with, resolve_wasm_cflags_from_str, rust_toolchain_channel_from, - rustup_target_list_args, selected_specs, target_list_contains, validate_target_list, - wasm_pack_args, wasm_package_requires_c_compiler, - }; - - #[test] - fn selects_all_specs_by_default() { - assert_eq!(selected_specs(&[]).expect("all specs").len(), 3); - } - - #[test] - fn selects_one_spec_by_key() { - let specs = selected_specs(&["--package".to_owned(), "replica_store".to_owned()]) - .expect("replica store spec"); - assert_eq!(specs[0].package_name, "@radroots/replica-store-wasm"); - } - - #[test] - fn rejects_unknown_spec_key() { - assert!(selected_specs(&["--package".to_owned(), "missing".to_owned()]).is_err()); - } - - #[test] - fn wasm_pack_arguments_disable_package_manifest_generation() { - let args = wasm_pack_args(wasm_package_specs()[0]); - assert!(args.contains(&"--no-pack")); - } - - #[test] - fn removes_wasm_pack_generated_gitignore() { - let root = test_root("wasm_pack_gitignore"); - let dist_dir = root.join("dist"); - fs::create_dir_all(&dist_dir).expect("create dist"); - fs::write(dist_dir.join(".gitignore"), "*\n").expect("write ignore"); - - remove_wasm_pack_gitignore(&dist_dir, wasm_package_specs()[0]).expect("remove ignore"); - - assert!(!dist_dir.join(".gitignore").exists()); - let _ = fs::remove_dir_all(root); - } - - #[test] - fn refuses_unexpected_wasm_pack_gitignore_contents() { - let root = test_root("custom_gitignore"); - let dist_dir = root.join("dist"); - fs::create_dir_all(&dist_dir).expect("create dist"); - fs::write(dist_dir.join(".gitignore"), "!keep\n").expect("write ignore"); - - let error = remove_wasm_pack_gitignore(&dist_dir, wasm_package_specs()[0]) - .expect_err("custom ignore rejected"); - - assert!(error.contains("unexpected wasm-pack ignore file")); - let _ = fs::remove_dir_all(root); - } - - #[test] - fn path_tool_resolution_reports_missing_tools() { - let error = resolve_path_tool_from_path("wasm-pack", std::ffi::OsStr::new("")) - .expect_err("missing"); - assert!(error.contains("missing wasm-pack")); - } - - #[test] - fn path_tool_resolution_reports_ambiguous_tools() { - let root = test_root("ambiguous_wasm_pack"); - let first = root.join("first"); - let second = root.join("second"); - fs::create_dir_all(&first).expect("create first dir"); - fs::create_dir_all(&second).expect("create second dir"); - write_executable(first.join("wasm-pack")); - write_executable(second.join("wasm-pack")); - let path = env::join_paths([first, second]).expect("join path"); - - let error = - resolve_path_tool_from_path("wasm-pack", &path).expect_err("ambiguous wasm-pack"); - - assert!(error.contains("ambiguous wasm-pack")); - let _ = fs::remove_dir_all(root); - } - - #[test] - fn target_list_parser_requires_exact_target() { - assert!(target_list_contains( - "aarch64-apple-darwin\nwasm32-unknown-unknown\n", - "wasm32-unknown-unknown" - )); - assert!(!target_list_contains( - "wasm32-unknown-emscripten\n", - "wasm32-unknown-unknown" - )); - } - - #[test] - fn rustup_target_list_args_are_bound_to_selected_toolchain() { - assert_eq!( - rustup_target_list_args("1.97.0"), - ["target", "list", "--installed", "--toolchain", "1.97.0"] - ); - } - - #[test] - fn missing_wasm_target_error_names_selected_toolchain() { - let error = validate_target_list("aarch64-apple-darwin\n", WASM_TARGET, "1.97.0") - .expect_err("missing target"); - - assert!(error.contains("wasm32-unknown-unknown")); - assert!(error.contains("--toolchain 1.97.0")); - } - - #[test] - fn parses_rust_toolchain_channel() { - let channel = rust_toolchain_channel_from( - r#"[toolchain] -channel = "1.97.0" -"#, - ) - .expect("channel"); - - assert_eq!(channel, "1.97.0"); - } - - #[test] - fn rejects_missing_wasm_c_compiler() { - let error = resolve_wasm_c_compiler_with( - None, - OsStr::new(""), - Vec::new(), - wasm_cflags(None), - |_, _| Ok(()), - ) - .expect_err("missing compiler"); - - assert!(error.contains(WASM_C_COMPILER_ENV)); - assert!(error.contains(WASM_C_TARGET_ARG)); - } - - #[test] - fn rejects_unsuitable_wasm_c_compiler_candidates() { - let root = test_root("unsuitable_wasm_c_compiler"); - fs::create_dir_all(&root).expect("create root"); - let compiler = root.join("clang"); - write_executable(compiler.clone()); - - let error = resolve_wasm_c_compiler_with( - None, - OsStr::new(""), - vec![compiler], - wasm_cflags(None), - |_, _| Err("target unsupported".to_owned()), - ) - .expect_err("unsuitable compiler"); - - assert!(error.contains("missing suitable WASM C compiler")); - assert!(error.contains(WASM_C_COMPILER_ENV)); - let _ = fs::remove_dir_all(root); - } - - #[test] - fn accepts_verified_wasm_c_compiler_candidate() { - let root = test_root("verified_wasm_c_compiler"); - fs::create_dir_all(&root).expect("create root"); - let compiler = root.join("clang"); - write_executable(compiler.clone()); - - let resolved = resolve_wasm_c_compiler_with( - None, - OsStr::new(""), - vec![compiler.clone()], - wasm_cflags(Some("-O2")), - |path: &Path, cflags: &ResolvedWasmCFlags| { - assert_eq!(path, compiler); - assert_eq!(cflags.value, format!("{WASM_C_TARGET_ARG} -O2")); - Ok(()) - }, - ) - .expect("compiler"); - - assert_eq!(resolved.path, compiler); - assert_eq!(resolved.cflags.value, format!("{WASM_C_TARGET_ARG} -O2")); - let _ = fs::remove_dir_all(root); - } - - #[test] - fn absent_wasm_cflags_resolve_to_required_target() { - let resolved = resolve_wasm_cflags_from_str(None).expect("cflags"); - - assert_eq!(resolved.value, WASM_C_TARGET_ARG); - assert_eq!(resolved.args, [WASM_C_TARGET_ARG.to_owned()]); - } - - #[test] - fn present_valid_wasm_cflags_preserve_additional_flags() { - let value = "-O2 --target=wasm32-unknown-unknown -fvisibility=hidden"; - let resolved = resolve_wasm_cflags_from_str(Some(value)).expect("cflags"); - - assert_eq!(resolved.value, value); - assert_eq!( - resolved.args, - [ - "-O2".to_owned(), - WASM_C_TARGET_ARG.to_owned(), - "-fvisibility=hidden".to_owned() - ] - ); - } - - #[test] - fn missing_target_wasm_cflags_prepend_required_target() { - let resolved = resolve_wasm_cflags_from_str(Some(" -O2 -fPIC ")).expect("target prepended"); - - assert_eq!(resolved.value, format!("{WASM_C_TARGET_ARG} -O2 -fPIC")); - assert_eq!( - resolved.args, - [ - WASM_C_TARGET_ARG.to_owned(), - "-O2".to_owned(), - "-fPIC".to_owned() - ] - ); - } - - #[test] - fn conflicting_target_wasm_cflags_are_rejected() { - let error = resolve_wasm_cflags_from_str(Some("--target=wasm32-wasip1 -O2")) - .expect_err("conflicting target"); - - assert!(error.contains(WASM_CFLAGS_ENV)); - assert!(error.contains("--target=wasm32-wasip1")); - assert!(error.contains(WASM_C_TARGET_ARG)); - } - - #[test] - fn separated_target_wasm_cflags_are_rejected() { - let error = resolve_wasm_cflags_from_str(Some("--target wasm32-unknown-unknown")) - .expect_err("separated target"); - - assert!(error.contains("--target wasm32-unknown-unknown")); - assert!(error.contains(WASM_C_TARGET_ARG)); - } - - #[test] - fn wasm_c_compiler_command_uses_resolved_cflags() { - let compiler = WasmCCompiler { - path: PathBuf::from("clang"), - cflags: wasm_cflags(Some("-O2")), - }; - let mut command = Command::new("wasm-pack"); - - compiler.apply_to_command(&mut command); - - assert_eq!( - command_env(&command, WASM_C_COMPILER_ENV), - Some(std::ffi::OsString::from("clang")) - ); - assert_eq!( - command_env(&command, WASM_CFLAGS_ENV), - Some(std::ffi::OsString::from(format!("{WASM_C_TARGET_ARG} -O2"))) - ); - } - - #[test] - fn event_codec_wasm_requires_c_compiler() { - assert!(wasm_package_requires_c_compiler(wasm_package_specs()[0])); - assert!(!wasm_package_requires_c_compiler(wasm_package_specs()[1])); - } - - fn wasm_cflags(value: Option<&str>) -> ResolvedWasmCFlags { - resolve_wasm_cflags_from_str(value).expect("resolved cflags") - } - - fn command_env(command: &Command, name: &str) -> Option<std::ffi::OsString> { - command.get_envs().find_map(|(key, value)| { - if key == OsStr::new(name) { - value.map(std::ffi::OsStr::to_os_string) - } else { - None - } - }) - } - - fn write_executable(path: PathBuf) { - fs::write(&path, "#!/bin/sh\n").expect("write executable"); - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - let mut permissions = fs::metadata(&path).expect("metadata").permissions(); - permissions.set_mode(0o755); - fs::set_permissions(&path, permissions).expect("set executable permissions"); - } - } - - fn test_root(name: &str) -> PathBuf { - let stamp = SystemTime::now() - .duration_since(UNIX_EPOCH) - .expect("system time") - .as_nanos(); - env::temp_dir().join(format!("radroots_sdk_xtask_{name}_{stamp}")) - } -} diff --git a/tools/sdk_xtask_import/src/wasm_declarations.rs b/tools/sdk_xtask_import/src/wasm_declarations.rs @@ -1,1147 +0,0 @@ -use std::path::Path; - -use dto_bindgen_backend_ts::{ - TypeScriptDeclaration, TypeScriptModule, TypeScriptParameter, TypeScriptProperty, - TypeScriptType, TypeScriptValue, -}; - -use crate::package_matrix::WasmPackageSpec; - -const WASM_DECLARATION_HEADER: &str = - "// @generated by cargo xtask generate wasm via dto_bindgen\n// Do not edit by hand.\n"; - -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct WasmDeclarationFile { - pub relative_path: String, - pub contents: String, -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -struct WasmDeclarationInventory { - public_functions: &'static [WasmPublicFunction], - support_exports: &'static [WasmSupportExport], -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -struct WasmPublicFunction { - name: &'static str, - parameter: Option<&'static str>, - return_kind: WasmReturnKind, - low_level: LowLevelSignature, -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -enum WasmReturnKind { - String, - Any, - Void, -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -struct LowLevelSignature { - parameters: usize, - returns: usize, -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -struct WasmSupportExport { - name: &'static str, - export_type: WasmSupportExportType, -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -enum WasmSupportExportType { - Memory, - Table, - Function(LowLevelSignature), - NumberFunction(usize), -} - -macro_rules! public_function { - ($name:literal, $parameter:expr, $return_kind:ident, $low_parameters:literal, $low_returns:literal) => { - WasmPublicFunction { - name: $name, - parameter: $parameter, - return_kind: WasmReturnKind::$return_kind, - low_level: LowLevelSignature { - parameters: $low_parameters, - returns: $low_returns, - }, - } - }; -} - -macro_rules! memory_export { - () => { - WasmSupportExport { - name: "memory", - export_type: WasmSupportExportType::Memory, - } - }; -} - -macro_rules! table_export { - ($name:literal) => { - WasmSupportExport { - name: $name, - export_type: WasmSupportExportType::Table, - } - }; -} - -macro_rules! void_function_export { - ($name:literal, $parameters:literal) => { - WasmSupportExport { - name: $name, - export_type: WasmSupportExportType::Function(LowLevelSignature { - parameters: $parameters, - returns: 0, - }), - } - }; -} - -macro_rules! number_function_export { - ($name:literal, $parameters:literal) => { - WasmSupportExport { - name: $name, - export_type: WasmSupportExportType::NumberFunction($parameters), - } - }; -} - -pub fn write_declaration_files(root: &Path, spec: WasmPackageSpec) -> Result<(), String> { - let package_dir = root.join(spec.package_dir); - for file in declaration_files(spec)? { - crate::fs::write_if_changed(&package_dir.join(&file.relative_path), &file.contents)?; - } - Ok(()) -} - -pub fn declaration_files(spec: WasmPackageSpec) -> Result<Vec<WasmDeclarationFile>, String> { - let inventory = inventory(spec)?; - Ok(vec![ - WasmDeclarationFile { - relative_path: format!("dist/{}.d.ts", spec.out_name), - contents: render_declaration_module(primary_module(spec, inventory)), - }, - WasmDeclarationFile { - relative_path: format!("dist/{}_bg.wasm.d.ts", spec.out_name), - contents: render_declaration_module(sidecar_module(spec, inventory)), - }, - ]) -} - -fn inventory(spec: WasmPackageSpec) -> Result<WasmDeclarationInventory, String> { - match spec.key { - "event_codec" => Ok(WasmDeclarationInventory { - public_functions: EVENT_CODEC_FUNCTIONS, - support_exports: EVENT_CODEC_SUPPORT_EXPORTS, - }), - "replica_store" => Ok(WasmDeclarationInventory { - public_functions: REPLICA_STORE_FUNCTIONS, - support_exports: SQL_WASM_SUPPORT_EXPORTS, - }), - "replica_sync" => Ok(WasmDeclarationInventory { - public_functions: REPLICA_SYNC_FUNCTIONS, - support_exports: SQL_WASM_SUPPORT_EXPORTS, - }), - _ => Err(format!( - "missing wasm declaration inventory for {}", - spec.key - )), - } -} - -fn primary_module(spec: WasmPackageSpec, inventory: WasmDeclarationInventory) -> TypeScriptModule { - let mut module = TypeScriptModule::new(format!("dist/{}.d.ts", spec.out_name)); - for function in inventory.public_functions { - module.push_declaration(TypeScriptDeclaration::function( - function.name, - public_parameters(function.parameter), - public_return_type(function.return_kind), - )); - } - module.push_declaration(TypeScriptDeclaration::type_alias( - "InitInput", - init_input_type(), - )); - module.push_declaration(TypeScriptDeclaration::type_alias( - "InitOutput", - init_output_type(inventory), - )); - module.push_declaration(TypeScriptDeclaration::type_alias( - "SyncInitInput", - sync_init_input_type(), - )); - module.push_declaration(TypeScriptDeclaration::function( - "initSync", - vec![TypeScriptParameter::new( - "module", - TypeScriptType::union(vec![ - TypeScriptType::object(vec![TypeScriptProperty::new( - "module", - TypeScriptType::named("SyncInitInput"), - )]), - TypeScriptType::named("SyncInitInput"), - ]), - )], - TypeScriptType::named("InitOutput"), - )); - module.push_declaration(TypeScriptDeclaration::default_function( - "__wbg_init", - vec![ - TypeScriptParameter::new( - "module_or_path", - TypeScriptType::union(vec![ - TypeScriptType::object(vec![TypeScriptProperty::new( - "module_or_path", - TypeScriptType::union(vec![ - TypeScriptType::named("InitInput"), - promise_of(TypeScriptType::named("InitInput")), - ]), - )]), - TypeScriptType::named("InitInput"), - promise_of(TypeScriptType::named("InitInput")), - ]), - ) - .optional(), - ], - promise_of(TypeScriptType::named("InitOutput")), - )); - module -} - -fn sidecar_module(spec: WasmPackageSpec, inventory: WasmDeclarationInventory) -> TypeScriptModule { - let mut module = TypeScriptModule::new(format!("dist/{}_bg.wasm.d.ts", spec.out_name)); - for export in inventory.support_exports { - if matches!(export.export_type, WasmSupportExportType::Memory) { - module.push_declaration(support_export_declaration(*export)); - } - } - for function in inventory.public_functions { - module.push_declaration(TypeScriptDeclaration::constant( - function.name, - Some(low_level_function_type(function.low_level)), - TypeScriptValue::string(""), - )); - } - for export in inventory.support_exports { - if !matches!(export.export_type, WasmSupportExportType::Memory) { - module.push_declaration(support_export_declaration(*export)); - } - } - module -} - -fn render_declaration_module(module: TypeScriptModule) -> String { - let mut rendered = String::from(WASM_DECLARATION_HEADER); - rendered.push_str(&module.render_declaration()); - rendered -} - -fn public_parameters(parameter: Option<&'static str>) -> Vec<TypeScriptParameter> { - parameter - .map(|name| vec![TypeScriptParameter::new(name, TypeScriptType::String)]) - .unwrap_or_default() -} - -fn public_return_type(kind: WasmReturnKind) -> TypeScriptType { - match kind { - WasmReturnKind::String => TypeScriptType::String, - WasmReturnKind::Any => TypeScriptType::Any, - WasmReturnKind::Void => TypeScriptType::Void, - } -} - -fn init_input_type() -> TypeScriptType { - TypeScriptType::union(vec![ - TypeScriptType::named("RequestInfo"), - TypeScriptType::named("URL"), - TypeScriptType::named("Response"), - TypeScriptType::named("BufferSource"), - TypeScriptType::named("WebAssembly.Module"), - ]) -} - -fn sync_init_input_type() -> TypeScriptType { - TypeScriptType::union(vec![ - TypeScriptType::named("BufferSource"), - TypeScriptType::named("WebAssembly.Module"), - ]) -} - -fn init_output_type(inventory: WasmDeclarationInventory) -> TypeScriptType { - let mut properties = vec![TypeScriptProperty::new("memory", memory_type()).readonly()]; - properties.extend(inventory.public_functions.iter().map(|function| { - TypeScriptProperty::new(function.name, low_level_function_type(function.low_level)) - .readonly() - })); - properties.extend( - inventory - .support_exports - .iter() - .filter(|export| !matches!(export.export_type, WasmSupportExportType::Memory)) - .map(|export| { - TypeScriptProperty::new(export.name, support_export_type(*export)).readonly() - }), - ); - TypeScriptType::object(properties) -} - -fn support_export_declaration(export: WasmSupportExport) -> TypeScriptDeclaration { - TypeScriptDeclaration::constant( - export.name, - Some(support_export_type(export)), - TypeScriptValue::string(""), - ) -} - -fn support_export_type(export: WasmSupportExport) -> TypeScriptType { - match export.export_type { - WasmSupportExportType::Memory => memory_type(), - WasmSupportExportType::Table => TypeScriptType::named("WebAssembly.Table"), - WasmSupportExportType::Function(signature) => low_level_function_type(signature), - WasmSupportExportType::NumberFunction(parameters) => { - low_level_number_function_type(parameters) - } - } -} - -fn memory_type() -> TypeScriptType { - TypeScriptType::named("WebAssembly.Memory") -} - -fn low_level_number_function_type(parameters: usize) -> TypeScriptType { - TypeScriptType::function(number_parameters(parameters), TypeScriptType::Number) -} - -fn low_level_function_type(signature: LowLevelSignature) -> TypeScriptType { - TypeScriptType::function( - number_parameters(signature.parameters), - low_level_return_type(signature.returns), - ) -} - -fn number_parameters(count: usize) -> Vec<TypeScriptParameter> { - (0..count) - .map(|index| { - TypeScriptParameter::new(low_level_parameter_name(index), TypeScriptType::Number) - }) - .collect() -} - -fn low_level_parameter_name(index: usize) -> String { - match index { - 0 => "a".to_owned(), - 1 => "b".to_owned(), - 2 => "c".to_owned(), - 3 => "d".to_owned(), - _ => format!("arg{index}"), - } -} - -fn low_level_return_type(count: usize) -> TypeScriptType { - match count { - 0 => TypeScriptType::Void, - 1 => TypeScriptType::Number, - _ => TypeScriptType::tuple(vec![TypeScriptType::Number; count]), - } -} - -fn promise_of(type_expr: TypeScriptType) -> TypeScriptType { - TypeScriptType::generic("Promise", vec![type_expr]) -} - -const EVENT_CODEC_FUNCTIONS: &[WasmPublicFunction] = &[ - public_function!("article_tags", Some("article_json"), String, 2, 4), - public_function!("coop_tags", Some("coop_json"), String, 2, 4), - public_function!("document_tags", Some("document_json"), String, 2, 4), - public_function!("farm_crdt_change_tags", Some("input_json"), String, 2, 4), - public_function!("farm_file_metadata_tags", Some("file_json"), String, 2, 4), - public_function!("farm_tags", Some("farm_json"), String, 2, 4), - public_function!( - "farm_workspace_manifest_tags", - Some("workspace_json"), - String, - 2, - 4 - ), - public_function!("file_metadata_tags", Some("metadata_json"), String, 2, 4), - public_function!("follow_tags", Some("follow_json"), String, 2, 4), - public_function!("generic_repost_tags", Some("repost_json"), String, 2, 4), - public_function!("gift_wrap_tags", Some("gift_wrap_json"), String, 2, 4), - public_function!("group_admins_tags", Some("group_json"), String, 2, 4), - public_function!("group_create_group_tags", Some("group_json"), String, 2, 4), - public_function!("group_create_invite_tags", Some("group_json"), String, 2, 4), - public_function!("group_delete_event_tags", Some("group_json"), String, 2, 4), - public_function!("group_delete_group_tags", Some("group_json"), String, 2, 4), - public_function!("group_edit_metadata_tags", Some("group_json"), String, 2, 4), - public_function!("group_join_request_tags", Some("group_json"), String, 2, 4), - public_function!("group_leave_request_tags", Some("group_json"), String, 2, 4), - public_function!("group_members_tags", Some("group_json"), String, 2, 4), - public_function!("group_metadata_tags", Some("group_json"), String, 2, 4), - public_function!("group_put_user_tags", Some("group_json"), String, 2, 4), - public_function!("group_remove_user_tags", Some("group_json"), String, 2, 4), - public_function!("group_roles_tags", Some("group_json"), String, 2, 4), - public_function!("http_auth_tags", Some("auth_json"), String, 2, 4), - public_function!("job_feedback_tags", Some("job_json"), String, 2, 4), - public_function!("job_request_tags", Some("job_json"), String, 2, 4), - public_function!("job_result_tags", Some("job_json"), String, 2, 4), - public_function!("knowledge_claim_tags", Some("claim_json"), String, 2, 4), - public_function!( - "knowledge_field_report_tags", - Some("report_json"), - String, - 2, - 4 - ), - public_function!( - "knowledge_relation_tags", - Some("relation_json"), - String, - 2, - 4 - ), - public_function!("knowledge_review_tags", Some("review_json"), String, 2, 4), - public_function!("knowledge_source_tags", Some("source_json"), String, 2, 4), - public_function!("list_set_tags", Some("list_json"), String, 2, 4), - public_function!("list_tags", Some("list_json"), String, 2, 4), - public_function!( - "operational_listing_tags", - Some("listing_json"), - String, - 2, - 4 - ), - public_function!( - "operational_listing_tags_full", - Some("listing_json"), - String, - 2, - 4 - ), - public_function!("message_file_tags", Some("message_json"), String, 2, 4), - public_function!("message_tags", Some("message_json"), String, 2, 4), - public_function!("plot_tags", Some("plot_json"), String, 2, 4), - public_function!("reaction_tags", Some("reaction_json"), String, 2, 4), - public_function!("relay_auth_tags", Some("auth_json"), String, 2, 4), - public_function!("report_tags", Some("report_json"), String, 2, 4), - public_function!("repost_tags", Some("repost_json"), String, 2, 4), - public_function!("seal_tags", Some("seal_json"), String, 2, 4), - public_function!( - "social_ask_build_authored_draft", - Some("input_json"), - String, - 2, - 4 - ), - public_function!( - "social_comment_build_authored_draft", - Some("input_json"), - String, - 2, - 4 - ), - public_function!( - "social_photo_update_build_authored_draft", - Some("input_json"), - String, - 2, - 4 - ), - public_function!( - "social_update_build_authored_draft", - Some("input_json"), - String, - 2, - 4 - ), - public_function!("wiki_article_tags", Some("article_json"), String, 2, 4), - public_function!( - "wiki_merge_request_tags", - Some("request_json"), - String, - 2, - 4 - ), - public_function!("wiki_redirect_tags", Some("redirect_json"), String, 2, 4), - public_function!( - "verify_and_decode_event_json", - Some("event_json"), - String, - 2, - 4 - ), - public_function!("contract_manifest_json", None, String, 0, 4), -]; - -const EVENT_CODEC_SUPPORT_EXPORTS: &[WasmSupportExport] = &[ - memory_export!(), - table_export!("__wbindgen_externrefs"), - number_function_export!("__wbindgen_malloc", 2), - number_function_export!("__wbindgen_realloc", 4), - void_function_export!("__externref_table_dealloc", 1), - void_function_export!("__wbindgen_free", 3), - void_function_export!("__wbindgen_start", 0), -]; - -const REPLICA_STORE_FUNCTIONS: &[WasmPublicFunction] = &[ - public_function!("replica_store_export_begin", None, Any, 0, 3), - public_function!("replica_store_export_finish", None, Void, 0, 2), - public_function!("replica_store_export_json", None, Any, 0, 3), - public_function!("replica_store_farm_create", Some("opts_json"), Any, 2, 3), - public_function!("replica_store_farm_delete", Some("opts_json"), Any, 2, 3), - public_function!("replica_store_farm_find_many", Some("opts_json"), Any, 2, 3), - public_function!("replica_store_farm_find_one", Some("opts_json"), Any, 2, 3), - public_function!("replica_store_farm_update", Some("opts_json"), Any, 2, 3), - public_function!( - "replica_store_farm_gcs_location_create", - Some("opts_json"), - Any, - 2, - 3 - ), - public_function!( - "replica_store_farm_gcs_location_delete", - Some("opts_json"), - Any, - 2, - 3 - ), - public_function!( - "replica_store_farm_gcs_location_find_many", - Some("opts_json"), - Any, - 2, - 3 - ), - public_function!( - "replica_store_farm_gcs_location_find_one", - Some("opts_json"), - Any, - 2, - 3 - ), - public_function!( - "replica_store_farm_gcs_location_update", - Some("opts_json"), - Any, - 2, - 3 - ), - public_function!( - "replica_store_farm_member_claim_create", - Some("opts_json"), - Any, - 2, - 3 - ), - public_function!( - "replica_store_farm_member_claim_delete", - Some("opts_json"), - Any, - 2, - 3 - ), - public_function!( - "replica_store_farm_member_claim_find_many", - Some("opts_json"), - Any, - 2, - 3 - ), - public_function!( - "replica_store_farm_member_claim_find_one", - Some("opts_json"), - Any, - 2, - 3 - ), - public_function!( - "replica_store_farm_member_claim_update", - Some("opts_json"), - Any, - 2, - 3 - ), - public_function!( - "replica_store_farm_member_create", - Some("opts_json"), - Any, - 2, - 3 - ), - public_function!( - "replica_store_farm_member_delete", - Some("opts_json"), - Any, - 2, - 3 - ), - public_function!( - "replica_store_farm_member_find_many", - Some("opts_json"), - Any, - 2, - 3 - ), - public_function!( - "replica_store_farm_member_find_one", - Some("opts_json"), - Any, - 2, - 3 - ), - public_function!( - "replica_store_farm_member_update", - Some("opts_json"), - Any, - 2, - 3 - ), - public_function!( - "replica_store_farm_tag_create", - Some("opts_json"), - Any, - 2, - 3 - ), - public_function!( - "replica_store_farm_tag_delete", - Some("opts_json"), - Any, - 2, - 3 - ), - public_function!( - "replica_store_farm_tag_find_many", - Some("opts_json"), - Any, - 2, - 3 - ), - public_function!( - "replica_store_farm_tag_find_one", - Some("opts_json"), - Any, - 2, - 3 - ), - public_function!( - "replica_store_farm_tag_update", - Some("opts_json"), - Any, - 2, - 3 - ), - public_function!( - "replica_store_gcs_location_create", - Some("opts_json"), - Any, - 2, - 3 - ), - public_function!( - "replica_store_gcs_location_delete", - Some("opts_json"), - Any, - 2, - 3 - ), - public_function!( - "replica_store_gcs_location_find_many", - Some("opts_json"), - Any, - 2, - 3 - ), - public_function!( - "replica_store_gcs_location_find_one", - Some("opts_json"), - Any, - 2, - 3 - ), - public_function!( - "replica_store_gcs_location_update", - Some("opts_json"), - Any, - 2, - 3 - ), - public_function!("replica_store_import_json", Some("dump_json"), Void, 2, 2), - public_function!( - "replica_store_log_error_create", - Some("opts_json"), - Any, - 2, - 3 - ), - public_function!( - "replica_store_log_error_delete", - Some("opts_json"), - Any, - 2, - 3 - ), - public_function!( - "replica_store_log_error_find_many", - Some("opts_json"), - Any, - 2, - 3 - ), - public_function!( - "replica_store_log_error_find_one", - Some("opts_json"), - Any, - 2, - 3 - ), - public_function!( - "replica_store_log_error_update", - Some("opts_json"), - Any, - 2, - 3 - ), - public_function!( - "replica_store_media_image_create", - Some("opts_json"), - Any, - 2, - 3 - ), - public_function!( - "replica_store_media_image_delete", - Some("opts_json"), - Any, - 2, - 3 - ), - public_function!( - "replica_store_media_image_find_many", - Some("opts_json"), - Any, - 2, - 3 - ), - public_function!( - "replica_store_media_image_find_one", - Some("opts_json"), - Any, - 2, - 3 - ), - public_function!( - "replica_store_media_image_update", - Some("opts_json"), - Any, - 2, - 3 - ), - public_function!( - "replica_store_nostr_event_head_create", - Some("opts_json"), - Any, - 2, - 3 - ), - public_function!( - "replica_store_nostr_event_head_delete", - Some("opts_json"), - Any, - 2, - 3 - ), - public_function!( - "replica_store_nostr_event_head_find_many", - Some("opts_json"), - Any, - 2, - 3 - ), - public_function!( - "replica_store_nostr_event_head_find_one", - Some("opts_json"), - Any, - 2, - 3 - ), - public_function!( - "replica_store_nostr_event_head_update", - Some("opts_json"), - Any, - 2, - 3 - ), - public_function!( - "replica_store_nostr_profile_create", - Some("opts_json"), - Any, - 2, - 3 - ), - public_function!( - "replica_store_nostr_profile_delete", - Some("opts_json"), - Any, - 2, - 3 - ), - public_function!( - "replica_store_nostr_profile_find_many", - Some("opts_json"), - Any, - 2, - 3 - ), - public_function!( - "replica_store_nostr_profile_find_one", - Some("opts_json"), - Any, - 2, - 3 - ), - public_function!( - "replica_store_nostr_profile_relay_set", - Some("opts_json"), - Any, - 2, - 3 - ), - public_function!( - "replica_store_nostr_profile_relay_unset", - Some("opts_json"), - Any, - 2, - 3 - ), - public_function!( - "replica_store_nostr_profile_update", - Some("opts_json"), - Any, - 2, - 3 - ), - public_function!( - "replica_store_nostr_relay_create", - Some("opts_json"), - Any, - 2, - 3 - ), - public_function!( - "replica_store_nostr_relay_delete", - Some("opts_json"), - Any, - 2, - 3 - ), - public_function!( - "replica_store_nostr_relay_find_many", - Some("opts_json"), - Any, - 2, - 3 - ), - public_function!( - "replica_store_nostr_relay_find_one", - Some("opts_json"), - Any, - 2, - 3 - ), - public_function!( - "replica_store_nostr_relay_update", - Some("opts_json"), - Any, - 2, - 3 - ), - public_function!("replica_store_plot_create", Some("opts_json"), Any, 2, 3), - public_function!("replica_store_plot_delete", Some("opts_json"), Any, 2, 3), - public_function!("replica_store_plot_find_many", Some("opts_json"), Any, 2, 3), - public_function!("replica_store_plot_find_one", Some("opts_json"), Any, 2, 3), - public_function!("replica_store_plot_update", Some("opts_json"), Any, 2, 3), - public_function!( - "replica_store_plot_gcs_location_create", - Some("opts_json"), - Any, - 2, - 3 - ), - public_function!( - "replica_store_plot_gcs_location_delete", - Some("opts_json"), - Any, - 2, - 3 - ), - public_function!( - "replica_store_plot_gcs_location_find_many", - Some("opts_json"), - Any, - 2, - 3 - ), - public_function!( - "replica_store_plot_gcs_location_find_one", - Some("opts_json"), - Any, - 2, - 3 - ), - public_function!( - "replica_store_plot_gcs_location_update", - Some("opts_json"), - Any, - 2, - 3 - ), - public_function!( - "replica_store_plot_tag_create", - Some("opts_json"), - Any, - 2, - 3 - ), - public_function!( - "replica_store_plot_tag_delete", - Some("opts_json"), - Any, - 2, - 3 - ), - public_function!( - "replica_store_plot_tag_find_many", - Some("opts_json"), - Any, - 2, - 3 - ), - public_function!( - "replica_store_plot_tag_find_one", - Some("opts_json"), - Any, - 2, - 3 - ), - public_function!( - "replica_store_plot_tag_update", - Some("opts_json"), - Any, - 2, - 3 - ), - public_function!("replica_store_reset_database", None, Void, 0, 2), - public_function!("replica_store_run_migrations", None, Void, 0, 2), - public_function!( - "replica_store_trade_product_create", - Some("opts_json"), - Any, - 2, - 3 - ), - public_function!( - "replica_store_trade_product_delete", - Some("opts_json"), - Any, - 2, - 3 - ), - public_function!( - "replica_store_trade_product_find_many", - Some("opts_json"), - Any, - 2, - 3 - ), - public_function!( - "replica_store_trade_product_find_one", - Some("opts_json"), - Any, - 2, - 3 - ), - public_function!( - "replica_store_trade_product_location_set", - Some("opts_json"), - Any, - 2, - 3 - ), - public_function!( - "replica_store_trade_product_location_unset", - Some("opts_json"), - Any, - 2, - 3 - ), - public_function!( - "replica_store_trade_product_media_set", - Some("opts_json"), - Any, - 2, - 3 - ), - public_function!( - "replica_store_trade_product_media_unset", - Some("opts_json"), - Any, - 2, - 3 - ), - public_function!( - "replica_store_trade_product_update", - Some("opts_json"), - Any, - 2, - 3 - ), -]; - -const REPLICA_SYNC_FUNCTIONS: &[WasmPublicFunction] = &[ - public_function!("replica_sync_ingest_event", Some("event_json"), Any, 2, 3), - public_function!("replica_sync_sync_all", Some("request_json"), Any, 2, 3), -]; - -const SQL_WASM_SUPPORT_EXPORTS: &[WasmSupportExport] = &[ - memory_export!(), - number_function_export!("__wbindgen_malloc", 2), - number_function_export!("__wbindgen_realloc", 4), - void_function_export!("__wbindgen_exn_store", 1), - number_function_export!("__externref_table_alloc", 0), - table_export!("__wbindgen_externrefs"), - void_function_export!("__externref_table_dealloc", 1), - void_function_export!("__wbindgen_start", 0), -]; - -#[cfg(test)] -mod tests { - use crate::package_matrix::wasm_package_specs; - - use super::{WASM_DECLARATION_HEADER, declaration_files}; - - #[test] - fn declaration_files_are_dto_owned_and_cover_primary_and_sidecar_paths() { - for spec in wasm_package_specs() { - let files = declaration_files(*spec).expect("declaration files"); - assert_eq!(files.len(), 2); - assert!( - files - .iter() - .any(|file| file.relative_path == format!("dist/{}.d.ts", spec.out_name)) - ); - assert!( - files.iter().any( - |file| file.relative_path == format!("dist/{}_bg.wasm.d.ts", spec.out_name) - ) - ); - for file in files { - assert!(file.contents.starts_with(WASM_DECLARATION_HEADER)); - assert!(!file.contents.contains("tslint:disable")); - assert!(!file.contents.contains("eslint-disable")); - } - } - } - - #[test] - fn generated_declarations_include_public_wasm_entrypoints() { - let specs = wasm_package_specs(); - let events = declaration_files(specs[0]).expect("event declarations"); - assert!( - events[0] - .contents - .contains("export function operational_listing_tags") - ); - assert!( - events[1] - .contents - .contains("export declare const operational_listing_tags") - ); - for authored in [ - "social_ask_build_authored_draft", - "social_comment_build_authored_draft", - "social_photo_update_build_authored_draft", - "social_update_build_authored_draft", - ] { - assert!( - events[0] - .contents - .contains(&format!("export function {authored}")) - ); - assert!( - events[1] - .contents - .contains(&format!("export declare const {authored}")) - ); - } - for retired in [ - "calendar_date_event_tags", - "calendar_event_rsvp_tags", - "calendar_tags", - "calendar_time_event_tags", - ] { - assert!(!events[0].contents.contains(retired)); - assert!(!events[1].contents.contains(retired)); - } - for retired in ["comment_tags", "post_tags"] { - assert!( - !events[0] - .contents - .contains(&format!("export function {retired}(")) - ); - assert!( - !events[1] - .contents - .contains(&format!("export declare const {retired}:")) - ); - } - - let replica_store = declaration_files(specs[1]).expect("replica store declarations"); - assert!( - replica_store[0] - .contents - .contains("export function replica_store_farm_create") - ); - assert!( - replica_store[0] - .contents - .contains("export function replica_store_farm_update") - ); - assert!( - replica_store[0] - .contents - .contains("export function replica_store_plot_update") - ); - assert!( - replica_store[1] - .contents - .contains("export declare const replica_store_farm_create") - ); - assert!( - replica_store[1] - .contents - .contains("export declare const replica_store_farm_update") - ); - assert!( - replica_store[1] - .contents - .contains("export declare const replica_store_plot_update") - ); - - let replica_sync = declaration_files(specs[2]).expect("replica sync declarations"); - assert!( - replica_sync[0] - .contents - .contains("export function replica_sync_sync_all") - ); - assert!( - replica_sync[1] - .contents - .contains("export declare const replica_sync_sync_all") - ); - } -} diff --git a/tools/sdk_xtask_import/tests/fixtures/api-leakage/adr-exception.rs b/tools/sdk_xtask_import/tests/fixtures/api-leakage/adr-exception.rs @@ -1,3 +0,0 @@ -pub fn temporary_client() -> reqwest::Client { - unreachable!() -} diff --git a/tools/sdk_xtask_import/tests/fixtures/api-leakage/allowed-concrete-adapter.rs b/tools/sdk_xtask_import/tests/fixtures/api-leakage/allowed-concrete-adapter.rs @@ -1,3 +0,0 @@ -pub fn protocol_event(event: nostr::Event) -> nostr::Event { - event -} diff --git a/tools/sdk_xtask_import/tests/fixtures/api-leakage/generic-renamed-tokio.rs b/tools/sdk_xtask_import/tests/fixtures/api-leakage/generic-renamed-tokio.rs @@ -1,5 +0,0 @@ -use tokio::runtime::Handle as Executor; - -pub fn executor() -> Executor { - unreachable!() -} diff --git a/tools/sdk_xtask_import/tests/fixtures/api-leakage/generic-sqlx.rs b/tools/sdk_xtask_import/tests/fixtures/api-leakage/generic-sqlx.rs @@ -1,5 +0,0 @@ -use sqlx::SqlitePool; - -pub struct StorageHandle { - pub pool: SqlitePool, -} diff --git a/tools/sdk_xtask_import/tests/fixtures/api-leakage/private-implementation.rs b/tools/sdk_xtask_import/tests/fixtures/api-leakage/private-implementation.rs @@ -1,11 +0,0 @@ -use reqwest::Client; - -pub struct Adapter { - client: Client, -} - -impl Adapter { - fn client(&self) -> &Client { - &self.client - } -} diff --git a/tools/sdk_xtask_import/tests/fixtures/dependency-boundaries/domain-to-storage.json b/tools/sdk_xtask_import/tests/fixtures/dependency-boundaries/domain-to-storage.json @@ -1,46 +0,0 @@ -{ - "packages": [ - { - "id": "fixture#radroots_event@0.1.0", - "name": "radroots_event", - "dependencies": [ - { - "name": "radroots_storage", - "rename": "storage_alias", - "kind": null, - "features": ["memory"], - "target": "cfg(unix)", - "uses_default_features": false - } - ] - }, - { - "id": "fixture#radroots_storage@0.1.0", - "name": "radroots_storage", - "dependencies": [] - } - ], - "resolve": { - "nodes": [ - { - "id": "fixture#radroots_event@0.1.0", - "deps": [ - { - "name": "storage_alias", - "pkg": "fixture#radroots_storage@0.1.0", - "dep_kinds": [ - { - "kind": null, - "target": "cfg(unix)" - } - ] - } - ] - }, - { - "id": "fixture#radroots_storage@0.1.0", - "deps": [] - } - ] - } -} diff --git a/tools/sdk_xtask_import/tests/fixtures/dependency-boundaries/service-to-sdk.json b/tools/sdk_xtask_import/tests/fixtures/dependency-boundaries/service-to-sdk.json @@ -1,46 +0,0 @@ -{ - "packages": [ - { - "id": "fixture#radroots_sync@0.1.0", - "name": "radroots_sync", - "dependencies": [ - { - "name": "radroots_sdk", - "rename": "client_engine", - "kind": null, - "features": ["memory"], - "target": null, - "uses_default_features": false - } - ] - }, - { - "id": "fixture#radroots_sdk@0.1.0", - "name": "radroots_sdk", - "dependencies": [] - } - ], - "resolve": { - "nodes": [ - { - "id": "fixture#radroots_sync@0.1.0", - "deps": [ - { - "name": "client_engine", - "pkg": "fixture#radroots_sdk@0.1.0", - "dep_kinds": [ - { - "kind": null, - "target": null - } - ] - } - ] - }, - { - "id": "fixture#radroots_sdk@0.1.0", - "deps": [] - } - ] - } -} diff --git a/tools/sdk_xtask_import/tests/fixtures/dependency-boundaries/spi-to-adapter.json b/tools/sdk_xtask_import/tests/fixtures/dependency-boundaries/spi-to-adapter.json @@ -1,46 +0,0 @@ -{ - "packages": [ - { - "id": "fixture#radroots_transport@0.1.0", - "name": "radroots_transport", - "dependencies": [ - { - "name": "radroots_nostr", - "rename": "protocol_adapter", - "kind": "build", - "features": ["events"], - "target": "cfg(target_arch = \"wasm32\")", - "uses_default_features": false - } - ] - }, - { - "id": "fixture#radroots_nostr@0.1.0", - "name": "radroots_nostr", - "dependencies": [] - } - ], - "resolve": { - "nodes": [ - { - "id": "fixture#radroots_transport@0.1.0", - "deps": [ - { - "name": "protocol_adapter", - "pkg": "fixture#radroots_nostr@0.1.0", - "dep_kinds": [ - { - "kind": "build", - "target": "cfg(target_arch = \"wasm32\")" - } - ] - } - ] - }, - { - "id": "fixture#radroots_nostr@0.1.0", - "deps": [] - } - ] - } -} diff --git a/tools/sdk_xtask_import/tests/fixtures/dependency-boundaries/valid-downward.json b/tools/sdk_xtask_import/tests/fixtures/dependency-boundaries/valid-downward.json @@ -1,46 +0,0 @@ -{ - "packages": [ - { - "id": "fixture#radroots_transport_nostr@0.1.0", - "name": "radroots_transport_nostr", - "dependencies": [ - { - "name": "radroots_transport", - "rename": "transport_spi", - "kind": "dev", - "features": [], - "target": "cfg(unix)", - "uses_default_features": false - } - ] - }, - { - "id": "fixture#radroots_transport@0.1.0", - "name": "radroots_transport", - "dependencies": [] - } - ], - "resolve": { - "nodes": [ - { - "id": "fixture#radroots_transport_nostr@0.1.0", - "deps": [ - { - "name": "transport_spi", - "pkg": "fixture#radroots_transport@0.1.0", - "dep_kinds": [ - { - "kind": "dev", - "target": "cfg(unix)" - } - ] - } - ] - }, - { - "id": "fixture#radroots_transport@0.1.0", - "deps": [] - } - ] - } -} diff --git a/tools/xtask/Cargo.toml b/tools/xtask/Cargo.toml @@ -12,7 +12,9 @@ authors = ["Tyson Lupul <tyson@radroots.org>"] [dependencies] clap = { workspace = true, features = ["derive"] } dto_bindgen_core = { workspace = true } +dto_bindgen_backend_ts = { workspace = true } fs2 = { workspace = true } +flate2 = { workspace = true } hex = { workspace = true } jsonschema = { workspace = true } prettyplease = { workspace = true } @@ -21,12 +23,18 @@ quote = { workspace = true } radroots_event_codec = { workspace = true, features = [ "manifests", ], default-features = false } +radroots_core_bindings = { workspace = true } +radroots_event_bindings = { workspace = true } +radroots_identity = { workspace = true } +radroots_identity_bindings = { workspace = true } radroots_protocol = { workspace = true, features = ["std"] } +radroots_replica_schema_bindings = { workspace = true } semver = { workspace = true } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } sha2 = { workspace = true } syn = { workspace = true } +tar = { workspace = true } tempfile = { workspace = true } toml = { workspace = true } diff --git a/tools/xtask/src/build_control.rs b/tools/xtask/src/build_control.rs @@ -520,15 +520,8 @@ pub fn artifact( validate_identifier(target, "target")?; validate_identifier(language, "language")?; validate_identifier(builder_id, "builder id")?; - validate_artifact_route(product, target, language)?; + validate_generation_roots(product, target, language, consumer_path, source_path)?; let consumer = ConsumerRoot::open(consumer_path)?; - if consumer.product != product { - return Err(format!( - "consumer marker {} does not match artifact product {product}", - consumer.product - )); - } - verify_source_root(source_path, &consumer.source_lock)?; let mut sorted_features = features.iter().map(String::as_str).collect::<Vec<_>>(); sorted_features.sort_unstable(); sorted_features.dedup(); @@ -577,9 +570,36 @@ pub fn artifact( } } +pub fn validate_generation_roots( + product: &str, + target: &str, + language: &str, + consumer_path: &Path, + source_path: &Path, +) -> Result<(), String> { + validate_identifier(product, "product")?; + validate_identifier(target, "target")?; + validate_identifier(language, "language")?; + validate_artifact_route(product, target, language)?; + let consumer = ConsumerRoot::open(consumer_path)?; + if consumer.product != product { + return Err(format!( + "consumer marker {} does not match artifact product {product}", + consumer.product + )); + } + verify_source_root(source_path, &consumer.source_lock) +} + fn validate_artifact_route(product: &str, target: &str, language: &str) -> Result<(), String> { let valid = match product { - "sdk" => matches!(target, "typescript" | "wasm" | "ffi") && language == "typescript", + "sdk" => matches!( + (target, language), + ("typescript", "typescript") + | ("wasm", "javascript") + | ("ffi", "swift") + | ("ffi", "kotlin") + ), "mobile" => matches!( (target, language), ("ios", "swift") | ("android", "kotlin") | ("wasm", "javascript") @@ -786,6 +806,10 @@ pub(crate) fn atomic_write(path: &Path, bytes: &[u8]) -> Result<(), String> { if bytes.contains(&b'\r') || !bytes.ends_with(b"\n") { return Err("generated text must use LF and end with one newline".to_owned()); } + atomic_write_bytes(path, bytes) +} + +pub(crate) fn atomic_write_bytes(path: &Path, bytes: &[u8]) -> Result<(), String> { let parent = path .parent() .ok_or_else(|| format!("output {} has no parent", path.display()))?; @@ -1193,6 +1217,10 @@ mod tests { validate_artifact_route("mobile", "ios", "kotlin").is_err(), "unsupported target/language must fail" ); + assert!(validate_artifact_route("sdk", "wasm", "javascript").is_ok()); + assert!(validate_artifact_route("sdk", "ffi", "swift").is_ok()); + assert!(validate_artifact_route("sdk", "ffi", "kotlin").is_ok()); + assert!(validate_artifact_route("sdk", "wasm", "typescript").is_err()); assert!( artifact( "sdk", diff --git a/tools/xtask/src/coverage.rs b/tools/xtask/src/coverage.rs @@ -2,6 +2,7 @@ use std::ffi::OsString; use std::fs; +use std::path::Component; use std::path::Path; use std::path::PathBuf; use std::process::Command; @@ -416,7 +417,7 @@ fn read_detailed_summary( function .filenames .iter() - .any(|filename| filename.contains(scope_filter)) + .any(|filename| coverage_filename_matches_scope(filename, scope_filter)) }) { continue; @@ -426,7 +427,7 @@ fn read_detailed_summary( let filename = region_filename(function, region)?; if scope_filter .as_deref() - .is_none_or(|scope_filter| filename.contains(scope_filter)) + .is_none_or(|scope_filter| coverage_filename_matches_scope(filename, scope_filter)) { Some((filename, region[0])) } else { @@ -452,10 +453,9 @@ fn read_detailed_summary( let Some(filename) = region_filename(function, region) else { continue; }; - if scope_filter - .as_deref() - .is_some_and(|scope_filter| !filename.contains(scope_filter)) - { + if scope_filter.as_deref().is_some_and(|scope_filter| { + !coverage_filename_matches_scope(filename, scope_filter) + }) { continue; } let key = RegionCoverageKey { @@ -496,10 +496,9 @@ fn read_detailed_summary( let Some(filename) = branch_filename(function, branch) else { continue; }; - if scope_filter - .as_deref() - .is_some_and(|scope_filter| !filename.contains(scope_filter)) - { + if scope_filter.as_deref().is_some_and(|scope_filter| { + !coverage_filename_matches_scope(filename, scope_filter) + }) { continue; } let key = BranchCoverageKey { @@ -624,6 +623,20 @@ fn scope_path_fragment(scope: &str) -> String { format!("/crates/{crate_dir}/src/") } +fn coverage_filename_matches_scope(filename: &str, scope_filter: &str) -> bool { + let mut normalized = PathBuf::new(); + for component in Path::new(filename).components() { + match component { + Component::CurDir => {} + Component::ParentDir => { + normalized.pop(); + } + component => normalized.push(component.as_os_str()), + } + } + normalized.to_string_lossy().contains(scope_filter) +} + fn percentage(covered: u64, total: u64) -> f64 { if total == 0 { 100.0 @@ -2397,6 +2410,13 @@ mod tests { "regions": [ [20, 1, 20, 6, 0, 0, 0, 0] ] + }, + { + "count": 0, + "filenames": ["/workspace/crates/a/src/adapters/../../tests/unit.rs"], + "regions": [ + [30, 1, 30, 6, 0, 0, 0, 0] + ] } ] } diff --git a/tools/xtask/src/hygiene.rs b/tools/xtask/src/hygiene.rs @@ -242,7 +242,7 @@ pub fn validate_forbidden_identifiers(root: &Path) -> Result<(), String> { "removed identifier 'tangle' must not reappear", &[ "contracts/consolidation/baseline.v1.toml", - "tools/sdk_xtask_import/src/package_matrix.rs", + "tools/xtask/src/sdk_generation/package_matrix.rs", "tools/xtask/src/hygiene.rs", ], &mut failures, @@ -334,7 +334,7 @@ fn reject_retired_listing_aliases(root: &Path, failures: &mut Vec<String>) { for file in files_under(root, &rel_roots) { let rel = display_path(root, &file); - if rel == "tools/xtask/src/hygiene.rs" || rel.starts_with("tools/sdk_xtask_import/") { + if rel == "tools/xtask/src/hygiene.rs" { continue; } if is_retired_listing_module_path(&rel) { diff --git a/tools/xtask/src/main.rs b/tools/xtask/src/main.rs @@ -34,6 +34,8 @@ mod release_graph; #[cfg_attr(coverage_nightly, coverage(off))] mod release_qualification; #[cfg_attr(coverage_nightly, coverage(off))] +mod sdk_generation; +#[cfg_attr(coverage_nightly, coverage(off))] mod supply_chain_qualification; #[cfg_attr(coverage_nightly, coverage(off))] mod target_qualification; @@ -495,21 +497,37 @@ fn run(args: &[String]) -> Result<(), String> { source_date_epoch, builder_id, features, - } => build_control::artifact( - product.as_str(), - target.as_str(), - language.as_str(), - match mode { + } => { + let product = product.as_str(); + let target = target.as_str(); + let language = language.as_str(); + let mode = match mode { ArtifactMode::Check => build_control::Mode::Check, ArtifactMode::Write => build_control::Mode::Write, - }, - &consumer_root, - &source_root, - &output, - source_date_epoch, - &builder_id, - &features, - ), + }; + build_control::validate_generation_roots( + product, + target, + language, + &consumer_root, + &source_root, + )?; + if product == "sdk" { + sdk_generation::artifact(&source_root, &consumer_root, target, language, mode)?; + } + build_control::artifact( + product, + target, + language, + mode, + &consumer_root, + &source_root, + &output, + source_date_epoch, + &builder_id, + &features, + ) + } } } diff --git a/tools/xtask/src/sdk_generation/bindings.rs b/tools/xtask/src/sdk_generation/bindings.rs @@ -0,0 +1,362 @@ +use std::{ + collections::{BTreeMap, BTreeSet}, + env, fs, + path::{Path, PathBuf}, + process::Command, +}; + +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; + +use crate::{build_control::Mode, sdk_generation::fs::write_or_check}; + +const FFI_PACKAGE: &str = "radroots_sdk_ffi"; +const FFI_VERSION: &str = "0.1.0-alpha"; +const BINDGEN_VERSION: &str = "0.29.5"; + +#[derive(Debug, Deserialize, Eq, PartialEq, Serialize)] +struct SourceLock { + schema_version: u16, + source_package: String, + source_version: String, + source_sha256: String, + generator: String, + generator_version: String, + language: String, + outputs: BTreeMap<String, String>, +} + +pub fn generate( + source_root: &Path, + consumer_root: &Path, + language: &str, + mode: Mode, +) -> Result<(), String> { + if !matches!(language, "swift" | "kotlin") { + return Err("SDK binding language must be swift or kotlin".to_owned()); + } + build_bindgen(source_root)?; + let target_dir = env::var_os("CARGO_TARGET_DIR") + .map(PathBuf::from) + .ok_or_else(|| "CARGO_TARGET_DIR must be set by extbuild for FFI generation".to_owned())?; + let library = target_dir.join("debug").join(format!( + "{}radroots_sdk_ffi{}", + env::consts::DLL_PREFIX, + env::consts::DLL_SUFFIX + )); + if !library.is_file() { + return Err(format!("missing built FFI library: {}", library.display())); + } + let bindgen = target_dir + .join("debug") + .join(format!("radroots-sdk-bindgen{}", env::consts::EXE_SUFFIX)); + if !bindgen.is_file() { + return Err(format!( + "missing FFI bindgen executable: {}", + bindgen.display() + )); + } + let temporary = tempfile::tempdir() + .map_err(|error| format!("failed to create binding output directory: {error}"))?; + let status = Command::new(&bindgen) + .args(["generate", "--library"]) + .arg(&library) + .args([ + "--crate", + FFI_PACKAGE, + "--language", + language, + "--no-format", + "--out-dir", + ]) + .arg(temporary.path()) + .current_dir(source_root) + .status() + .map_err(|error| format!("failed to start {}: {error}", bindgen.display()))?; + if !status.success() { + return Err(format!( + "{language} binding generation failed with {status}" + )); + } + install_outputs(source_root, consumer_root, language, temporary.path(), mode)?; + println!("generated {language} bindings for {FFI_PACKAGE}"); + Ok(()) +} + +fn build_bindgen(root: &Path) -> Result<(), String> { + let cargo = env::var_os("CARGO").unwrap_or_else(|| "cargo".into()); + let status = Command::new(cargo) + .args([ + "build", + "--locked", + "-p", + FFI_PACKAGE, + "--lib", + "--bin", + "radroots-sdk-bindgen", + ]) + .current_dir(root) + .status() + .map_err(|error| format!("failed to start Cargo for FFI generation: {error}"))?; + if !status.success() { + return Err(format!( + "FFI library and bindgen build failed with {status}" + )); + } + Ok(()) +} + +fn install_outputs( + source_root: &Path, + consumer_root: &Path, + language: &str, + temporary: &Path, + mode: Mode, +) -> Result<(), String> { + let output_dir = consumer_root.join("generated").join(language); + let mut generated = Vec::new(); + collect_files(temporary, &mut generated)?; + generated.sort(); + let mut outputs = BTreeMap::new(); + for path in generated { + let name = path + .strip_prefix(temporary) + .map_err(|error| format!("failed to relativize {}: {error}", path.display()))? + .to_str() + .ok_or_else(|| format!("non-UTF-8 generated binding path: {}", path.display()))? + .replace('\\', "/"); + let contents = fs::read(&path) + .map_err(|error| format!("failed to read {}: {error}", path.display()))?; + let destination = output_dir.join(&name); + let text = String::from_utf8(contents) + .map_err(|error| format!("generated text binding is not UTF-8: {error}"))?; + let text = normalize_generated_text(&text); + outputs.insert( + name.clone(), + format!("{:x}", Sha256::digest(text.as_bytes())), + ); + write_or_check(&destination, &text, mode)?; + } + if outputs.is_empty() { + return Err(format!("{language} binding generator emitted no files")); + } + let lock = SourceLock { + schema_version: 1, + source_package: FFI_PACKAGE.to_owned(), + source_version: FFI_VERSION.to_owned(), + source_sha256: ffi_source_sha256(source_root)?, + generator: "uniffi".to_owned(), + generator_version: BINDGEN_VERSION.to_owned(), + language: language.to_owned(), + outputs, + }; + let mut rendered = serde_json::to_string_pretty(&lock) + .map_err(|error| format!("failed to render {language} source lock: {error}"))?; + rendered.push('\n'); + write_or_check(&output_dir.join("source.lock"), &rendered, mode) +} + +fn normalize_generated_text(contents: &str) -> String { + let mut normalized = contents + .lines() + .map(str::trim_end) + .collect::<Vec<_>>() + .join("\n"); + while normalized.ends_with('\n') { + normalized.pop(); + } + normalized.push('\n'); + normalized +} + +pub fn check(source_root: &Path, consumer_root: &Path) -> Result<(), String> { + check_language( + source_root, + consumer_root, + "swift", + &[ + "radroots_sdk.swift", + "radroots_sdkFFI.h", + "radroots_sdkFFI.modulemap", + ], + )?; + check_language( + source_root, + consumer_root, + "kotlin", + &["uniffi/radroots_sdk/radroots_sdk.kt"], + )?; + check_kotlin_schema_inventory(consumer_root) +} + +fn check_kotlin_schema_inventory(root: &Path) -> Result<(), String> { + let path = root.join("generated/kotlin/uniffi/radroots_sdk/radroots_sdk.kt"); + let source = fs::read_to_string(&path) + .map_err(|error| format!("failed to read {}: {error}", path.display()))?; + for required in [ + "class MobileClient", + "data class CapabilityStatus", + "enum class CapabilityAvailability", + "enum class CapabilityMaturity", + "sealed class Exception", + ] { + if !source.contains(required) { + return Err(format!( + "generated Kotlin schema inventory is missing {required}: {}", + path.display() + )); + } + } + for forbidden in ["Keychain", "BackgroundTask", "Presentation"] { + if source.contains(forbidden) { + return Err(format!( + "generated Kotlin bindings contain host-owned {forbidden} API: {}", + path.display() + )); + } + } + Ok(()) +} + +fn check_language( + source_root: &Path, + consumer_root: &Path, + language: &str, + expected: &[&str], +) -> Result<(), String> { + let output_dir = consumer_root.join("generated").join(language); + let mut actual_paths = Vec::new(); + collect_files(&output_dir, &mut actual_paths)?; + let actual = actual_paths + .into_iter() + .map(|path| { + path.strip_prefix(&output_dir) + .map(|relative| relative.to_string_lossy().replace('\\', "/")) + .map_err(|error| format!("failed to relativize {}: {error}", path.display())) + }) + .collect::<Result<BTreeSet<_>, _>>()?; + let expected_inventory = expected + .iter() + .map(|name| (*name).to_owned()) + .chain(["source.lock".to_owned()]) + .collect::<BTreeSet<_>>(); + if actual != expected_inventory { + return Err(format!( + "unexpected generated {language} binding inventory: expected {expected_inventory:?}, found {actual:?}" + )); + } + let lock_path = output_dir.join("source.lock"); + let raw = fs::read_to_string(&lock_path) + .map_err(|error| format!("failed to read {}: {error}", lock_path.display()))?; + let actual: SourceLock = serde_json::from_str(&raw) + .map_err(|error| format!("failed to parse {}: {error}", lock_path.display()))?; + let mut outputs = BTreeMap::new(); + for name in expected { + let path = output_dir.join(name); + let contents = fs::read(&path) + .map_err(|error| format!("failed to read {}: {error}", path.display()))?; + outputs.insert( + (*name).to_owned(), + format!("{:x}", Sha256::digest(contents)), + ); + } + let expected_lock = SourceLock { + schema_version: 1, + source_package: FFI_PACKAGE.to_owned(), + source_version: FFI_VERSION.to_owned(), + source_sha256: ffi_source_sha256(source_root)?, + generator: "uniffi".to_owned(), + generator_version: BINDGEN_VERSION.to_owned(), + language: language.to_owned(), + outputs, + }; + if actual != expected_lock { + return Err(format!( + "stale generated {language} bindings: {}", + lock_path.display() + )); + } + Ok(()) +} + +fn ffi_source_sha256(root: &Path) -> Result<String, String> { + let crate_dir = root.join("crates/sdk_ffi"); + let mut paths = vec![crate_dir.join("Cargo.toml"), crate_dir.join("README.md")]; + collect_files(&crate_dir.join("src"), &mut paths)?; + paths.sort(); + let mut hasher = Sha256::new(); + for path in paths { + let relative = path + .strip_prefix(root) + .map_err(|error| format!("failed to relativize {}: {error}", path.display()))?; + hasher.update(relative.to_string_lossy().as_bytes()); + hasher.update([0]); + hasher.update( + fs::read(&path) + .map_err(|error| format!("failed to read {}: {error}", path.display()))?, + ); + hasher.update([0]); + } + Ok(format!("{:x}", hasher.finalize())) +} + +fn collect_files(dir: &Path, paths: &mut Vec<PathBuf>) -> Result<(), String> { + let mut entries = fs::read_dir(dir) + .map_err(|error| format!("failed to read {}: {error}", dir.display()))? + .collect::<Result<Vec<_>, _>>() + .map_err(|error| format!("failed to enumerate {}: {error}", dir.display()))?; + entries.sort_by_key(std::fs::DirEntry::file_name); + for entry in entries { + let path = entry.path(); + if path.is_dir() { + collect_files(&path, paths)?; + } else if path.is_file() { + paths.push(path); + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use std::{collections::BTreeMap, fs}; + + use sha2::{Digest, Sha256}; + + use crate::build_control::Mode; + + use super::{check, generate}; + + #[test] + #[ignore = "requires the native UniFFI bindgen toolchain"] + fn canonical_bindgen_matches_the_frozen_sdk_surface() { + let consumer = tempfile::TempDir::new().expect("consumer fixture"); + let source = crate::workspace_root(); + generate(&source, consumer.path(), "swift", Mode::Write).expect("Swift generation"); + generate(&source, consumer.path(), "kotlin", Mode::Write).expect("Kotlin generation"); + check(&source, consumer.path()).expect("fresh generated bindings"); + + let expected = BTreeMap::from([ + ( + "generated/swift/radroots_sdk.swift", + "0f81256c17243e485d9bca8f2dd4771c0ed2c2c1dc4fb03eb352389aab8599b0", + ), + ( + "generated/swift/radroots_sdkFFI.h", + "511d3a22f969ef21d1a6d7357241756c2fda5d96adbb8bb17f2da5e8c65bcc54", + ), + ( + "generated/swift/radroots_sdkFFI.modulemap", + "b18fa5af1567a940fc699afe371a3a4653dca206f3233bcdf322062ab8192170", + ), + ( + "generated/kotlin/uniffi/radroots_sdk/radroots_sdk.kt", + "63a91b7c7790ee786d1c94cfad30cbe6e3ac5c737108ec61c74696fc068ef7da", + ), + ]); + for (relative, expected_sha256) in expected { + let bytes = fs::read(consumer.path().join(relative)).expect("generated binding"); + assert_eq!(format!("{:x}", Sha256::digest(bytes)), expected_sha256); + } + } +} diff --git a/tools/xtask/src/sdk_generation/contracts.rs b/tools/xtask/src/sdk_generation/contracts.rs @@ -0,0 +1,690 @@ +use std::{ + collections::{BTreeMap, BTreeSet}, + fs, + path::{Path, PathBuf}, +}; + +use serde::Deserialize; + +use crate::sdk_generation::package_matrix::{ + PackageSpec, WasmPackageSpec, package_specs, wasm_package_specs, +}; + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct ExportContract { + language: LanguageContract, + packages: BTreeMap<String, String>, + artifacts: Option<ExportArtifacts>, + runtime: RuntimeContract, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct PackageContract { + language: LanguageContract, + sdk: SdkPackageContract, + rollout: RolloutContract, + operations: BTreeMap<String, String>, + shared_types: BTreeMap<String, String>, + artifacts: Option<SdkArtifacts>, + npm_packages: Option<BTreeMap<String, NpmPackageContract>>, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct LanguageContract { + id: String, + repository: String, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct RuntimeContract { + networking: String, + signing: String, + deterministic_codec: String, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct ExportArtifacts { + models_dir: String, + constants_dir: String, + wasm_dist_dir: Option<String>, + manifest_file: String, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct SdkPackageContract { + package: Option<String>, + package_family: Option<String>, + module_format: Option<String>, + deterministic_codec: String, + signing: String, + networking: String, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct NpmPackageContract { + kind: String, + crate_name: String, + crate_dir: String, + package: String, + package_dir: String, + out_name: Option<String>, + out_dir: Option<String>, +} + +#[derive(Debug)] +struct ExpectedNpmPackage { + kind: &'static str, + crate_name: &'static str, + crate_dir: &'static str, + package: &'static str, + package_dir: &'static str, + out_name: Option<&'static str>, + out_dir: Option<&'static str>, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct RolloutContract { + stage: String, + order: u32, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct SdkArtifacts { + models_dir: String, + runtime_dir: String, + wasm_dist_dir: String, + manifest_file: String, +} + +pub fn validate_sdk_contracts(root: &Path) -> Result<(), String> { + radroots_protocol::runtime::v1::validate_catalog(radroots_protocol::runtime::v1::CATALOG) + .map_err(|error| error.to_string())?; + let exports = load_contract_dir::<ExportContract>(&root.join("contracts").join("exports"))?; + let packages = load_contract_dir::<PackageContract>(&root.join("contracts").join("packages"))?; + if exports.is_empty() { + return Err("contracts/exports must define at least one language".to_owned()); + } + if packages.is_empty() { + return Err("contracts/packages must define at least one language".to_owned()); + } + + let mut export_packages = BTreeMap::new(); + let mut export_languages = BTreeSet::new(); + for export in &exports { + validate_language(&export.language, "exports")?; + validate_non_empty_map(&export.packages, "exports packages")?; + validate_runtime( + &export.runtime.networking, + &export.runtime.signing, + &export.runtime.deterministic_codec, + &format!("exports {}", export.language.id), + )?; + let artifacts = export + .artifacts + .as_ref() + .ok_or_else(|| format!("exports {} artifacts are required", export.language.id))?; + validate_non_empty(&artifacts.models_dir, "exports artifacts.models_dir")?; + validate_non_empty(&artifacts.constants_dir, "exports artifacts.constants_dir")?; + validate_non_empty(&artifacts.manifest_file, "exports artifacts.manifest_file")?; + if export.language.id == "ts" { + validate_non_empty( + artifacts.wasm_dist_dir.as_deref().unwrap_or(""), + "exports ts artifacts.wasm_dist_dir", + )?; + } + if !export_languages.insert(export.language.id.clone()) { + return Err(format!("duplicate exports language {}", export.language.id)); + } + let packages = export + .packages + .values() + .cloned() + .collect::<BTreeSet<String>>(); + if export.language.id != "ts" && packages.len() != 1 { + return Err(format!( + "exports {} must resolve to one curated package", + export.language.id + )); + } + export_packages.insert(export.language.id.clone(), packages); + } + + let mut package_languages = BTreeSet::new(); + let mut operation_keys: Option<BTreeSet<String>> = None; + let mut shared_type_keys: Option<BTreeSet<String>> = None; + let mut rollout_orders = BTreeMap::new(); + for package in &packages { + validate_language(&package.language, "packages")?; + if let Some(package_name) = package.sdk.package.as_deref() { + validate_non_empty(package_name, "packages sdk.package")?; + } + if let Some(package_family) = package.sdk.package_family.as_deref() { + validate_non_empty(package_family, "packages sdk.package_family")?; + } + validate_runtime( + &package.sdk.networking, + &package.sdk.signing, + &package.sdk.deterministic_codec, + &format!("packages {}", package.language.id), + )?; + if let Some(module_format) = package.sdk.module_format.as_deref() { + validate_non_empty(module_format, "packages sdk.module_format")?; + } + if package.sdk.package.is_none() && package.sdk.package_family.is_none() { + return Err(format!( + "packages {} sdk.package or sdk.package_family is required", + package.language.id + )); + } + validate_rollout(&package.language.id, &package.rollout)?; + validate_non_empty_map(&package.operations, "packages operations")?; + validate_non_empty_map(&package.shared_types, "packages shared_types")?; + if package.language.id == "ts" { + let artifacts = package + .artifacts + .as_ref() + .ok_or_else(|| "packages ts artifacts are required".to_owned())?; + validate_non_empty(&artifacts.models_dir, "packages ts artifacts.models_dir")?; + validate_non_empty(&artifacts.runtime_dir, "packages ts artifacts.runtime_dir")?; + validate_non_empty( + &artifacts.wasm_dist_dir, + "packages ts artifacts.wasm_dist_dir", + )?; + validate_non_empty( + &artifacts.manifest_file, + "packages ts artifacts.manifest_file", + )?; + } + if !package_languages.insert(package.language.id.clone()) { + return Err(format!( + "duplicate packages language {}", + package.language.id + )); + } + let Some(packages_for_language) = export_packages.get(&package.language.id) else { + return Err(format!( + "packages {} is missing a matching export contract", + package.language.id + )); + }; + if package.language.id == "ts" { + let npm_packages = package + .npm_packages + .as_ref() + .ok_or_else(|| "packages ts npm_packages are required".to_owned())?; + validate_ts_npm_packages(package, packages_for_language, npm_packages)?; + } else { + if package.npm_packages.is_some() { + return Err(format!( + "packages {} npm_packages is only supported for ts", + package.language.id + )); + } + let sdk_package = package.sdk.package.as_ref().ok_or_else(|| { + format!("packages {} sdk.package is required", package.language.id) + })?; + let expected = [sdk_package.clone()].into_iter().collect::<BTreeSet<_>>(); + if packages_for_language != &expected { + return Err(format!( + "exports {} must resolve to package {}", + package.language.id, sdk_package + )); + } + } + let current_operations = package.operations.keys().cloned().collect::<BTreeSet<_>>(); + match &operation_keys { + Some(expected) if expected != &current_operations => { + return Err(format!( + "packages {} operations must match the shared operation set", + package.language.id + )); + } + None => operation_keys = Some(current_operations), + _ => {} + } + let current_shared_types = package + .shared_types + .keys() + .cloned() + .collect::<BTreeSet<_>>(); + match &shared_type_keys { + Some(expected) if expected != &current_shared_types => { + return Err(format!( + "packages {} shared_types must match the shared type set", + package.language.id + )); + } + None => shared_type_keys = Some(current_shared_types), + _ => {} + } + rollout_orders.insert(package.language.id.clone(), package.rollout.order); + } + + if export_languages != package_languages { + return Err("contracts/exports and contracts/packages languages must match".to_owned()); + } + if rollout_orders.get("ts") != Some(&1) { + return Err("packages ts rollout.order must be 1".to_owned()); + } + Ok(()) +} + +fn validate_ts_npm_packages( + package: &PackageContract, + export_packages: &BTreeSet<String>, + npm_packages: &BTreeMap<String, NpmPackageContract>, +) -> Result<(), String> { + if package.sdk.package.is_some() { + return Err( + "packages ts sdk.package must not be set for multi-package npm output".to_owned(), + ); + } + let package_family = package + .sdk + .package_family + .as_deref() + .ok_or_else(|| "packages ts sdk.package_family is required".to_owned())?; + if package_family != "@radroots" { + return Err("packages ts sdk.package_family must be @radroots".to_owned()); + } + let expected = expected_ts_npm_packages(); + let expected_keys = expected.keys().cloned().collect::<BTreeSet<_>>(); + let actual_keys = npm_packages.keys().cloned().collect::<BTreeSet<_>>(); + if actual_keys != expected_keys { + let missing = expected_keys + .difference(&actual_keys) + .cloned() + .collect::<Vec<_>>(); + let extra = actual_keys + .difference(&expected_keys) + .cloned() + .collect::<Vec<_>>(); + return Err(format!( + "packages ts npm_packages must match package matrix: missing {:?}, extra {:?}", + missing, extra + )); + } + for (key, expected_package) in expected { + let actual = npm_packages + .get(&key) + .ok_or_else(|| format!("packages ts npm package {key} is missing"))?; + validate_npm_package(&key, actual, &expected_package)?; + } + let expected_names = npm_packages + .values() + .map(|package| package.package.clone()) + .collect::<BTreeSet<_>>(); + if export_packages != &expected_names { + return Err("exports ts package set must match TypeScript npm package matrix".to_owned()); + } + Ok(()) +} + +fn expected_ts_npm_packages() -> BTreeMap<String, ExpectedNpmPackage> { + let mut expected = BTreeMap::new(); + for spec in package_specs() { + expected.insert(spec.key.to_owned(), expected_from_package_spec(*spec)); + } + for spec in wasm_package_specs() { + expected.insert(spec.key.to_owned(), expected_from_wasm_package_spec(*spec)); + } + expected +} + +fn expected_from_package_spec(spec: PackageSpec) -> ExpectedNpmPackage { + ExpectedNpmPackage { + kind: "bindings", + crate_name: spec.crate_name, + crate_dir: spec.crate_dir, + package: spec.package_name, + package_dir: spec.package_dir, + out_name: None, + out_dir: None, + } +} + +fn expected_from_wasm_package_spec(spec: WasmPackageSpec) -> ExpectedNpmPackage { + ExpectedNpmPackage { + kind: "wasm", + crate_name: spec.crate_name, + crate_dir: spec.crate_dir, + package: spec.package_name, + package_dir: spec.package_dir, + out_name: Some(spec.out_name), + out_dir: Some(spec.out_dir), + } +} + +fn validate_npm_package( + key: &str, + actual: &NpmPackageContract, + expected: &ExpectedNpmPackage, +) -> Result<(), String> { + validate_npm_field(key, "kind", &actual.kind, expected.kind)?; + validate_npm_field(key, "crate_name", &actual.crate_name, expected.crate_name)?; + validate_npm_field(key, "crate_dir", &actual.crate_dir, expected.crate_dir)?; + validate_npm_field(key, "package", &actual.package, expected.package)?; + validate_npm_field( + key, + "package_dir", + &actual.package_dir, + expected.package_dir, + )?; + validate_optional_npm_field( + key, + "out_name", + actual.out_name.as_deref(), + expected.out_name, + )?; + validate_optional_npm_field(key, "out_dir", actual.out_dir.as_deref(), expected.out_dir)?; + Ok(()) +} + +fn validate_npm_field(key: &str, field: &str, actual: &str, expected: &str) -> Result<(), String> { + validate_non_empty(actual, &format!("packages ts npm package {key} {field}"))?; + if actual != expected { + return Err(format!( + "packages ts npm package {key} {field} must be {expected}" + )); + } + Ok(()) +} + +fn validate_optional_npm_field( + key: &str, + field: &str, + actual: Option<&str>, + expected: Option<&str>, +) -> Result<(), String> { + match (actual, expected) { + (Some(actual), Some(expected)) => validate_npm_field(key, field, actual, expected), + (None, None) => Ok(()), + (Some(_), None) => Err(format!( + "packages ts npm package {key} {field} must not be set" + )), + (None, Some(expected)) => Err(format!( + "packages ts npm package {key} {field} must be {expected}" + )), + } +} + +fn load_contract_dir<T>(dir: &Path) -> Result<Vec<T>, String> +where + T: for<'de> Deserialize<'de>, +{ + let read_dir = + fs::read_dir(dir).map_err(|error| format!("failed to read {}: {error}", dir.display()))?; + let mut entries = read_dir + .collect::<Result<Vec<_>, _>>() + .map_err(|error| format!("failed to read {} entry: {error}", dir.display()))?; + entries.sort_by_key(|entry| entry.file_name()); + let mut contracts = Vec::new(); + for entry in entries { + let path = entry.path(); + if path.extension().and_then(|extension| extension.to_str()) != Some("toml") { + continue; + } + contracts.push(parse_toml(&path)?); + } + Ok(contracts) +} + +fn parse_toml<T>(path: &PathBuf) -> Result<T, String> +where + T: for<'de> Deserialize<'de>, +{ + let raw = fs::read_to_string(path) + .map_err(|error| format!("failed to read {}: {error}", path.display()))?; + toml::from_str(&raw).map_err(|error| format!("failed to parse {}: {error}", path.display())) +} + +fn validate_language(language: &LanguageContract, family: &str) -> Result<(), String> { + validate_non_empty(&language.id, &format!("{family} language.id"))?; + validate_non_empty( + &language.repository, + &format!("{family} language.repository"), + ) +} + +fn validate_runtime( + networking: &str, + signing: &str, + deterministic_codec: &str, + family: &str, +) -> Result<(), String> { + validate_non_empty(networking, &format!("{family} networking"))?; + validate_non_empty(signing, &format!("{family} signing"))?; + validate_non_empty( + deterministic_codec, + &format!("{family} deterministic_codec"), + ) +} + +fn validate_rollout(language: &str, rollout: &RolloutContract) -> Result<(), String> { + validate_non_empty(&rollout.stage, "packages rollout.stage")?; + if !matches!(rollout.stage.as_str(), "active" | "next" | "deferred") { + return Err(format!("packages {language} rollout.stage is invalid")); + } + if rollout.order == 0 { + return Err(format!( + "packages {language} rollout.order must be greater than zero" + )); + } + Ok(()) +} + +fn validate_non_empty(value: &str, field: &str) -> Result<(), String> { + if value.trim().is_empty() || value.trim() != value { + return Err(format!("{field} must be non-empty")); + } + Ok(()) +} + +fn validate_non_empty_map(map: &BTreeMap<String, String>, field: &str) -> Result<(), String> { + if map.is_empty() { + return Err(format!("{field} must not be empty")); + } + for (key, value) in map { + validate_non_empty(key, field)?; + validate_non_empty(value, field)?; + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use std::{ + fs, + time::{SystemTime, UNIX_EPOCH}, + }; + + use super::validate_sdk_contracts; + + #[test] + fn rejects_mismatched_language_sets() { + let root = test_root("language_mismatch"); + write_contract(&root, "contracts/exports/ts.toml", EXPORT_TS); + let error = validate_sdk_contracts(&root).expect_err("missing packages should fail"); + assert!(error.contains("contracts/packages")); + let _ = fs::remove_dir_all(root); + } + + #[test] + fn rejects_package_export_mismatch() { + let root = test_root("package_mismatch"); + write_contract( + &root, + "contracts/exports/ts.toml", + EXPORT_TS + .replace("@radroots/core-bindings", "@radroots/other") + .as_str(), + ); + write_contract(&root, "contracts/packages/ts.toml", PACKAGE_TS); + let error = validate_sdk_contracts(&root).expect_err("mismatch should fail"); + assert!(error.contains("exports ts package set")); + let _ = fs::remove_dir_all(root); + } + + #[test] + fn rejects_ts_package_matrix_drift() { + let root = test_root("ts_matrix_drift"); + write_contract(&root, "contracts/exports/ts.toml", EXPORT_TS); + write_contract( + &root, + "contracts/packages/ts.toml", + PACKAGE_TS + .replace( + "package = \"@radroots/core-bindings\"", + "package = \"@radroots/other\"", + ) + .as_str(), + ); + let error = validate_sdk_contracts(&root).expect_err("mismatch should fail"); + assert!(error.contains("packages ts npm package core package")); + let _ = fs::remove_dir_all(root); + } + + fn test_root(name: &str) -> std::path::PathBuf { + let stamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("time") + .as_nanos(); + std::env::temp_dir().join(format!("radroots_sdk_contracts_{name}_{stamp}")) + } + + fn write_contract(root: &std::path::Path, relative: &str, contents: &str) { + let path = root.join(relative); + fs::create_dir_all(path.parent().expect("parent")).expect("create parent"); + fs::write(path, contents).expect("write contract"); + } + + const EXPORT_TS: &str = r#"[language] +id = "ts" +repository = "sdk-typescript" + +[packages] +"radroots_core" = "@radroots/core-bindings" +"radroots_event" = "@radroots/event-bindings" +"radroots_identity" = "@radroots/identity-bindings" +"radroots_replica_schema" = "@radroots/replica-schema-bindings" +"radroots_trade" = "@radroots/trade-bindings" +"radroots_event_codec_wasm" = "@radroots/event-codec-wasm" +"radroots_replica_store_wasm" = "@radroots/replica-store-wasm" +"radroots_replica_sync_wasm" = "@radroots/replica-sync-wasm" + +[artifacts] +models_dir = "src/generated" +constants_dir = "src/generated" +wasm_dist_dir = "dist" +manifest_file = "export-manifest.json" + +[runtime] +networking = "native" +signing = "native" +deterministic_codec = "wasm" +"#; + + const PACKAGE_TS: &str = r#"[language] +id = "ts" +repository = "sdk-typescript" + +[sdk] +package_family = "@radroots" +module_format = "esm" +deterministic_codec = "wasm" +signing = "native" +networking = "native" + +[npm_packages.core] +kind = "bindings" +crate_name = "radroots_core_bindings" +crate_dir = "crates/core_bindings" +package = "@radroots/core-bindings" +package_dir = "packages/core-bindings" + +[npm_packages.event] +kind = "bindings" +crate_name = "radroots_event_bindings" +crate_dir = "crates/event_bindings" +package = "@radroots/event-bindings" +package_dir = "packages/event-bindings" + +[npm_packages.identity] +kind = "bindings" +crate_name = "radroots_identity_bindings" +crate_dir = "crates/identity_bindings" +package = "@radroots/identity-bindings" +package_dir = "packages/identity-bindings" + +[npm_packages.replica_schema] +kind = "bindings" +crate_name = "radroots_replica_schema_bindings" +crate_dir = "crates/replica_schema_bindings" +package = "@radroots/replica-schema-bindings" +package_dir = "packages/replica-schema-bindings" + +[npm_packages.trade] +kind = "bindings" +crate_name = "radroots_trade_bindings" +crate_dir = "crates/trade_bindings" +package = "@radroots/trade-bindings" +package_dir = "packages/trade-bindings" + +[npm_packages.event_codec] +kind = "wasm" +crate_name = "radroots_event_codec_wasm" +crate_dir = "crates/event_codec_wasm" +package = "@radroots/event-codec-wasm" +package_dir = "packages/event-codec-wasm" +out_name = "radroots_event_codec_wasm" +out_dir = "../../packages/event-codec-wasm/dist" + +[npm_packages.replica_store] +kind = "wasm" +crate_name = "radroots_replica_store_wasm" +crate_dir = "crates/replica_store_wasm" +package = "@radroots/replica-store-wasm" +package_dir = "packages/replica-store-wasm" +out_name = "radroots_replica_store_wasm" +out_dir = "../../packages/replica-store-wasm/dist" + +[npm_packages.replica_sync] +kind = "wasm" +crate_name = "radroots_replica_sync_wasm" +crate_dir = "crates/replica_sync_wasm" +package = "@radroots/replica-sync-wasm" +package_dir = "packages/replica-sync-wasm" +out_name = "radroots_replica_sync_wasm" +out_dir = "../../packages/replica-sync-wasm/dist" + +[rollout] +stage = "active" +order = 1 + +[operations] +"farm.build_draft" = "farm.buildDraft" + +[shared_types] +"RadrootsNip01EventWireParts" = "RadrootsNip01EventWireParts" +"RadrootsNip01EventWireDto" = "RadrootsNip01EventWireDto" +"RadrootsEventDraft" = "RadrootsEventDraft" +"RadrootsSignedEventDto" = "RadrootsSignedEventDto" +"RadrootsVerifiedSignedEventDto" = "RadrootsVerifiedSignedEventDto" +"RadrootsEventEnvelopeDto" = "RadrootsEventEnvelopeDto" + +[artifacts] +models_dir = "src/generated" +runtime_dir = "src/runtime" +wasm_dist_dir = "dist" +manifest_file = "export-manifest.json" +"#; +} diff --git a/tools/xtask/src/sdk_generation/dto_roots.rs b/tools/xtask/src/sdk_generation/dto_roots.rs @@ -0,0 +1,851 @@ +#![cfg_attr(not(test), allow(dead_code))] + +use std::collections::{BTreeMap, BTreeSet}; + +use dto_bindgen_backend_ts::{ + DtoRegistryRenderOptions, DtoTypesModule, TypeScriptDeclaration, TypeScriptModule, + TypeScriptType, render_registry_types, +}; +use dto_bindgen_core::{Registry, RootDescriptor, build_registry}; + +#[derive(Clone, Copy, Debug)] +pub struct DtoPackageRootSet { + pub package_key: &'static str, + roots: fn() -> Vec<RootDescriptor>, +} + +impl DtoPackageRootSet { + pub fn roots(&self) -> Vec<RootDescriptor> { + (self.roots)() + } + + pub fn registry(&self) -> Registry { + build_registry(self.roots()) + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct ManualDescriptorFamily { + pub package_key: &'static str, + pub source_family: &'static str, + pub reason: &'static str, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct SdkLocalWrapperAllowance { + pub package_key: &'static str, + pub shape_family: &'static str, + pub reason: &'static str, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +struct DtoExternalOverride { + target_type: &'static str, + import_name: &'static str, + from_package_key: &'static str, + from: &'static str, +} + +const CORE_BINDINGS_PACKAGE_KEY: &str = "core"; +const CORE_BINDINGS_PACKAGE_NAME: &str = "@radroots/core-bindings"; +const EVENT_BINDINGS_PACKAGE_KEY: &str = "event"; +const EVENT_BINDINGS_PACKAGE_NAME: &str = "@radroots/event-bindings"; + +pub const DTO_PACKAGE_ROOTS: &[DtoPackageRootSet] = &[DtoPackageRootSet { + package_key: "core", + roots: core_roots, +}]; + +pub const MANUAL_DESCRIPTOR_FAMILIES: &[ManualDescriptorFamily] = &[ + ManualDescriptorFamily { + package_key: "core", + source_family: "decimal, currency, money, quantity, percent, quantity price, unit, and discount value families", + reason: "custom serde, string-backed newtypes, aliases, and tagged enum wire forms require source-owned manual descriptors", + }, + ManualDescriptorFamily { + package_key: "event", + source_family: "event timestamps, counters, and optional metadata fields", + reason: "large integers and source-specific optional/null policy must be explicit", + }, + ManualDescriptorFamily { + package_key: "event", + source_family: "GeoJSON coordinate arrays", + reason: "fixed-size Rust arrays must preserve tuple semantics in TypeScript", + }, + ManualDescriptorFamily { + package_key: "trade", + source_family: "trade listing roots and package projection count fields", + reason: "core aliases, source-owned event imports, and count-family numeric policy require explicit descriptors", + }, + ManualDescriptorFamily { + package_key: "replica_schema", + source_family: "untagged query wrappers and serde_json value fields", + reason: "schema query shapes are generated and not all source fields map to derive-supported DTOs", + }, +]; + +pub const SDK_LOCAL_WRAPPER_ALLOWANCES: &[SdkLocalWrapperAllowance] = &[ + SdkLocalWrapperAllowance { + package_key: "core", + shape_family: "Currency and Decimal package aliases", + reason: "string-backed source descriptors require canonical named TypeScript package aliases", + }, + SdkLocalWrapperAllowance { + package_key: "replica_schema", + shape_family: "generated query argument wrappers", + reason: "schema operation inputs are generated package shapes rather than source-owned public DTO structs", + }, + SdkLocalWrapperAllowance { + package_key: "trade", + shape_family: "marketplace, query, projection, sort, review, and backoffice DTO shapes", + reason: "these are SDK package contract shapes layered over source-owned trade, events, and core DTOs", + }, +]; + +const TRADE_REQUIRED_EXTERNAL_PACKAGE_IMPORTS: &[&str] = + &[CORE_BINDINGS_PACKAGE_NAME, EVENT_BINDINGS_PACKAGE_NAME]; +const EVENT_BINDINGS_TYPES_TS: &str = + include_str!("../../../../crates/event_bindings/tests/fixtures/generated_types.ts"); + +pub fn package_root_set(package_key: &str) -> Option<&'static DtoPackageRootSet> { + DTO_PACKAGE_ROOTS + .iter() + .find(|root_set| root_set.package_key == package_key) +} + +pub fn core_types_module() -> Result<DtoTypesModule, String> { + let root_set = package_root_set("core").ok_or_else(|| "missing core DTO roots".to_owned())?; + let rendered = + render_registry_types(&root_set.registry(), &DtoRegistryRenderOptions::default())?; + Ok(with_type_aliases_sorted( + rendered, + [ + type_alias("Currency", TypeScriptType::String), + type_alias("Decimal", TypeScriptType::String), + ], + )) +} + +pub fn replica_schema_types_module() -> Result<DtoTypesModule, String> { + render_registry_types( + &radroots_replica_schema_bindings::dto_registry(), + &DtoRegistryRenderOptions::default(), + ) +} + +fn core_roots() -> Vec<RootDescriptor> { + radroots_core_bindings::dto_roots() +} + +fn generated_external_package_exports() -> Result<BTreeMap<&'static str, BTreeSet<String>>, String> +{ + Ok(BTreeMap::from([ + ( + CORE_BINDINGS_PACKAGE_KEY, + type_exports(core_types_module()?.body_ts()), + ), + ( + EVENT_BINDINGS_PACKAGE_KEY, + type_exports(EVENT_BINDINGS_TYPES_TS), + ), + ])) +} + +fn validate_external_override_target( + override_target: DtoExternalOverride, + package_exports: &BTreeMap<&'static str, BTreeSet<String>>, +) -> Result<(), String> { + let exports = package_exports + .get(override_target.from_package_key) + .ok_or_else(|| { + format!( + "external DTO override `{}` references unknown package key `{}`", + override_target.target_type, override_target.from_package_key + ) + })?; + if exports.contains(override_target.import_name) { + return Ok(()); + } + Err(format!( + "external DTO override `{}` imports `{}` from `{}`, but package `{}` does not export it", + override_target.target_type, + override_target.import_name, + override_target.from, + override_target.from_package_key + )) +} + +fn validate_external_override_usage( + module: &DtoTypesModule, + overrides: &[DtoExternalOverride], +) -> Result<(), String> { + let imports = imported_type_inventory(module.imports_ts().unwrap_or_default()); + let package_exports = generated_external_package_exports()?; + for package_name in TRADE_REQUIRED_EXTERNAL_PACKAGE_IMPORTS { + if !imports.contains_key(*package_name) { + return Err(format!( + "expected generated DTO imports from `{package_name}` but none were emitted" + )); + } + } + + for override_target in overrides { + if type_exports(module.body_ts()).contains(override_target.target_type) { + return Err(format!( + "external DTO override `{}` from `{}` was emitted locally instead of imported", + override_target.target_type, override_target.from + )); + } + if imports + .get(override_target.from) + .is_some_and(|names| names.contains(override_target.import_name)) + { + validate_external_override_target(*override_target, &package_exports)?; + } + } + Ok(()) +} + +fn imported_type_inventory(imports_ts: &str) -> BTreeMap<String, BTreeSet<String>> { + let mut imports = BTreeMap::new(); + let mut pending_names: Option<Vec<String>> = None; + + for line in imports_ts.lines() { + let line = line.trim(); + if let Some(single) = line.strip_prefix("import type { ") { + if let Some((name, from)) = single.split_once(" } from ") { + insert_import_names(&mut imports, from, name); + } + } else if line == "import type {" { + pending_names = Some(Vec::new()); + } else if let Some(from) = line.strip_prefix("} from ") { + if let Some(names) = pending_names.take() { + for name in names { + insert_import(&mut imports, from, &name); + } + } + } else if let Some(names) = pending_names.as_mut() + && let Some(name) = line.strip_suffix(',') + { + names.push(name.to_owned()); + } + } + + imports +} + +fn with_additional_type_imports( + module: DtoTypesModule, + from: &str, + names: impl IntoIterator<Item = &'static str>, +) -> DtoTypesModule { + let mut imports = imported_type_inventory(module.imports_ts().unwrap_or_default()); + imports + .entry(from.to_owned()) + .or_default() + .extend(names.into_iter().map(str::to_owned)); + let imports_ts = imports + .into_iter() + .map(|(from, names)| { + format!( + "import type {{ {} }} from \"{from}\";", + names.into_iter().collect::<Vec<_>>().join(", ") + ) + }) + .collect::<Vec<_>>() + .join("\n"); + let imports_ts = if imports_ts.is_empty() { + imports_ts + } else { + format!("{imports_ts}\n\n") + }; + DtoTypesModule::new(imports_ts, module.body_ts()) +} + +fn insert_import_names(imports: &mut BTreeMap<String, BTreeSet<String>>, from: &str, names: &str) { + for name in names.split(',') { + insert_import(imports, from, name); + } +} + +fn insert_import(imports: &mut BTreeMap<String, BTreeSet<String>>, from: &str, name: &str) { + let from = from.trim_end_matches(';').trim_matches('"'); + imports + .entry(from.to_owned()) + .or_default() + .insert(name.trim().to_owned()); +} + +fn type_exports(types_ts: &str) -> BTreeSet<String> { + types_ts + .lines() + .filter_map(|line| line.strip_prefix("export type ")) + .filter_map(|rest| rest.split([' ', '<']).next()) + .map(str::to_owned) + .collect() +} + +const fn event_override(export_name: &'static str) -> DtoExternalOverride { + DtoExternalOverride { + target_type: export_name, + import_name: export_name, + from_package_key: EVENT_BINDINGS_PACKAGE_KEY, + from: EVENT_BINDINGS_PACKAGE_NAME, + } +} + +fn declaration_name(declaration: &str) -> &str { + declaration + .strip_prefix("export type ") + .and_then(|rest| rest.split([' ', '<']).next()) + .unwrap_or(declaration) +} + +fn with_type_aliases_sorted( + module: DtoTypesModule, + aliases: impl IntoIterator<Item = String>, +) -> DtoTypesModule { + let mut declarations = module + .body_ts() + .split("\n\n") + .filter(|declaration| !declaration.trim().is_empty()) + .map(str::to_owned) + .collect::<Vec<_>>(); + declarations.extend(aliases); + declarations.sort_by(|left, right| declaration_name(left).cmp(declaration_name(right))); + DtoTypesModule::new( + module.imports_ts().unwrap_or_default(), + declarations.join("\n\n"), + ) +} + +fn type_alias(name: impl Into<String>, type_expr: TypeScriptType) -> String { + TypeScriptModule::new("types.ts") + .with_declaration(TypeScriptDeclaration::type_alias(name, type_expr)) + .render_source() + .trim() + .to_owned() +} + +#[cfg(test)] +mod tests { + use std::collections::{BTreeMap, BTreeSet}; + + use super::{ + CORE_BINDINGS_PACKAGE_KEY, CORE_BINDINGS_PACKAGE_NAME, DTO_PACKAGE_ROOTS, + DtoExternalOverride, MANUAL_DESCRIPTOR_FAMILIES, SDK_LOCAL_WRAPPER_ALLOWANCES, + event_override, imported_type_inventory, package_root_set, type_exports, + validate_external_override_target, validate_external_override_usage, + with_additional_type_imports, + }; + use dto_bindgen_backend_ts::DtoTypesModule; + + const CORE_BINDINGS_TYPES_TS: &str = + include_str!("../../../../crates/core_bindings/tests/fixtures/generated_types.ts"); + const EVENT_BINDINGS_TYPES_TS: &str = + include_str!("../../../../crates/event_bindings/tests/fixtures/generated_types.ts"); + const REPLICA_SCHEMA_BINDINGS_TYPES_TS: &str = include_str!( + "../../../../crates/replica_schema_bindings/tests/fixtures/generated_types.ts" + ); + const TRADE_BINDINGS_TYPES_TS: &str = + include_str!("../../../../crates/trade_bindings/tests/fixtures/generated_types.ts"); + const REPLICA_SCHEMA_MODEL_SOURCES: &[&str] = &[ + include_str!("../../../../crates/replica_schema/src/models/farm.rs"), + include_str!("../../../../crates/replica_schema/src/models/farm_gcs_location.rs"), + include_str!("../../../../crates/replica_schema/src/models/farm_member.rs"), + include_str!("../../../../crates/replica_schema/src/models/farm_member_claim.rs"), + include_str!("../../../../crates/replica_schema/src/models/farm_tag.rs"), + include_str!("../../../../crates/replica_schema/src/models/gcs_location.rs"), + include_str!("../../../../crates/replica_schema/src/models/log_error.rs"), + include_str!("../../../../crates/replica_schema/src/models/media_image.rs"), + include_str!("../../../../crates/replica_schema/src/models/nostr_event_head.rs"), + include_str!("../../../../crates/replica_schema/src/models/nostr_profile.rs"), + include_str!("../../../../crates/replica_schema/src/models/nostr_profile_relay.rs"), + include_str!("../../../../crates/replica_schema/src/models/nostr_relay.rs"), + include_str!("../../../../crates/replica_schema/src/models/plot.rs"), + include_str!("../../../../crates/replica_schema/src/models/plot_gcs_location.rs"), + include_str!("../../../../crates/replica_schema/src/models/plot_tag.rs"), + include_str!("../../../../crates/replica_schema/src/models/trade_product.rs"), + include_str!("../../../../crates/replica_schema/src/models/trade_product_location.rs"), + include_str!("../../../../crates/replica_schema/src/models/trade_product_media.rs"), + ]; + const CORE_TYPE_INVENTORY: &[&str] = &[ + "Currency", + "Decimal", + "Discount", + "DiscountScope", + "DiscountThreshold", + "DiscountValue", + "Money", + "Percent", + "Quantity", + "QuantityPrice", + "Unit", + "UnitDimension", + ]; + const EVENT_TYPE_INVENTORY: &[&str] = &[ + "JobFeedbackStatus", + "JobInputType", + "JobPaymentRequest", + "RadrootsAccountClaim", + "RadrootsAddressableRef", + "RadrootsAppData", + "RadrootsCommercialDomain", + "RadrootsContributionAttestation", + "RadrootsCoop", + "RadrootsCoopLocation", + "RadrootsCoopRef", + "RadrootsDocument", + "RadrootsDocumentSubject", + "RadrootsEventEnvelopeDto", + "RadrootsEventPtr", + "RadrootsEventRef", + "RadrootsEvidenceBounty", + "RadrootsFarm", + "RadrootsFarmPublicLocation", + "RadrootsFarmRef", + "RadrootsFollow", + "RadrootsFollowProfile", + "RadrootsGcsLocation", + "RadrootsGeoChat", + "RadrootsGeoJsonPoint", + "RadrootsGeoJsonPolygon", + "RadrootsGiftWrap", + "RadrootsGiftWrapRecipient", + "RadrootsJobFeedback", + "RadrootsJobInput", + "RadrootsJobParam", + "RadrootsJobRequest", + "RadrootsJobResult", + "RadrootsKnowledgeChangeProposal", + "RadrootsKnowledgeCitationSpan", + "RadrootsKnowledgeClaim", + "RadrootsKnowledgeFieldContext", + "RadrootsKnowledgeFieldReport", + "RadrootsKnowledgeLocation", + "RadrootsKnowledgeLocationPrecision", + "RadrootsKnowledgeNodeRef", + "RadrootsKnowledgeObservation", + "RadrootsKnowledgeObservationValue", + "RadrootsKnowledgeRelation", + "RadrootsKnowledgeReview", + "RadrootsKnowledgeReviewScope", + "RadrootsKnowledgeReviewScore", + "RadrootsKnowledgeReviewTarget", + "RadrootsKnowledgeSource", + "RadrootsList", + "RadrootsListEntry", + "RadrootsListSet", + "RadrootsMessage", + "RadrootsMessageFile", + "RadrootsMessageFileDimensions", + "RadrootsMessageRecipient", + "RadrootsNip01EventWireDto", + "RadrootsOperationalListing", + "RadrootsOperationalListingAvailability", + "RadrootsOperationalListingBin", + "RadrootsOperationalListingDeliveryMethod", + "RadrootsOperationalListingImage", + "RadrootsOperationalListingImageSize", + "RadrootsOperationalListingParseError", + "RadrootsOperationalListingProduct", + "RadrootsOperationalListingProductTagKeys", + "RadrootsOperationalListingPublicLocation", + "RadrootsOperationalListingStatus", + "RadrootsOperationalListingValidationError", + "RadrootsPlot", + "RadrootsPlotLocation", + "RadrootsPlotRef", + "RadrootsPost", + "RadrootsProfileType", + "RadrootsReaction", + "RadrootsRelayDocument", + "RadrootsResourceArea", + "RadrootsResourceAreaLocation", + "RadrootsResourceAreaRef", + "RadrootsResourceHarvestCap", + "RadrootsResourceHarvestProduct", + "RadrootsRightsAssertion", + "RadrootsSeal", + "RadrootsSignedEventDto", + "RadrootsSignedEventVerificationStateDto", + "RadrootsSocialFarmAnchor", + "RadrootsSocialLocation", + "RadrootsSocialMediaDimensions", + "RadrootsSocialMediaMetadata", + "RadrootsSocialMediaThumbnail", + "RadrootsSocialTarget", + "RadrootsVerifiedSignedEventDto", + "RadrootsVerifiedSignedEventVerificationStateDto", + "RadrootsWikiArticle", + "RadrootsWikiArticleVersionRef", + "RadrootsWikiMergeRequest", + "RadrootsWikiRedirect", + ]; + const TRADE_TYPE_INVENTORY: &[&str] = &[ + "RadrootsOperationalListingSubtotal", + "RadrootsOperationalListingTotal", + "RadrootsOperationalListingTradeProjection", + "RadrootsTradeAgreementClaimV1", + "RadrootsTradeAgreementStateV1", + "RadrootsTradeAttestationRecordV1", + "RadrootsTradeAttestationResultV1", + "RadrootsTradeAttestationStateV1", + "RadrootsTradeConflictStateV1", + "RadrootsTradeEvidenceStateV1", + "RadrootsTradeFacetCount", + "RadrootsTradeFulfillmentStateV1", + "RadrootsTradeListingBackofficeOverlay", + "RadrootsTradeListingBackofficeQuery", + "RadrootsTradeListingBackofficeView", + "RadrootsTradeListingBinProjection", + "RadrootsTradeListingFacets", + "RadrootsTradeListingMarketStatus", + "RadrootsTradeListingProjection", + "RadrootsTradeListingQuery", + "RadrootsTradeListingSort", + "RadrootsTradeListingSortField", + "RadrootsTradeMarketplaceListingSummary", + "RadrootsTradeModerationFlag", + "RadrootsTradeModerationSeverity", + "RadrootsTradeModerationStatus", + "RadrootsTradeNegotiationStateV1", + "RadrootsTradePaymentStateV1", + "RadrootsTradePrivateTermsStateV1", + "RadrootsTradeProjectionV1", + "RadrootsTradeReducerIssueV1", + "RadrootsTradeReviewPriority", + "RadrootsTradeReviewQueueEntry", + "RadrootsTradeReviewStatus", + "RadrootsTradeSortDirection", + ]; + + #[test] + fn approved_source_roots_build_registries() { + for root_set in DTO_PACKAGE_ROOTS { + let registry = root_set.registry(); + assert!( + !registry.has_errors(), + "registry for {} has diagnostics: {:?}", + root_set.package_key, + registry.diagnostics + ); + assert!(!registry.roots.is_empty()); + } + } + + #[test] + fn package_roots_are_explicit_not_discovered() { + assert!(package_root_set("core").is_some()); + assert!(package_root_set("event").is_none()); + assert!(package_root_set("trade").is_none()); + } + + #[test] + fn manual_descriptor_catalog_covers_known_review_families() { + assert!( + MANUAL_DESCRIPTOR_FAMILIES + .iter() + .any(|family| family.source_family.contains("GeoJSON")) + ); + assert!(SDK_LOCAL_WRAPPER_ALLOWANCES.iter().any(|allowance| { + allowance + .shape_family + .contains("marketplace, query, projection") + })); + } + + #[test] + fn external_override_target_validation_rejects_missing_generated_export() { + let package_exports = BTreeMap::from([( + CORE_BINDINGS_PACKAGE_KEY, + BTreeSet::from(["ExistingType".to_owned()]), + )]); + let error = validate_external_override_target( + DtoExternalOverride { + target_type: "MissingType", + import_name: "MissingType", + from_package_key: CORE_BINDINGS_PACKAGE_KEY, + from: CORE_BINDINGS_PACKAGE_NAME, + }, + &package_exports, + ) + .expect_err("missing target package export must fail"); + + assert_eq!( + error, + "external DTO override `MissingType` imports `MissingType` from `@radroots/core-bindings`, but package `core` does not export it" + ); + } + + #[test] + fn external_override_usage_rejects_absent_required_package_import() { + let module = DtoTypesModule::new("", "export type RadrootsTradeListing = string;"); + let error = validate_external_override_usage(&module, &[]) + .expect_err("missing required package import must fail"); + + assert_eq!( + error, + "expected generated DTO imports from `@radroots/core-bindings` but none were emitted" + ); + } + + #[test] + fn external_override_usage_rejects_local_emission_for_imported_type() { + let module = DtoTypesModule::new( + "import type { Decimal } from \"@radroots/core-bindings\";\n\nimport type { RadrootsFarmRef } from \"@radroots/event-bindings\";\n\n", + "export type RadrootsFarmRef = { pubkey: string, d_tag: string, };", + ); + let error = validate_external_override_usage(&module, &[event_override("RadrootsFarmRef")]) + .expect_err("local emission of imported type must fail"); + + assert_eq!( + error, + "external DTO override `RadrootsFarmRef` from `@radroots/event-bindings` was emitted locally instead of imported" + ); + } + + #[test] + fn generated_import_inventory_parses_single_and_multiline_imports() { + let imports = imported_type_inventory( + "import type { One, Four } from \"@radroots/one\";\nimport type {\n Two,\n Three,\n} from \"@radroots/many\";\n\n", + ); + + assert_eq!( + imports.get("@radroots/one").expect("single import package"), + &BTreeSet::from(["Four".to_owned(), "One".to_owned()]) + ); + assert_eq!( + imports.get("@radroots/many").expect("multi import package"), + &BTreeSet::from(["Three".to_owned(), "Two".to_owned()]) + ); + } + + #[test] + fn additional_type_imports_merge_with_generated_package_imports() { + let module = DtoTypesModule::new( + "import type { Decimal, Money } from \"@radroots/core-bindings\";\n\n", + "export type Listing = { discounts: Array<Discount>, };", + ); + let merged = with_additional_type_imports(module, CORE_BINDINGS_PACKAGE_NAME, ["Discount"]); + + assert_eq!( + merged.imports_ts(), + Some("import type { Decimal, Discount, Money } from \"@radroots/core-bindings\";\n\n") + ); + } + + #[test] + fn generated_type_export_inventory_parses_type_aliases() { + assert_eq!( + type_exports("export type Alpha = string;\nexport type Beta<T> = T;\n"), + BTreeSet::from(["Alpha".to_owned(), "Beta".to_owned()]) + ); + } + + #[test] + fn core_type_inventory_matches_current_package_surface() { + let actual = type_inventory(CORE_BINDINGS_TYPES_TS); + + assert_eq!(actual, CORE_TYPE_INVENTORY); + assert!(!CORE_BINDINGS_TYPES_TS.contains("RadrootsCore")); + } + + #[test] + fn event_type_inventory_matches_current_package_surface() { + let actual = type_inventory(EVENT_BINDINGS_TYPES_TS); + + assert_eq!(actual, EVENT_TYPE_INVENTORY); + assert!(!EVENT_BINDINGS_TYPES_TS.contains("RadrootsOrder")); + } + + #[test] + fn event_generated_types_expose_current_signed_event_dto_shapes() { + let wire = type_declaration(EVENT_BINDINGS_TYPES_TS, "RadrootsNip01EventWireDto"); + let envelope = type_declaration(EVENT_BINDINGS_TYPES_TS, "RadrootsEventEnvelopeDto"); + let signed = type_declaration(EVENT_BINDINGS_TYPES_TS, "RadrootsSignedEventDto"); + let verified = type_declaration(EVENT_BINDINGS_TYPES_TS, "RadrootsVerifiedSignedEventDto"); + + assert!(wire.contains("pubkey: string")); + assert!(wire.contains("extra: { [key: string]: unknown }")); + assert!(envelope.contains("author: string")); + assert!(!envelope.contains("pubkey")); + assert!(!envelope.contains("extra")); + assert!(signed.contains("state: RadrootsSignedEventVerificationStateDto")); + assert!(signed.contains("envelope: RadrootsEventEnvelopeDto")); + assert!(signed.contains("wire: RadrootsNip01EventWireDto")); + assert!(signed.contains("raw_json: string")); + assert!(verified.contains("state: RadrootsVerifiedSignedEventVerificationStateDto")); + assert!(verified.contains("signed_event: RadrootsSignedEventDto")); + assert!( + !EVENT_BINDINGS_TYPES_TS.contains("export type RadrootsEventEnvelope ="), + "event package must not export the retired raw envelope type name" + ); + } + + #[test] + fn trade_type_inventory_matches_current_package_surface() { + let actual = type_inventory(TRADE_BINDINGS_TYPES_TS); + + assert_eq!(actual, TRADE_TYPE_INVENTORY); + } + + #[test] + fn replica_schema_generated_types_preserve_source_schema_contracts() { + let actual = type_inventory(REPLICA_SCHEMA_BINDINGS_TYPES_TS); + let trade_product_filter = type_declaration( + REPLICA_SCHEMA_BINDINGS_TYPES_TS, + "ITradeProductFieldsFilter", + ); + let trade_product_partial = type_declaration( + REPLICA_SCHEMA_BINDINGS_TYPES_TS, + "ITradeProductFieldsPartial", + ); + + assert!(actual.contains(&"Farm")); + assert!(actual.contains(&"GcsLocation")); + assert!(actual.contains(&"NostrEventHead")); + assert!(actual.contains(&"ReplicaStoreJsonValue")); + assert!(actual.contains(&"ITradeProductFieldsPartial")); + assert!(!actual.contains(&"NostrEventState")); + assert!(REPLICA_SCHEMA_BINDINGS_TYPES_TS.contains( + "export type ReplicaStoreJsonValue = null | boolean | number | string | Array<ReplicaStoreJsonValue> | { [key: string]: ReplicaStoreJsonValue };" + )); + assert!( + REPLICA_SCHEMA_BINDINGS_TYPES_TS + .contains("export type IFarmFindOneResolve = ReplicaSchemaResult<Farm | null>;") + ); + assert!(actual.contains(&"ReplicaSchemaResult")); + assert!(actual.contains(&"ReplicaSchemaResultList")); + assert!(actual.contains(&"ReplicaSchemaResultPass")); + assert!(actual.contains(&"ReplicaSchemaError")); + assert!(trade_product_filter.contains("year?: bigint")); + assert!(trade_product_filter.contains("qty_avail?: bigint")); + assert!(trade_product_partial.contains("year?: ReplicaStoreJsonValue | null")); + assert!(trade_product_partial.contains("qty_avail?: ReplicaStoreJsonValue | null")); + } + + #[test] + fn replica_schema_generated_types_match_source_public_inventory() { + let actual = type_inventory(REPLICA_SCHEMA_BINDINGS_TYPES_TS) + .into_iter() + .collect::<BTreeSet<_>>(); + let missing = source_public_schema_type_inventory() + .into_iter() + .filter(|name| !actual.contains(name)) + .collect::<Vec<_>>(); + + assert!( + missing.is_empty(), + "missing generated replica schema exports: {missing:?}" + ); + } + + #[test] + fn trade_package_imports_source_owned_support_types() { + assert!(TRADE_BINDINGS_TYPES_TS.contains("from \"@radroots/core-bindings\"")); + assert!(TRADE_BINDINGS_TYPES_TS.contains("from \"@radroots/event-bindings\"")); + + let imports = imported_type_inventory(TRADE_BINDINGS_TYPES_TS); + assert!( + imports + .get("@radroots/event-bindings") + .is_some_and(|names| names.contains("RadrootsFarmRef")) + ); + assert!( + imports + .get("@radroots/event-bindings") + .is_some_and(|names| names.contains("RadrootsOperationalListing")) + ); + assert!( + imports + .get("@radroots/event-bindings") + .is_some_and(|names| names.contains("RadrootsOperationalListingBin")) + ); + + for duplicate in [ + "export type RadrootsOperationalListing = ", + "export type RadrootsFarmRef = ", + "export type RadrootsOrderEconomics = ", + "export type RadrootsOrderInventoryCommitment = ", + "export type RadrootsCommercialMessagePayload = ", + "export type RadrootsCommercialMessageType = ", + "export type RadrootsOrderStatus = ", + "export type RadrootsTradeOrderWorkflowMessage = ", + "export type RadrootsOrderWorkflowProjection = ", + "export type RadrootsTradeMarketplaceOrderSummary = ", + "export type RadrootsTradeOrderQuery = ", + ] { + assert!(!TRADE_BINDINGS_TYPES_TS.contains(duplicate)); + } + + let projection = type_declaration(TRADE_BINDINGS_TYPES_TS, "RadrootsTradeProjectionV1"); + assert!(projection.contains("agreement_state: RadrootsTradeAgreementStateV1")); + assert!(projection.contains("negotiation_state: RadrootsTradeNegotiationStateV1")); + assert!(projection.contains("agreement_claims: Array<RadrootsTradeAgreementClaimV1>")); + assert!(projection.contains("issues: Array<RadrootsTradeReducerIssueV1>")); + assert!(projection.contains("projection_digest: string")); + for stale_counter in [ + "root_event_id", + "last_message_type", + "last_discount_request", + "last_discount_offer", + "accepted_discount", + "last_discount_decline_reason", + "has_requested_discounts", + "question_count", + "answer_count", + "discount_request_count", + "discount_offer_count", + "discount_accept_count", + "discount_decline_count", + ] { + assert!(!projection.contains(stale_counter)); + } + } + + fn type_inventory(types_ts: &str) -> Vec<&str> { + types_ts + .lines() + .filter_map(|line| line.strip_prefix("export type ")) + .map(|rest| rest.split([' ', '<']).next().expect("type name")) + .collect() + } + + fn source_public_schema_type_inventory() -> Vec<&'static str> { + let mut names = BTreeSet::new(); + + for source in REPLICA_SCHEMA_MODEL_SOURCES { + for line in source.lines() { + if let Some(name) = public_rust_type_name(line) + && !name.ends_with("Ts") + { + names.insert(name); + } + } + } + + names.into_iter().collect() + } + + fn public_rust_type_name(line: &'static str) -> Option<&'static str> { + let line = line.trim_start(); + + ["pub struct ", "pub enum ", "pub type "] + .into_iter() + .find_map(|prefix| { + line.strip_prefix(prefix).map(|rest| { + rest.split(|char: char| !(char == '_' || char.is_ascii_alphanumeric())) + .next() + .expect("type name") + }) + }) + } + + fn type_declaration<'a>(types_ts: &'a str, name: &str) -> &'a str { + types_ts + .lines() + .find(|line| line.starts_with(&format!("export type {name} = "))) + .unwrap_or_else(|| panic!("missing type declaration for {name}")) + } +} diff --git a/tools/xtask/src/sdk_generation/fs.rs b/tools/xtask/src/sdk_generation/fs.rs @@ -0,0 +1,38 @@ +use std::{fs, path::Path}; + +use crate::build_control::Mode; + +pub fn write_if_changed(path: &Path, contents: &str) -> Result<bool, String> { + if let Ok(existing) = fs::read_to_string(path) + && existing == contents + { + return Ok(false); + } + crate::build_control::atomic_write(path, contents.as_bytes())?; + Ok(true) +} + +pub fn write_or_check(path: &Path, contents: &str, mode: Mode) -> Result<(), String> { + match mode { + Mode::Write => write_if_changed(path, contents).map(|_| ()), + Mode::Check => { + let current = fs::read_to_string(path) + .map_err(|error| format!("failed to read {}: {error}", path.display()))?; + if current == contents { + Ok(()) + } else { + Err(format!("stale generated SDK output: {}", path.display())) + } + } + } +} + +pub fn write_bytes_if_changed(path: &Path, contents: &[u8]) -> Result<bool, String> { + if let Ok(existing) = fs::read(path) + && existing == contents + { + return Ok(false); + } + crate::build_control::atomic_write_bytes(path, contents)?; + Ok(true) +} diff --git a/tools/xtask/src/sdk_generation/generate.rs b/tools/xtask/src/sdk_generation/generate.rs @@ -0,0 +1,92 @@ +use std::path::Path; + +use crate::build_control::Mode; +use crate::sdk_generation::{ + output::package_outputs, package_matrix::validate_package_matrix, package_metadata, +}; + +pub fn generate_all(source_root: &Path, consumer_root: &Path, mode: Mode) -> Result<(), String> { + generate_ts(consumer_root, mode)?; + generate_package_metadata(source_root, consumer_root, mode) +} + +pub fn generate_ts(consumer_root: &Path, mode: Mode) -> Result<(), String> { + validate_package_matrix()?; + for output in package_outputs()? { + for generated_file in output.files() { + let path = consumer_root + .join(output.spec.package_dir) + .join(generated_file.relative_path); + crate::sdk_generation::fs::write_or_check(&path, &generated_file.contents, mode)?; + } + let provenance_file = output.provenance_file(); + crate::sdk_generation::fs::write_or_check( + &consumer_root.join(&provenance_file.relative_path), + &provenance_file.contents, + mode, + )?; + println!("generated TypeScript package {}", output.spec.package_name); + } + Ok(()) +} + +pub fn generate_package_metadata( + source_root: &Path, + consumer_root: &Path, + mode: Mode, +) -> Result<(), String> { + validate_package_matrix()?; + package_metadata::generate_package_metadata(source_root, consumer_root, mode) +} + +#[cfg(test)] +mod tests { + use std::fs; + + use crate::{ + build_control::Mode, + sdk_generation::package_matrix::{package_specs, wasm_package_specs}, + }; + + use super::generate_all; + + #[test] + fn explicit_consumer_generation_round_trips_and_detects_drift() { + let source = tempfile::TempDir::new().expect("source fixture"); + let consumer = tempfile::TempDir::new().expect("consumer fixture"); + fs::write(source.path().join("LICENSE-MIT"), "MIT fixture\n").expect("MIT license"); + fs::write(source.path().join("LICENSE-APACHE"), "Apache fixture\n") + .expect("Apache license"); + for (package_name, package_dir) in package_specs() + .iter() + .map(|spec| (spec.package_name, spec.package_dir)) + .chain( + wasm_package_specs() + .iter() + .map(|spec| (spec.package_name, spec.package_dir)), + ) + { + let directory = consumer.path().join(package_dir); + fs::create_dir_all(&directory).expect("package directory"); + fs::write( + directory.join("package.json"), + format!( + "{{\n \"name\": \"{package_name}\",\n \"description\": \"Fixture package\"\n}}\n" + ), + ) + .expect("package manifest"); + } + + generate_all(source.path(), consumer.path(), Mode::Write).expect("write generation"); + generate_all(source.path(), consumer.path(), Mode::Check).expect("fresh generation"); + + let generated = consumer + .path() + .join(package_specs()[0].package_dir) + .join("src/generated/types.ts"); + fs::write(&generated, "drift\n").expect("drift generated output"); + let error = generate_all(source.path(), consumer.path(), Mode::Check) + .expect_err("drift must fail check mode"); + assert!(error.contains("stale generated SDK output")); + } +} diff --git a/tools/xtask/src/sdk_generation/manifest.rs b/tools/xtask/src/sdk_generation/manifest.rs @@ -0,0 +1,53 @@ +use serde_json::json; + +use crate::sdk_generation::package_matrix::PackageSpec; + +pub fn manifest_relative_path(spec: PackageSpec) -> String { + format!("contracts/provenance/typescript/{}.json", spec.key) +} + +pub fn package_manifest(spec: PackageSpec) -> serde_json::Value { + json!({ + "package": spec.package_name, + "crate": spec.crate_name, + "version": "0.1.0", + "license": "MIT OR Apache-2.0", + "repository": "https://github.com/radrootslabs/sdk", + "repository_directory": spec.package_dir, + "generator": "radroots_sdk_xtask", + "generated": false + }) +} + +#[cfg(test)] +mod tests { + use crate::sdk_generation::{ + manifest::{manifest_relative_path, package_manifest}, + package_matrix::package_specs, + }; + + #[test] + fn manifest_path_is_outside_package_source() { + assert_eq!( + manifest_relative_path(package_specs()[0]), + "contracts/provenance/typescript/core.json" + ); + } + + #[test] + fn manifest_records_package_and_crate() { + let manifest = package_manifest(package_specs()[0]); + assert_eq!(manifest["package"], package_specs()[0].package_name); + assert_eq!(manifest["crate"], package_specs()[0].crate_name); + assert_eq!(manifest["version"], "0.1.0"); + assert_eq!(manifest["license"], "MIT OR Apache-2.0"); + assert_eq!( + manifest["repository"], + "https://github.com/radrootslabs/sdk" + ); + assert_eq!( + manifest["repository_directory"], + package_specs()[0].package_dir + ); + } +} diff --git a/tools/xtask/src/sdk_generation/mod.rs b/tools/xtask/src/sdk_generation/mod.rs @@ -0,0 +1,44 @@ +mod bindings; +mod contracts; +mod dto_roots; +mod fs; +mod generate; +mod manifest; +mod output; +mod package_matrix; +mod package_metadata; +mod ts; +mod wasm; +mod wasm_declarations; + +use std::path::Path; + +use crate::build_control::Mode; + +pub fn artifact( + source_root: &Path, + consumer_root: &Path, + target: &str, + language: &str, + mode: Mode, +) -> Result<(), String> { + match (target, language) { + ("typescript", "typescript") => { + generate::generate_all(source_root, consumer_root, mode)?; + } + ("wasm", "javascript") => { + wasm::generate(source_root, consumer_root, &[], mode)?; + generate::generate_package_metadata(source_root, consumer_root, mode)?; + } + ("ffi", "swift" | "kotlin") => { + bindings::generate(source_root, consumer_root, language, mode)?; + bindings::check(source_root, consumer_root)?; + } + _ => { + return Err(format!( + "unsupported SDK artifact route {target}/{language}" + )); + } + } + contracts::validate_sdk_contracts(consumer_root) +} diff --git a/tools/xtask/src/sdk_generation/output.rs b/tools/xtask/src/sdk_generation/output.rs @@ -0,0 +1,494 @@ +use dto_bindgen_backend_ts::{DtoTypesModule, TypeScriptImport, TypeScriptModule}; +use sha2::{Digest, Sha256}; + +use crate::sdk_generation::{ + dto_roots, + manifest::manifest_relative_path, + manifest::package_manifest, + package_matrix::{PackageSpec, package_specs}, + ts::{generated_constants_file, generated_header, generated_kinds_file, generated_types_file}, +}; + +pub struct PackageOutput { + pub spec: PackageSpec, + pub types_ts: Option<TsSource>, + pub types_imports: Vec<TypeScriptImport>, + pub constants_ts: Option<TsSource>, + pub kinds_ts: Option<TsSource>, +} + +pub struct GeneratedFile { + pub relative_path: String, + pub contents: String, +} + +pub enum TsSource { + DtoRegistry(DtoTypesModule), + Module(TypeScriptModule), + CanonicalNativeSnapshot { contents: &'static str }, +} + +impl TsSource { + fn canonical_native_snapshot( + contents: &'static str, + sha256: &'static str, + source_packages: &'static [&'static str], + ) -> Result<Self, String> { + let actual = format!("{:x}", Sha256::digest(contents.as_bytes())); + if actual != sha256 { + return Err(format!( + "canonical native type snapshot drifted: expected {sha256}, found {actual}; regenerate from and review against {}", + source_packages.join(", ") + )); + } + Ok(Self::CanonicalNativeSnapshot { contents }) + } + + fn render(&self) -> String { + match self { + Self::DtoRegistry(module) => module.body_ts().to_owned(), + Self::Module(module) => module.render_source(), + Self::CanonicalNativeSnapshot { contents } => (*contents).to_owned(), + } + } + + fn imports(&self) -> Option<&str> { + match self { + Self::DtoRegistry(module) => module.imports_ts(), + Self::Module(_) | Self::CanonicalNativeSnapshot { .. } => None, + } + } +} + +const EVENT_BINDINGS_TYPES_TS: &str = + include_str!("../../../../crates/event_bindings/tests/fixtures/generated_types.ts"); +const EVENT_BINDINGS_TYPES_SHA256: &str = + "fe4496950852715c573ff2abc7df8edede7161de75d9c68c3f4d6836e67f829a"; +const TRADE_BINDINGS_TYPES_TS: &str = + include_str!("../../../../crates/trade_bindings/tests/fixtures/generated_types.ts"); +const TRADE_BINDINGS_TYPES_SHA256: &str = + "49560c572206095bd7de5cfd377c234a7f136e4affc13d53516ae2c4b8998e87"; +const EVENT_NATIVE_SOURCES: &[&str] = &["radroots_event", "radroots_core"]; +const TRADE_NATIVE_SOURCES: &[&str] = &["radroots_trade", "radroots_event", "radroots_core"]; + +impl PackageOutput { + pub fn files(&self) -> Vec<GeneratedFile> { + let mut files = Vec::new(); + if let Some(types_ts) = &self.types_ts { + let imports = combined_imports( + structured_imports_ts(&self.types_imports).as_deref(), + types_ts.imports(), + ); + files.push(GeneratedFile { + relative_path: format!("src/generated/{}", generated_types_file()), + contents: render_ts(types_ts, imports.as_deref()), + }); + } + if let Some(constants_ts) = &self.constants_ts { + files.push(GeneratedFile { + relative_path: format!("src/generated/{}", generated_constants_file()), + contents: render_ts(constants_ts, None), + }); + } + if let Some(kinds_ts) = &self.kinds_ts { + files.push(GeneratedFile { + relative_path: format!("src/generated/{}", generated_kinds_file()), + contents: render_ts(kinds_ts, None), + }); + } + files + } + + pub fn provenance_file(&self) -> GeneratedFile { + GeneratedFile { + relative_path: manifest_relative_path(self.spec), + contents: render_manifest(self), + } + } +} + +pub fn package_outputs() -> Result<Vec<PackageOutput>, String> { + Ok(vec![ + PackageOutput { + spec: spec_by_key("core"), + types_ts: Some(TsSource::DtoRegistry(dto_roots::core_types_module()?)), + types_imports: Vec::new(), + constants_ts: None, + kinds_ts: None, + }, + PackageOutput { + spec: spec_by_key("event"), + types_ts: Some(TsSource::canonical_native_snapshot( + EVENT_BINDINGS_TYPES_TS, + EVENT_BINDINGS_TYPES_SHA256, + EVENT_NATIVE_SOURCES, + )?), + types_imports: Vec::new(), + constants_ts: Some(TsSource::Module(radroots_event_bindings::constants_module())), + kinds_ts: Some(TsSource::Module(radroots_event_bindings::kinds_module())), + }, + PackageOutput { + spec: spec_by_key("identity"), + types_ts: None, + types_imports: Vec::new(), + constants_ts: Some(TsSource::Module( + radroots_identity_bindings::constants_module(), + )), + kinds_ts: None, + }, + PackageOutput { + spec: spec_by_key("replica_schema"), + types_ts: Some(TsSource::DtoRegistry( + dto_roots::replica_schema_types_module()?, + )), + types_imports: Vec::new(), + constants_ts: None, + kinds_ts: None, + }, + PackageOutput { + spec: spec_by_key("trade"), + types_ts: Some(TsSource::canonical_native_snapshot( + TRADE_BINDINGS_TYPES_TS, + TRADE_BINDINGS_TYPES_SHA256, + TRADE_NATIVE_SOURCES, + )?), + types_imports: Vec::new(), + constants_ts: None, + kinds_ts: None, + }, + ]) +} + +fn spec_by_key(key: &str) -> PackageSpec { + package_specs() + .iter() + .copied() + .find(|spec| spec.key == key) + .unwrap_or_else(|| panic!("missing package spec for {key}")) +} + +fn render_ts(source: &TsSource, imports: Option<&str>) -> String { + if matches!(source, TsSource::CanonicalNativeSnapshot { .. }) { + return source.render(); + } + let body = source.render(); + let imports = imports.unwrap_or(""); + let mut rendered = format!("{}{}{}", generated_header(), imports, body.trim_start()); + if !rendered.ends_with('\n') { + rendered.push('\n'); + } + rendered +} + +fn combined_imports(first: Option<&str>, second: Option<&str>) -> Option<String> { + match (first, second) { + (Some(first), Some(second)) => Some(format!("{first}{second}")), + (Some(first), None) => Some(first.to_owned()), + (None, Some(second)) => Some(second.to_owned()), + (None, None) => None, + } +} + +fn structured_imports_ts(imports: &[TypeScriptImport]) -> Option<String> { + if imports.is_empty() { + return None; + } + Some( + imports + .iter() + .cloned() + .fold(TypeScriptModule::new("types.ts"), |module, import| { + module.with_import(import) + }) + .render_source(), + ) +} + +fn render_manifest(output: &PackageOutput) -> String { + let spec = output.spec; + let mut value = package_manifest(spec); + value["generated"] = serde_json::Value::Bool(true); + value["outputs"] = serde_json::Value::Object( + output + .files() + .into_iter() + .map(|file| { + ( + file.relative_path, + serde_json::Value::String(format!( + "{:x}", + Sha256::digest(file.contents.as_bytes()) + )), + ) + }) + .collect(), + ); + if matches!(spec.key, "event" | "trade") { + let (sha256, source_packages) = match spec.key { + "event" => (EVENT_BINDINGS_TYPES_SHA256, EVENT_NATIVE_SOURCES), + "trade" => (TRADE_BINDINGS_TYPES_SHA256, TRADE_NATIVE_SOURCES), + _ => unreachable!(), + }; + value["types_source"] = + serde_json::Value::String("canonical_native_type_snapshot".to_owned()); + value["types_sha256"] = serde_json::Value::String(sha256.to_owned()); + value["types_source_packages"] = serde_json::Value::Array( + source_packages + .iter() + .map(|package| serde_json::Value::String((*package).to_owned())) + .collect(), + ); + } + format!( + "{}\n", + serde_json::to_string_pretty(&value).expect("manifest json serializes") + ) +} + +#[cfg(test)] +mod tests { + use super::{PackageOutput, TsSource, package_outputs, render_ts}; + use crate::sdk_generation::package_matrix::package_specs; + use dto_bindgen_backend_ts::{ + DtoTypesModule, TypeScriptDeclaration, TypeScriptImport, TypeScriptModule, TypeScriptType, + }; + + const TRADE_BINDINGS_TYPES_TS: &str = + include_str!("../../../../crates/trade_bindings/tests/fixtures/generated_types.ts"); + const REPLICA_SCHEMA_BINDINGS_TYPES_TS: &str = include_str!( + "../../../../crates/replica_schema_bindings/tests/fixtures/generated_types.ts" + ); + const EVENT_BINDINGS_CONSTANTS_TS: &str = + include_str!("../../../../crates/event_bindings/tests/fixtures/generated_constants.ts"); + const EVENT_BINDINGS_KINDS_TS: &str = + include_str!("../../../../crates/event_bindings/tests/fixtures/generated_kinds.ts"); + const IDENTITY_BINDINGS_CONSTANTS_TS: &str = + include_str!("../../../../crates/identity_bindings/tests/fixtures/generated_constants.ts"); + + #[test] + fn renders_sdk_header() { + let output = render_ts(&test_module(), None); + assert!(output.starts_with("// @generated by cargo xtask generate ts")); + assert!(output.contains("export type A = string;")); + } + + #[test] + fn renders_import_prelude_after_header() { + let output = render_ts(&test_module(), Some("import type { B } from \"b\";\n\n")); + assert!(output.starts_with( + "// @generated by cargo xtask generate ts\n// Do not edit by hand.\nimport type" + )); + assert!(output.contains("export type A = string;")); + } + + #[test] + fn renders_module_sources() { + let output = render_ts(&test_module(), None); + assert_eq!( + output, + "// @generated by cargo xtask generate ts\n// Do not edit by hand.\nexport type A = string;\n" + ); + } + + #[test] + fn canonical_native_snapshot_rejects_digest_drift() { + let error = match TsSource::canonical_native_snapshot( + "drifted snapshot\n", + super::EVENT_BINDINGS_TYPES_SHA256, + super::EVENT_NATIVE_SOURCES, + ) { + Ok(_) => panic!("digest drift must fail closed"), + Err(error) => error, + }; + + assert!(error.contains("canonical native type snapshot drifted")); + assert!(error.contains(super::EVENT_BINDINGS_TYPES_SHA256)); + assert!(error.contains("radroots_event")); + } + + #[test] + fn includes_core_and_schema_outputs() { + let package_names = package_outputs() + .expect("package outputs") + .into_iter() + .map(|output| output.spec.package_name) + .collect::<Vec<_>>(); + assert!(package_names.contains(&"@radroots/core-bindings")); + assert!(package_names.contains(&"@radroots/event-bindings")); + assert!(package_names.contains(&"@radroots/identity-bindings")); + assert!(package_names.contains(&"@radroots/replica-schema-bindings")); + assert!(package_names.contains(&"@radroots/trade-bindings")); + } + + #[test] + fn dto_registry_source_uses_generated_package_files() { + let output = PackageOutput { + spec: package_specs()[0], + types_ts: Some(TsSource::DtoRegistry(DtoTypesModule::new( + "import type { ExternalThing } from \"@radroots/external-bindings\";\n\n", + "export type SyntheticThing = { external: ExternalThing, };", + ))), + types_imports: vec![TypeScriptImport::type_only( + ["LocalPrelude"], + "@radroots/local", + )], + constants_ts: None, + kinds_ts: None, + }; + let files = output.files(); + let types = files + .iter() + .find(|file| file.relative_path == "src/generated/types.ts") + .expect("types file"); + let manifest = output.provenance_file(); + + assert_eq!( + types.contents, + "// @generated by cargo xtask generate ts\n// Do not edit by hand.\nimport type { LocalPrelude } from \"@radroots/local\";\nimport type { ExternalThing } from \"@radroots/external-bindings\";\n\nexport type SyntheticThing = { external: ExternalThing, };\n" + ); + assert_eq!( + manifest.relative_path, + "contracts/provenance/typescript/core.json" + ); + assert!(manifest.contents.contains("\"generated\": true")); + assert!( + !files + .iter() + .any(|file| file.relative_path == "src/index.ts") + ); + } + + #[test] + fn package_outputs_do_not_generate_package_indices() { + for output in package_outputs().expect("package outputs") { + assert!( + !output + .files() + .iter() + .any(|file| file.relative_path == "src/index.ts"), + "{} index must remain handwritten source", + output.spec.package_name + ); + } + } + + #[test] + fn trade_output_uses_canonical_native_snapshot_and_matches_checked_in_types() { + let output = package_outputs() + .expect("package outputs") + .into_iter() + .find(|output| output.spec.key == "trade") + .expect("trade output"); + + assert!(matches!( + output.types_ts, + Some(TsSource::CanonicalNativeSnapshot { .. }) + )); + assert!(output.types_imports.is_empty()); + + let types = output + .files() + .into_iter() + .find(|file| file.relative_path == "src/generated/types.ts") + .expect("types file"); + + assert_eq!(types.contents, TRADE_BINDINGS_TYPES_TS); + } + + #[test] + fn event_output_uses_canonical_native_snapshot_and_matches_checked_in_types() { + let output = package_outputs() + .expect("package outputs") + .into_iter() + .find(|output| output.spec.key == "event") + .expect("event output"); + + assert!(matches!( + output.types_ts, + Some(TsSource::CanonicalNativeSnapshot { .. }) + )); + let types = output + .files() + .into_iter() + .find(|file| file.relative_path == "src/generated/types.ts") + .expect("types file"); + assert_eq!(types.contents, super::EVENT_BINDINGS_TYPES_TS); + + let manifest = output.provenance_file(); + assert!(manifest.contents.contains("canonical_native_type_snapshot")); + assert!(manifest.contents.contains("radroots_event")); + assert!(manifest.contents.contains("radroots_core")); + } + + #[test] + fn replica_schema_output_uses_dto_registry_and_matches_checked_in_types() { + let output = package_outputs() + .expect("package outputs") + .into_iter() + .find(|output| output.spec.key == "replica_schema") + .expect("replica_schema output"); + + assert!(matches!(output.types_ts, Some(TsSource::DtoRegistry(_)))); + assert!(output.types_imports.is_empty()); + + let types = output + .files() + .into_iter() + .find(|file| file.relative_path == "src/generated/types.ts") + .expect("types file"); + + assert_eq!(types.contents, REPLICA_SCHEMA_BINDINGS_TYPES_TS); + } + + #[test] + fn events_constants_and_kinds_use_modules_and_match_checked_in_files() { + let output = package_outputs() + .expect("package outputs") + .into_iter() + .find(|output| output.spec.key == "event") + .expect("event output"); + + assert!(matches!(output.constants_ts, Some(TsSource::Module(_)))); + assert!(matches!(output.kinds_ts, Some(TsSource::Module(_)))); + + let files = output.files(); + let constants = files + .iter() + .find(|file| file.relative_path == "src/generated/constants.ts") + .expect("constants file"); + let kinds = files + .iter() + .find(|file| file.relative_path == "src/generated/kinds.ts") + .expect("kinds file"); + + assert_eq!(constants.contents, EVENT_BINDINGS_CONSTANTS_TS); + assert_eq!(kinds.contents, EVENT_BINDINGS_KINDS_TS); + } + + #[test] + fn identity_constants_use_module_and_match_checked_in_file() { + let output = package_outputs() + .expect("package outputs") + .into_iter() + .find(|output| output.spec.key == "identity") + .expect("identity output"); + + assert!(matches!(output.constants_ts, Some(TsSource::Module(_)))); + + let constants = output + .files() + .into_iter() + .find(|file| file.relative_path == "src/generated/constants.ts") + .expect("constants file"); + + assert_eq!(constants.contents, IDENTITY_BINDINGS_CONSTANTS_TS); + } + + fn test_module() -> TsSource { + TsSource::Module( + TypeScriptModule::new("src/generated/test.ts").with_declaration( + TypeScriptDeclaration::type_alias("A", TypeScriptType::String), + ), + ) + } +} diff --git a/tools/sdk_xtask_import/src/package_matrix.rs b/tools/xtask/src/sdk_generation/package_matrix.rs diff --git a/tools/xtask/src/sdk_generation/package_metadata.rs b/tools/xtask/src/sdk_generation/package_metadata.rs @@ -0,0 +1,170 @@ +use std::{fs, path::Path}; + +use crate::{ + build_control::Mode, + sdk_generation::{ + fs::{write_bytes_if_changed, write_or_check}, + package_matrix::{package_specs, wasm_package_specs}, + }, +}; + +pub(crate) const PACKAGE_README_FILE: &str = "README.md"; +pub(crate) const PACKAGE_LICENSE_FILES: [&str; 2] = ["LICENSE-MIT", "LICENSE-APACHE"]; +pub(crate) fn generate_package_metadata( + source_root: &Path, + consumer_root: &Path, + mode: Mode, +) -> Result<(), String> { + for spec in package_specs() { + write_package_metadata(source_root, &consumer_root.join(spec.package_dir), mode)?; + println!("generated package metadata {}", spec.package_name); + } + for spec in wasm_package_specs() { + write_package_metadata(source_root, &consumer_root.join(spec.package_dir), mode)?; + println!("generated package metadata {}", spec.package_name); + } + Ok(()) +} + +pub(crate) fn package_description<'a>( + json: &'a serde_json::Value, + package_json_path: &Path, +) -> Result<&'a str, String> { + let description = json + .get("description") + .and_then(serde_json::Value::as_str) + .ok_or_else(|| { + format!( + "package.json missing description: {}", + package_json_path.display() + ) + })?; + if description.trim().is_empty() || description.trim() != description { + return Err(format!( + "package.json description must be non-empty and trimmed: {}", + package_json_path.display() + )); + } + Ok(description) +} + +pub(crate) fn package_readme(package_name: &str, description: &str) -> String { + format!( + "# {package_name}\n\n{description}\n\nThis package publishes generated ESM JavaScript, TypeScript declarations, and any runtime artifacts from the Radroots SDK build pipeline. Runtime files are distributed from `dist/`; source and provenance metadata are kept outside the npm package payload.\n\n## License\n\nLicensed under either MIT or Apache-2.0, at your option. See `LICENSE-MIT` and `LICENSE-APACHE`.\n" + ) +} + +fn write_package_metadata( + source_root: &Path, + package_dir: &Path, + mode: Mode, +) -> Result<bool, String> { + let package_json_path = package_dir.join("package.json"); + let json = read_package_json(&package_json_path)?; + let package_name = package_name(&json, &package_json_path)?; + let description = package_description(&json, &package_json_path)?; + let readme_path = package_dir.join(PACKAGE_README_FILE); + let readme = package_readme(package_name, description); + let previous_readme = fs::read_to_string(&readme_path).ok(); + write_or_check(&readme_path, &readme, mode)?; + let mut changed = previous_readme.as_deref() != Some(readme.as_str()); + for file_name in PACKAGE_LICENSE_FILES { + let source_path = source_root.join(file_name); + let contents = fs::read(&source_path) + .map_err(|error| format!("failed to read {}: {error}", source_path.display()))?; + let destination = package_dir.join(file_name); + match mode { + Mode::Write => changed |= write_bytes_if_changed(&destination, &contents)?, + Mode::Check => { + let current = fs::read(&destination).map_err(|error| { + format!("failed to read {}: {error}", destination.display()) + })?; + if current != contents { + return Err(format!( + "stale package license metadata: {}", + destination.display() + )); + } + } + } + } + Ok(changed) +} + +fn read_package_json(path: &Path) -> Result<serde_json::Value, String> { + let raw = fs::read_to_string(path) + .map_err(|error| format!("failed to read {}: {error}", path.display()))?; + serde_json::from_str::<serde_json::Value>(&raw) + .map_err(|error| format!("failed to parse {}: {error}", path.display())) +} + +fn package_name<'a>( + json: &'a serde_json::Value, + package_json_path: &Path, +) -> Result<&'a str, String> { + json.get("name") + .and_then(serde_json::Value::as_str) + .ok_or_else(|| format!("package.json missing name: {}", package_json_path.display())) +} + +#[cfg(test)] +mod tests { + use std::{ + fs, + path::PathBuf, + time::{SystemTime, UNIX_EPOCH}, + }; + + use crate::build_control::Mode; + + use super::{package_readme, write_package_metadata}; + + #[test] + fn package_metadata_generation_is_deterministic() { + let root = test_root("metadata_generation"); + let package_dir = root.join("packages/example"); + fs::create_dir_all(&package_dir).expect("create package"); + fs::write(root.join("LICENSE-MIT"), "MIT license\n").expect("write MIT license"); + fs::write(root.join("LICENSE-APACHE"), "Apache license\n").expect("write Apache license"); + fs::write( + package_dir.join("package.json"), + r#"{ + "name": "@radroots/example", + "description": "Example package" +}"#, + ) + .expect("write package json"); + + assert!( + write_package_metadata(&root, &package_dir, Mode::Write).expect("first generation") + ); + assert_eq!( + fs::read_to_string(package_dir.join("README.md")).expect("read README"), + package_readme("@radroots/example", "Example package") + ); + assert_eq!( + fs::read_to_string(package_dir.join("LICENSE-MIT")).expect("read MIT"), + "MIT license\n" + ); + assert!( + !write_package_metadata(&root, &package_dir, Mode::Write).expect("second generation") + ); + write_package_metadata(&root, &package_dir, Mode::Check).expect("fresh metadata"); + + let _ = fs::remove_dir_all(root); + } + + fn test_root(name: &str) -> PathBuf { + let stamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system time after epoch") + .as_nanos(); + let root = std::env::temp_dir().join(format!( + "radroots_sdk_xtask_package_metadata_{name}_{}_{}", + std::process::id(), + stamp + )); + let _ = fs::remove_dir_all(&root); + root + } +} diff --git a/tools/sdk_xtask_import/src/ts.rs b/tools/xtask/src/sdk_generation/ts.rs diff --git a/tools/xtask/src/sdk_generation/wasm.rs b/tools/xtask/src/sdk_generation/wasm.rs @@ -0,0 +1,1246 @@ +use std::{ + collections::{BTreeMap, BTreeSet}, + env, + ffi::OsStr, + fs, io, + path::{Path, PathBuf}, + process::{Command, Stdio}, +}; + +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; + +use crate::{ + build_control::Mode, + sdk_generation::{ + package_matrix::{WasmPackageSpec, validate_package_matrix, wasm_package_specs}, + wasm_declarations::write_declaration_files, + }, +}; + +pub(crate) const WASM_TARGET: &str = "wasm32-unknown-unknown"; +const WASM_C_COMPILER_ENV: &str = "CC_wasm32_unknown_unknown"; +const WASM_CFLAGS_ENV: &str = "CFLAGS_wasm32_unknown_unknown"; +const WASM_C_TARGET_ARG: &str = "--target=wasm32-unknown-unknown"; + +pub fn generate( + source_root: &Path, + consumer_root: &Path, + args: &[String], + mode: Mode, +) -> Result<(), String> { + validate_package_matrix()?; + let specs = selected_specs(args)?; + let toolchain = resolve_wasm_toolchain(source_root)?; + println!( + "using Rust toolchain {} for {}: rustc={}, cargo={}", + toolchain.channel(), + WASM_TARGET, + toolchain.rustc().display(), + toolchain.cargo().display() + ); + let wasm_c_compiler = if specs + .iter() + .any(|spec| wasm_package_requires_c_compiler(*spec)) + { + let compiler = resolve_wasm_c_compiler()?; + println!( + "using WASM C compiler for {}: {}", + WASM_TARGET, + compiler.path.display() + ); + Some(compiler) + } else { + None + }; + for spec in specs { + let staging = tempfile::tempdir() + .map_err(|error| format!("failed to create WASM staging directory: {error}"))?; + let dist_dir = staging.path().join(spec.package_dir).join("dist"); + fs::create_dir_all(&dist_dir) + .map_err(|error| format!("failed to create {}: {error}", dist_dir.display()))?; + let mut command = Command::new(&toolchain.wasm_pack); + command.current_dir(source_root); + for arg in wasm_pack_args(spec) { + command.arg(arg); + } + command.arg("--out-dir").arg(&dist_dir); + toolchain.apply_to_command(&mut command); + if let Some(compiler) = wasm_c_compiler.as_ref() + && wasm_package_requires_c_compiler(spec) + { + compiler.apply_to_command(&mut command); + } + let status = command.status().map_err(|error| { + format!( + "failed to start wasm-pack for {} while generating {}: {error}", + spec.key, spec.package_name + ) + })?; + if !status.success() { + return Err(format!( + "wasm-pack failed for {} while generating {} with status {status}; rerun `cargo xtask generate wasm --package {}` after fixing the wasm toolchain", + spec.key, spec.package_name, spec.key + )); + } + remove_wasm_pack_gitignore(&dist_dir, spec)?; + write_declaration_files(staging.path(), spec)?; + write_provenance_manifest(source_root, consumer_root, staging.path(), spec)?; + validate_staged_outputs(&dist_dir, spec)?; + install_outputs(source_root, consumer_root, staging.path(), spec, mode)?; + println!("generated wasm package {}", spec.package_name); + } + Ok(()) +} + +#[derive(Debug, Deserialize, Eq, PartialEq, Serialize)] +struct WasmProvenanceManifest { + schema_version: u16, + package: String, + package_version: String, + rust_crate: String, + rust_crate_version: String, + target: String, + deterministic_codec: bool, + networking: bool, + signing: bool, + source_sha256: String, + outputs: BTreeMap<String, String>, +} + +fn write_provenance_manifest( + source_root: &Path, + consumer_root: &Path, + staged_root: &Path, + spec: WasmPackageSpec, +) -> Result<(), String> { + let manifest = provenance_manifest(source_root, consumer_root, staged_root, spec)?; + let dist_dir = staged_root.join(spec.package_dir).join("dist"); + let path = provenance_manifest_path(&dist_dir, spec); + let mut rendered = serde_json::to_string_pretty(&manifest) + .map_err(|error| format!("failed to render {}: {error}", path.display()))?; + rendered.push('\n'); + fs::write(&path, rendered) + .map_err(|error| format!("failed to write {}: {error}", path.display())) +} + +fn provenance_manifest( + source_root: &Path, + consumer_root: &Path, + output_root: &Path, + spec: WasmPackageSpec, +) -> Result<WasmProvenanceManifest, String> { + let package_version = + manifest_version(&consumer_root.join(spec.package_dir).join("package.json"))?; + let rust_crate_version = + manifest_version(&source_root.join(spec.crate_dir).join("Cargo.toml"))?; + let source_sha256 = source_tree_sha256(source_root, spec)?; + let dist_dir = output_root.join(spec.package_dir).join("dist"); + let mut outputs = BTreeMap::new(); + for relative in wasm_output_files(spec) { + let path = dist_dir.join(&relative); + let bytes = fs::read(&path) + .map_err(|error| format!("failed to read {}: {error}", path.display()))?; + outputs.insert(relative, format!("{:x}", Sha256::digest(bytes))); + } + Ok(WasmProvenanceManifest { + schema_version: 1, + package: spec.package_name.to_owned(), + package_version, + rust_crate: spec.crate_name.to_owned(), + rust_crate_version, + target: WASM_TARGET.to_owned(), + deterministic_codec: spec.key == "event_codec", + networking: false, + signing: false, + source_sha256, + outputs, + }) +} + +fn validate_provenance_manifest( + source_root: &Path, + consumer_root: &Path, + spec: WasmPackageSpec, +) -> Result<(), String> { + let path = provenance_manifest_path(&consumer_root.join(spec.package_dir).join("dist"), spec); + let raw = fs::read_to_string(&path) + .map_err(|error| format!("failed to read {}: {error}", path.display()))?; + let actual: WasmProvenanceManifest = serde_json::from_str(&raw) + .map_err(|error| format!("failed to parse {}: {error}", path.display()))?; + let expected = provenance_manifest(source_root, consumer_root, consumer_root, spec)?; + if actual != expected { + return Err(format!( + "stale or invalid WASM provenance manifest: {}", + path.display() + )); + } + Ok(()) +} + +fn install_outputs( + source_root: &Path, + consumer_root: &Path, + staged_root: &Path, + spec: WasmPackageSpec, + mode: Mode, +) -> Result<(), String> { + let staged_dist = staged_root.join(spec.package_dir).join("dist"); + let destination_dist = consumer_root.join(spec.package_dir).join("dist"); + for relative in wasm_output_files(spec) + .into_iter() + .chain([format!("{}.provenance.json", spec.out_name)]) + { + let staged = staged_dist.join(&relative); + let bytes = fs::read(&staged) + .map_err(|error| format!("failed to read {}: {error}", staged.display()))?; + let destination = destination_dist.join(relative); + match mode { + Mode::Write => crate::build_control::atomic_write_bytes(&destination, &bytes)?, + Mode::Check => { + let current = fs::read(&destination).map_err(|error| { + format!("failed to read {}: {error}", destination.display()) + })?; + if current != bytes { + return Err(format!( + "stale generated SDK WASM output: {}", + destination.display() + )); + } + } + } + } + validate_provenance_manifest(source_root, consumer_root, spec) +} + +fn validate_staged_outputs(dist_dir: &Path, spec: WasmPackageSpec) -> Result<(), String> { + let expected = wasm_output_files(spec) + .into_iter() + .chain([format!("{}.provenance.json", spec.out_name)]) + .collect::<BTreeSet<_>>(); + let actual = fs::read_dir(dist_dir) + .map_err(|error| format!("failed to read {}: {error}", dist_dir.display()))? + .map(|entry| { + entry + .map_err(|error| format!("failed to enumerate {}: {error}", dist_dir.display())) + .and_then(|entry| { + entry + .file_name() + .into_string() + .map_err(|_| format!("non-UTF-8 WASM output in {}", dist_dir.display())) + }) + }) + .collect::<Result<BTreeSet<_>, _>>()?; + if actual != expected { + return Err(format!( + "unexpected WASM output inventory for {}: expected {expected:?}, found {actual:?}", + spec.package_name + )); + } + Ok(()) +} + +fn manifest_version(path: &Path) -> Result<String, String> { + let raw = fs::read_to_string(path) + .map_err(|error| format!("failed to read {}: {error}", path.display()))?; + if path.extension() == Some(OsStr::new("json")) { + let value: serde_json::Value = serde_json::from_str(&raw) + .map_err(|error| format!("failed to parse {}: {error}", path.display()))?; + return value["version"] + .as_str() + .map(str::to_owned) + .ok_or_else(|| format!("{} must define string version", path.display())); + } + let value: toml::Value = raw + .parse() + .map_err(|error| format!("failed to parse {}: {error}", path.display()))?; + value["package"]["version"] + .as_str() + .map(str::to_owned) + .ok_or_else(|| format!("{} must define explicit package.version", path.display())) +} + +fn source_tree_sha256(root: &Path, spec: WasmPackageSpec) -> Result<String, String> { + let crate_dir = root.join(spec.crate_dir); + let mut files = vec![crate_dir.join("Cargo.toml")]; + collect_source_files(&crate_dir.join("src"), &mut files)?; + files.sort(); + let mut hasher = Sha256::new(); + for path in files { + let relative = path + .strip_prefix(root) + .map_err(|error| format!("failed to relativize {}: {error}", path.display()))?; + hasher.update(relative.to_string_lossy().as_bytes()); + hasher.update([0]); + hasher.update( + fs::read(&path) + .map_err(|error| format!("failed to read {}: {error}", path.display()))?, + ); + hasher.update([0]); + } + Ok(format!("{:x}", hasher.finalize())) +} + +fn collect_source_files(dir: &Path, files: &mut Vec<PathBuf>) -> Result<(), String> { + let mut entries = fs::read_dir(dir) + .map_err(|error| format!("failed to read {}: {error}", dir.display()))? + .collect::<Result<Vec<_>, _>>() + .map_err(|error| format!("failed to enumerate {}: {error}", dir.display()))?; + entries.sort_by_key(std::fs::DirEntry::file_name); + for entry in entries { + let path = entry.path(); + if path.is_dir() { + collect_source_files(&path, files)?; + } else if path.is_file() { + files.push(path); + } + } + Ok(()) +} + +fn wasm_output_files(spec: WasmPackageSpec) -> [String; 4] { + [ + format!("{}.js", spec.out_name), + format!("{}.d.ts", spec.out_name), + format!("{}_bg.wasm", spec.out_name), + format!("{}_bg.wasm.d.ts", spec.out_name), + ] +} + +fn provenance_manifest_path(dist_dir: &Path, spec: WasmPackageSpec) -> PathBuf { + dist_dir.join(format!("{}.provenance.json", spec.out_name)) +} + +struct WasmToolchain { + wasm_pack: PathBuf, + rust: ResolvedRustToolchain, +} + +impl WasmToolchain { + fn apply_to_command(&self, command: &mut Command) { + self.rust.apply_to_command(command); + } + + fn rustc(&self) -> &Path { + &self.rust.rustc + } + + fn cargo(&self) -> &Path { + &self.rust.cargo + } + + fn channel(&self) -> &str { + &self.rust.channel + } +} + +pub(crate) struct ResolvedRustToolchain { + pub(crate) channel: String, + pub(crate) rustc: PathBuf, + pub(crate) cargo: PathBuf, + pub(crate) bin_dir: PathBuf, +} + +impl ResolvedRustToolchain { + pub(crate) fn apply_to_command(&self, command: &mut Command) { + prepend_path(command, &self.bin_dir); + command.env("RUSTC", &self.rustc); + command.env("CARGO", &self.cargo); + command.env("RUSTUP_TOOLCHAIN", &self.channel); + } +} + +fn resolve_wasm_toolchain(root: &Path) -> Result<WasmToolchain, String> { + let wasm_pack = resolve_required_path_tool("wasm-pack")?; + let rust = resolve_rust_toolchain(root)?; + Ok(WasmToolchain { wasm_pack, rust }) +} + +pub(crate) fn resolve_rust_toolchain(root: &Path) -> Result<ResolvedRustToolchain, String> { + let toolchain_path = root.join("rust-toolchain.toml"); + let channel = read_rust_toolchain_channel(&toolchain_path)?; + let rustc = rustup_tool_for_channel("rustc", &channel)?; + let cargo = rustup_tool_for_channel("cargo", &channel)?; + let rustc_bin = rustc.parent().ok_or_else(|| { + format!( + "rustup resolved rustc for toolchain {channel} without a parent path: {}", + rustc.display() + ) + })?; + let cargo_bin = cargo.parent().ok_or_else(|| { + format!( + "rustup resolved cargo for toolchain {channel} without a parent path: {}", + cargo.display() + ) + })?; + if rustc_bin != cargo_bin { + return Err(format!( + "rustup resolved mismatched Rust tool paths for toolchain {channel}: rustc={}, cargo={}", + rustc.display(), + cargo.display() + )); + } + let bin_dir = rustc_bin.to_path_buf(); + ensure_wasm_target_installed(&channel)?; + Ok(ResolvedRustToolchain { + channel, + rustc, + cargo, + bin_dir, + }) +} + +fn wasm_pack_args(spec: WasmPackageSpec) -> Vec<&'static str> { + vec![ + "build", + spec.crate_dir, + "--release", + "--target", + "web", + "--out-name", + spec.out_name, + "--no-pack", + ] +} + +fn remove_wasm_pack_gitignore(dist_dir: &Path, spec: WasmPackageSpec) -> Result<(), String> { + let ignore_path = dist_dir.join(".gitignore"); + let contents = match fs::read_to_string(&ignore_path) { + Ok(contents) => contents, + Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(()), + Err(error) => { + return Err(format!( + "failed to read wasm-pack ignore file for {}: {}: {error}", + spec.package_name, + ignore_path.display() + )); + } + }; + if contents.trim() != "*" { + return Err(format!( + "unexpected wasm-pack ignore file for {}: {}; refusing to remove it", + spec.package_name, + ignore_path.display() + )); + } + fs::remove_file(&ignore_path).map_err(|error| { + format!( + "failed to remove wasm-pack ignore file for {}: {}: {error}", + spec.package_name, + ignore_path.display() + ) + }) +} + +fn selected_specs(args: &[String]) -> Result<Vec<WasmPackageSpec>, String> { + match args { + [] => Ok(wasm_package_specs().to_vec()), + [flag, key] if flag == "--package" => wasm_package_specs() + .iter() + .copied() + .find(|spec| spec.key == key) + .map(|spec| vec![spec]) + .ok_or_else(|| format!("unknown wasm package: {key}")), + _ => Err("usage: cargo xtask generate wasm [--package <key>]".to_owned()), + } +} + +fn wasm_package_requires_c_compiler(spec: WasmPackageSpec) -> bool { + spec.key == "event_codec" +} + +fn resolve_required_path_tool(name: &str) -> Result<PathBuf, String> { + let path = env::var_os("PATH").ok_or_else(|| { + format!("missing {name}: PATH is not set; install {name} and expose it on PATH") + })?; + resolve_path_tool_from_path(name, &path) +} + +fn resolve_path_tool_from_path(name: &str, path: &std::ffi::OsStr) -> Result<PathBuf, String> { + let matches = executable_matches(name, path); + match matches.as_slice() { + [] => Err(format!( + "missing {name}: install {name} and rerun `cargo xtask generate wasm`" + )), + [tool] => Ok(tool.clone()), + _ => Err(format!( + "ambiguous {name}: found {}; remove duplicate {name} entries from PATH before running `cargo xtask generate wasm`", + matches + .iter() + .map(|path| path.display().to_string()) + .collect::<Vec<_>>() + .join(", ") + )), + } +} + +fn executable_matches(name: &str, path: &std::ffi::OsStr) -> Vec<PathBuf> { + let mut seen = BTreeSet::new(); + let mut matches = Vec::new(); + for dir in env::split_paths(path) { + let candidate = dir.join(name); + if !is_executable_file(&candidate) { + continue; + } + let key = fs::canonicalize(&candidate).unwrap_or_else(|_| candidate.clone()); + if seen.insert(key) { + matches.push(candidate); + } + } + matches +} + +fn is_executable_file(path: &Path) -> bool { + if !path.is_file() { + return false; + } + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + path.metadata() + .map(|metadata| metadata.permissions().mode() & 0o111 != 0) + .unwrap_or(false) + } + #[cfg(not(unix))] + { + true + } +} + +fn read_rust_toolchain_channel(path: &Path) -> Result<String, String> { + let contents = fs::read_to_string(path) + .map_err(|error| format!("failed to read {}: {error}", path.display()))?; + rust_toolchain_channel_from(&contents) +} + +fn rust_toolchain_channel_from(contents: &str) -> Result<String, String> { + let value = contents + .parse::<toml::Value>() + .map_err(|error| format!("failed to parse rust-toolchain.toml: {error}"))?; + let channel = value + .get("toolchain") + .and_then(|toolchain| toolchain.get("channel")) + .and_then(toml::Value::as_str) + .ok_or_else(|| "rust-toolchain.toml must define toolchain.channel".to_owned())? + .trim(); + if channel.is_empty() { + return Err("rust-toolchain.toml toolchain.channel must not be empty".to_owned()); + } + Ok(channel.to_owned()) +} + +fn rustup_tool_for_channel(name: &str, channel: &str) -> Result<PathBuf, String> { + let output = Command::new("rustup") + .arg("which") + .arg("--toolchain") + .arg(channel) + .arg(name) + .output() + .map_err(|error| { + format!( + "failed to resolve {name} for Rust toolchain {channel} with rustup: {error}; install rustup toolchain {channel}" + ) + })?; + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(format!( + "failed to resolve {name} for Rust toolchain {channel}: {}; run `rustup toolchain install {channel}`", + stderr.trim() + )); + } + let path = String::from_utf8(output.stdout) + .map_err(|error| format!("rustup emitted non-UTF-8 {name} path: {error}"))?; + let trimmed = path.trim(); + if trimmed.is_empty() { + return Err(format!( + "rustup returned an empty {name} path for Rust toolchain {channel}" + )); + } + Ok(PathBuf::from(trimmed)) +} + +fn ensure_wasm_target_installed(channel: &str) -> Result<(), String> { + let output = Command::new("rustup") + .args(rustup_target_list_args(channel)) + .output() + .map_err(|error| { + format!( + "failed to verify {WASM_TARGET} target for Rust toolchain {channel} with rustup: {error}; install rustup toolchain {channel}" + ) + })?; + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(format!( + "failed to verify {WASM_TARGET} target for Rust toolchain {channel}: {}; run `rustup target add {WASM_TARGET} --toolchain {channel}`", + stderr.trim() + )); + } + let stdout = String::from_utf8_lossy(&output.stdout); + validate_target_list(&stdout, WASM_TARGET, channel) +} + +fn rustup_target_list_args(channel: &str) -> [&str; 5] { + ["target", "list", "--installed", "--toolchain", channel] +} + +fn validate_target_list(output: &str, target: &str, channel: &str) -> Result<(), String> { + if target_list_contains(output, target) { + Ok(()) + } else { + Err(format!( + "missing Rust target {target} for Rust toolchain {channel}: run `rustup target add {target} --toolchain {channel}`" + )) + } +} + +fn target_list_contains(output: &str, target: &str) -> bool { + output.lines().any(|line| line.trim() == target) +} + +#[derive(Debug)] +struct WasmCCompiler { + path: PathBuf, + cflags: ResolvedWasmCFlags, +} + +impl WasmCCompiler { + fn apply_to_command(&self, command: &mut Command) { + command.env(WASM_C_COMPILER_ENV, &self.path); + command.env(WASM_CFLAGS_ENV, &self.cflags.value); + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +struct ResolvedWasmCFlags { + value: String, + args: Vec<String>, +} + +fn resolve_wasm_c_compiler() -> Result<WasmCCompiler, String> { + let path = env::var_os("PATH").unwrap_or_default(); + let explicit = env::var_os(WASM_C_COMPILER_ENV); + let cflags = resolve_wasm_cflags(env::var_os(WASM_CFLAGS_ENV).as_deref())?; + let candidates = wasm_c_compiler_candidates(&path); + resolve_wasm_c_compiler_with( + explicit.as_deref(), + &path, + candidates, + cflags, + verify_wasm_c_compiler, + ) +} + +fn resolve_wasm_c_compiler_with<F>( + explicit: Option<&OsStr>, + path: &OsStr, + candidates: Vec<PathBuf>, + cflags: ResolvedWasmCFlags, + verify: F, +) -> Result<WasmCCompiler, String> +where + F: Fn(&Path, &ResolvedWasmCFlags) -> Result<(), String>, +{ + if let Some(explicit) = explicit { + let compiler = resolve_explicit_wasm_c_compiler(explicit, path)?; + verify(&compiler, &cflags).map_err(|error| { + format!( + "{WASM_C_COMPILER_ENV} does not name a WASM-capable C compiler: {}; {error}", + compiler.display() + ) + })?; + return Ok(WasmCCompiler { + path: compiler, + cflags, + }); + } + + let mut checked = Vec::new(); + for candidate in candidates { + if !is_executable_file(&candidate) { + continue; + } + checked.push(candidate.display().to_string()); + if verify(&candidate, &cflags).is_ok() { + return Ok(WasmCCompiler { + path: candidate, + cflags, + }); + } + } + let checked = if checked.is_empty() { + "none".to_owned() + } else { + checked.join(", ") + }; + Err(format!( + "missing suitable WASM C compiler for {WASM_TARGET}: set {WASM_C_COMPILER_ENV} to a clang-style compiler that accepts `{WASM_C_TARGET_ARG}`; checked candidates: {checked}" + )) +} + +fn resolve_wasm_cflags(value: Option<&OsStr>) -> Result<ResolvedWasmCFlags, String> { + let raw = match value { + Some(value) => Some( + value + .to_str() + .ok_or_else(|| format!("{WASM_CFLAGS_ENV} must be valid UTF-8 compiler flags"))?, + ), + None => None, + }; + resolve_wasm_cflags_from_str(raw) +} + +fn resolve_wasm_cflags_from_str(value: Option<&str>) -> Result<ResolvedWasmCFlags, String> { + let raw = value.unwrap_or(""); + let words = parse_cflags_words(raw)?; + let mut has_required_target = false; + for index in 0..words.len() { + let word = &words[index]; + if word == WASM_C_TARGET_ARG { + has_required_target = true; + } else if wasm_cflags_target_conflict(index, &words) { + return Err(format!( + "{WASM_CFLAGS_ENV} contains unsupported target flag `{}`; use `{WASM_C_TARGET_ARG}` or omit the target so xtask can prepend it", + wasm_cflags_target_display(index, &words) + )); + } + } + let value = if has_required_target { + raw.to_owned() + } else { + let trimmed = raw.trim(); + if trimmed.is_empty() { + WASM_C_TARGET_ARG.to_owned() + } else { + format!("{WASM_C_TARGET_ARG} {trimmed}") + } + }; + let args = parse_cflags_words(&value)?; + Ok(ResolvedWasmCFlags { value, args }) +} + +fn wasm_cflags_target_conflict(index: usize, words: &[String]) -> bool { + let word = &words[index]; + word == "--target" + || word == "-target" + || word.starts_with("--target=") + || word.starts_with("-target=") +} + +fn wasm_cflags_target_display(index: usize, words: &[String]) -> String { + let word = &words[index]; + if matches!(word.as_str(), "--target" | "-target") + && let Some(target) = words.get(index + 1) + { + return format!("{word} {target}"); + } + word.clone() +} + +fn parse_cflags_words(value: &str) -> Result<Vec<String>, String> { + let mut words = Vec::new(); + let mut current = String::new(); + let mut quote = CFlagsQuote::None; + let mut in_word = false; + let mut chars = value.chars(); + while let Some(character) = chars.next() { + match quote { + CFlagsQuote::None => match character { + character if character.is_whitespace() => { + if in_word { + words.push(std::mem::take(&mut current)); + in_word = false; + } + } + '\'' => { + in_word = true; + quote = CFlagsQuote::Single; + } + '"' => { + in_word = true; + quote = CFlagsQuote::Double; + } + '\\' => { + in_word = true; + let Some(escaped) = chars.next() else { + return Err(format!("{WASM_CFLAGS_ENV} ends with an unfinished escape")); + }; + current.push(escaped); + } + character => { + in_word = true; + current.push(character); + } + }, + CFlagsQuote::Single => { + if character == '\'' { + quote = CFlagsQuote::None; + } else { + current.push(character); + } + } + CFlagsQuote::Double => { + if character == '"' { + quote = CFlagsQuote::None; + } else if character == '\\' { + let Some(escaped) = chars.next() else { + return Err(format!("{WASM_CFLAGS_ENV} ends with an unfinished escape")); + }; + current.push(escaped); + } else { + current.push(character); + } + } + } + } + match quote { + CFlagsQuote::None => { + if in_word { + words.push(current); + } + Ok(words) + } + CFlagsQuote::Single => Err(format!( + "{WASM_CFLAGS_ENV} contains an unterminated ' quote" + )), + CFlagsQuote::Double => Err(format!( + "{WASM_CFLAGS_ENV} contains an unterminated \" quote" + )), + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum CFlagsQuote { + None, + Single, + Double, +} + +fn resolve_explicit_wasm_c_compiler(value: &OsStr, path: &OsStr) -> Result<PathBuf, String> { + if value.is_empty() { + return Err(format!( + "{WASM_C_COMPILER_ENV} is set but empty; set it to a compiler path that accepts `{WASM_C_TARGET_ARG}`" + )); + } + let candidate = PathBuf::from(value); + if candidate.components().count() > 1 { + if is_executable_file(&candidate) { + Ok(candidate) + } else { + Err(format!( + "{WASM_C_COMPILER_ENV} is not executable: {}", + candidate.display() + )) + } + } else { + first_executable_match(value, path).ok_or_else(|| { + format!( + "{WASM_C_COMPILER_ENV} command was not found on PATH: {}", + value.to_string_lossy() + ) + }) + } +} + +fn first_executable_match(name: &OsStr, path: &OsStr) -> Option<PathBuf> { + for dir in env::split_paths(path) { + let candidate = dir.join(name); + if is_executable_file(&candidate) { + return Some(candidate); + } + } + None +} + +fn wasm_c_compiler_candidates(path: &OsStr) -> Vec<PathBuf> { + let mut seen = BTreeSet::new(); + let mut candidates = Vec::new(); + for name in ["wasm32-unknown-unknown-clang", "clang"] { + for candidate in executable_matches(name, path) { + let key = fs::canonicalize(&candidate).unwrap_or_else(|_| candidate.clone()); + if seen.insert(key) { + candidates.push(candidate); + } + } + } + candidates +} + +fn verify_wasm_c_compiler(path: &Path, cflags: &ResolvedWasmCFlags) -> Result<(), String> { + let tempdir = tempfile::tempdir() + .map_err(|error| format!("failed to create C probe tempdir: {error}"))?; + let source_path = tempdir.path().join("wasm_probe.c"); + let object_path = tempdir.path().join("wasm_probe.o"); + fs::write( + &source_path, + "int radroots_wasm_probe(void) { return 0; }\n", + ) + .map_err(|error| format!("failed to write C compiler probe: {error}"))?; + let output = Command::new(path) + .args(&cflags.args) + .arg("-c") + .arg(&source_path) + .arg("-o") + .arg(&object_path) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .output() + .map_err(|error| format!("failed to run {}: {error}", path.display()))?; + if output.status.success() && object_path.is_file() { + return Ok(()); + } + let stderr = String::from_utf8_lossy(&output.stderr); + Err(format!( + "{} rejected `{WASM_CFLAGS_ENV}={}` for {}: {}", + path.display(), + cflags.value, + WASM_TARGET, + stderr.trim() + )) +} + +fn prepend_path(command: &mut Command, prefix: &Path) { + let existing = env::var_os("PATH").unwrap_or_default(); + let mut paths = vec![prefix.to_path_buf()]; + paths.extend(env::split_paths(&existing)); + if let Ok(joined) = env::join_paths(paths) { + command.env("PATH", joined); + } +} + +#[cfg(test)] +mod tests { + use std::{ + env, + ffi::OsStr, + fs, + path::{Path, PathBuf}, + process::Command, + time::{SystemTime, UNIX_EPOCH}, + }; + + use crate::build_control::Mode; + use crate::sdk_generation::package_matrix::wasm_package_specs; + + use super::{ + ResolvedWasmCFlags, WASM_C_COMPILER_ENV, WASM_C_TARGET_ARG, WASM_CFLAGS_ENV, WASM_TARGET, + WasmCCompiler, remove_wasm_pack_gitignore, resolve_path_tool_from_path, + resolve_wasm_c_compiler_with, resolve_wasm_cflags_from_str, rust_toolchain_channel_from, + rustup_target_list_args, selected_specs, target_list_contains, validate_target_list, + wasm_pack_args, wasm_package_requires_c_compiler, + }; + + #[test] + #[ignore = "requires wasm-pack, the pinned WASM target, and a WASM C compiler"] + fn canonical_wasm_generation_round_trips_through_an_explicit_consumer() { + let consumer = tempfile::TempDir::new().expect("consumer fixture"); + for spec in wasm_package_specs() { + let package_dir = consumer.path().join(spec.package_dir); + fs::create_dir_all(&package_dir).expect("package directory"); + fs::write( + package_dir.join("package.json"), + format!( + "{{\n \"name\": \"{}\",\n \"version\": \"0.1.0\"\n}}\n", + spec.package_name + ), + ) + .expect("package manifest"); + } + let source = crate::workspace_root(); + super::generate(&source, consumer.path(), &[], Mode::Write).expect("WASM generation"); + super::generate(&source, consumer.path(), &[], Mode::Check).expect("fresh WASM outputs"); + } + + #[test] + fn selects_all_specs_by_default() { + assert_eq!(selected_specs(&[]).expect("all specs").len(), 3); + } + + #[test] + fn selects_one_spec_by_key() { + let specs = selected_specs(&["--package".to_owned(), "replica_store".to_owned()]) + .expect("replica store spec"); + assert_eq!(specs[0].package_name, "@radroots/replica-store-wasm"); + } + + #[test] + fn rejects_unknown_spec_key() { + assert!(selected_specs(&["--package".to_owned(), "missing".to_owned()]).is_err()); + } + + #[test] + fn wasm_pack_arguments_disable_package_manifest_generation() { + let args = wasm_pack_args(wasm_package_specs()[0]); + assert!(args.contains(&"--no-pack")); + } + + #[test] + fn removes_wasm_pack_generated_gitignore() { + let root = test_root("wasm_pack_gitignore"); + let dist_dir = root.join("dist"); + fs::create_dir_all(&dist_dir).expect("create dist"); + fs::write(dist_dir.join(".gitignore"), "*\n").expect("write ignore"); + + remove_wasm_pack_gitignore(&dist_dir, wasm_package_specs()[0]).expect("remove ignore"); + + assert!(!dist_dir.join(".gitignore").exists()); + let _ = fs::remove_dir_all(root); + } + + #[test] + fn refuses_unexpected_wasm_pack_gitignore_contents() { + let root = test_root("custom_gitignore"); + let dist_dir = root.join("dist"); + fs::create_dir_all(&dist_dir).expect("create dist"); + fs::write(dist_dir.join(".gitignore"), "!keep\n").expect("write ignore"); + + let error = remove_wasm_pack_gitignore(&dist_dir, wasm_package_specs()[0]) + .expect_err("custom ignore rejected"); + + assert!(error.contains("unexpected wasm-pack ignore file")); + let _ = fs::remove_dir_all(root); + } + + #[test] + fn path_tool_resolution_reports_missing_tools() { + let error = resolve_path_tool_from_path("wasm-pack", std::ffi::OsStr::new("")) + .expect_err("missing"); + assert!(error.contains("missing wasm-pack")); + } + + #[test] + fn path_tool_resolution_reports_ambiguous_tools() { + let root = test_root("ambiguous_wasm_pack"); + let first = root.join("first"); + let second = root.join("second"); + fs::create_dir_all(&first).expect("create first dir"); + fs::create_dir_all(&second).expect("create second dir"); + write_executable(first.join("wasm-pack")); + write_executable(second.join("wasm-pack")); + let path = env::join_paths([first, second]).expect("join path"); + + let error = + resolve_path_tool_from_path("wasm-pack", &path).expect_err("ambiguous wasm-pack"); + + assert!(error.contains("ambiguous wasm-pack")); + let _ = fs::remove_dir_all(root); + } + + #[test] + fn target_list_parser_requires_exact_target() { + assert!(target_list_contains( + "aarch64-apple-darwin\nwasm32-unknown-unknown\n", + "wasm32-unknown-unknown" + )); + assert!(!target_list_contains( + "wasm32-unknown-emscripten\n", + "wasm32-unknown-unknown" + )); + } + + #[test] + fn rustup_target_list_args_are_bound_to_selected_toolchain() { + assert_eq!( + rustup_target_list_args("1.97.0"), + ["target", "list", "--installed", "--toolchain", "1.97.0"] + ); + } + + #[test] + fn missing_wasm_target_error_names_selected_toolchain() { + let error = validate_target_list("aarch64-apple-darwin\n", WASM_TARGET, "1.97.0") + .expect_err("missing target"); + + assert!(error.contains("wasm32-unknown-unknown")); + assert!(error.contains("--toolchain 1.97.0")); + } + + #[test] + fn parses_rust_toolchain_channel() { + let channel = rust_toolchain_channel_from( + r#"[toolchain] +channel = "1.97.0" +"#, + ) + .expect("channel"); + + assert_eq!(channel, "1.97.0"); + } + + #[test] + fn rejects_missing_wasm_c_compiler() { + let error = resolve_wasm_c_compiler_with( + None, + OsStr::new(""), + Vec::new(), + wasm_cflags(None), + |_, _| Ok(()), + ) + .expect_err("missing compiler"); + + assert!(error.contains(WASM_C_COMPILER_ENV)); + assert!(error.contains(WASM_C_TARGET_ARG)); + } + + #[test] + fn rejects_unsuitable_wasm_c_compiler_candidates() { + let root = test_root("unsuitable_wasm_c_compiler"); + fs::create_dir_all(&root).expect("create root"); + let compiler = root.join("clang"); + write_executable(compiler.clone()); + + let error = resolve_wasm_c_compiler_with( + None, + OsStr::new(""), + vec![compiler], + wasm_cflags(None), + |_, _| Err("target unsupported".to_owned()), + ) + .expect_err("unsuitable compiler"); + + assert!(error.contains("missing suitable WASM C compiler")); + assert!(error.contains(WASM_C_COMPILER_ENV)); + let _ = fs::remove_dir_all(root); + } + + #[test] + fn accepts_verified_wasm_c_compiler_candidate() { + let root = test_root("verified_wasm_c_compiler"); + fs::create_dir_all(&root).expect("create root"); + let compiler = root.join("clang"); + write_executable(compiler.clone()); + + let resolved = resolve_wasm_c_compiler_with( + None, + OsStr::new(""), + vec![compiler.clone()], + wasm_cflags(Some("-O2")), + |path: &Path, cflags: &ResolvedWasmCFlags| { + assert_eq!(path, compiler); + assert_eq!(cflags.value, format!("{WASM_C_TARGET_ARG} -O2")); + Ok(()) + }, + ) + .expect("compiler"); + + assert_eq!(resolved.path, compiler); + assert_eq!(resolved.cflags.value, format!("{WASM_C_TARGET_ARG} -O2")); + let _ = fs::remove_dir_all(root); + } + + #[test] + fn absent_wasm_cflags_resolve_to_required_target() { + let resolved = resolve_wasm_cflags_from_str(None).expect("cflags"); + + assert_eq!(resolved.value, WASM_C_TARGET_ARG); + assert_eq!(resolved.args, [WASM_C_TARGET_ARG.to_owned()]); + } + + #[test] + fn present_valid_wasm_cflags_preserve_additional_flags() { + let value = "-O2 --target=wasm32-unknown-unknown -fvisibility=hidden"; + let resolved = resolve_wasm_cflags_from_str(Some(value)).expect("cflags"); + + assert_eq!(resolved.value, value); + assert_eq!( + resolved.args, + [ + "-O2".to_owned(), + WASM_C_TARGET_ARG.to_owned(), + "-fvisibility=hidden".to_owned() + ] + ); + } + + #[test] + fn missing_target_wasm_cflags_prepend_required_target() { + let resolved = resolve_wasm_cflags_from_str(Some(" -O2 -fPIC ")).expect("target prepended"); + + assert_eq!(resolved.value, format!("{WASM_C_TARGET_ARG} -O2 -fPIC")); + assert_eq!( + resolved.args, + [ + WASM_C_TARGET_ARG.to_owned(), + "-O2".to_owned(), + "-fPIC".to_owned() + ] + ); + } + + #[test] + fn conflicting_target_wasm_cflags_are_rejected() { + let error = resolve_wasm_cflags_from_str(Some("--target=wasm32-wasip1 -O2")) + .expect_err("conflicting target"); + + assert!(error.contains(WASM_CFLAGS_ENV)); + assert!(error.contains("--target=wasm32-wasip1")); + assert!(error.contains(WASM_C_TARGET_ARG)); + } + + #[test] + fn separated_target_wasm_cflags_are_rejected() { + let error = resolve_wasm_cflags_from_str(Some("--target wasm32-unknown-unknown")) + .expect_err("separated target"); + + assert!(error.contains("--target wasm32-unknown-unknown")); + assert!(error.contains(WASM_C_TARGET_ARG)); + } + + #[test] + fn wasm_c_compiler_command_uses_resolved_cflags() { + let compiler = WasmCCompiler { + path: PathBuf::from("clang"), + cflags: wasm_cflags(Some("-O2")), + }; + let mut command = Command::new("wasm-pack"); + + compiler.apply_to_command(&mut command); + + assert_eq!( + command_env(&command, WASM_C_COMPILER_ENV), + Some(std::ffi::OsString::from("clang")) + ); + assert_eq!( + command_env(&command, WASM_CFLAGS_ENV), + Some(std::ffi::OsString::from(format!("{WASM_C_TARGET_ARG} -O2"))) + ); + } + + #[test] + fn event_codec_wasm_requires_c_compiler() { + assert!(wasm_package_requires_c_compiler(wasm_package_specs()[0])); + assert!(!wasm_package_requires_c_compiler(wasm_package_specs()[1])); + } + + fn wasm_cflags(value: Option<&str>) -> ResolvedWasmCFlags { + resolve_wasm_cflags_from_str(value).expect("resolved cflags") + } + + fn command_env(command: &Command, name: &str) -> Option<std::ffi::OsString> { + command.get_envs().find_map(|(key, value)| { + if key == OsStr::new(name) { + value.map(std::ffi::OsStr::to_os_string) + } else { + None + } + }) + } + + fn write_executable(path: PathBuf) { + fs::write(&path, "#!/bin/sh\n").expect("write executable"); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mut permissions = fs::metadata(&path).expect("metadata").permissions(); + permissions.set_mode(0o755); + fs::set_permissions(&path, permissions).expect("set executable permissions"); + } + } + + fn test_root(name: &str) -> PathBuf { + let stamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system time") + .as_nanos(); + env::temp_dir().join(format!("radroots_sdk_xtask_{name}_{stamp}")) + } +} diff --git a/tools/xtask/src/sdk_generation/wasm_declarations.rs b/tools/xtask/src/sdk_generation/wasm_declarations.rs @@ -0,0 +1,1150 @@ +use std::path::Path; + +use dto_bindgen_backend_ts::{ + TypeScriptDeclaration, TypeScriptModule, TypeScriptParameter, TypeScriptProperty, + TypeScriptType, TypeScriptValue, +}; + +use crate::sdk_generation::package_matrix::WasmPackageSpec; + +const WASM_DECLARATION_HEADER: &str = + "// @generated by cargo xtask generate wasm via dto_bindgen\n// Do not edit by hand.\n"; + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct WasmDeclarationFile { + pub relative_path: String, + pub contents: String, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +struct WasmDeclarationInventory { + public_functions: &'static [WasmPublicFunction], + support_exports: &'static [WasmSupportExport], +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +struct WasmPublicFunction { + name: &'static str, + parameter: Option<&'static str>, + return_kind: WasmReturnKind, + low_level: LowLevelSignature, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum WasmReturnKind { + String, + Any, + Void, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +struct LowLevelSignature { + parameters: usize, + returns: usize, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +struct WasmSupportExport { + name: &'static str, + export_type: WasmSupportExportType, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum WasmSupportExportType { + Memory, + Table, + Function(LowLevelSignature), + NumberFunction(usize), +} + +macro_rules! public_function { + ($name:literal, $parameter:expr, $return_kind:ident, $low_parameters:literal, $low_returns:literal) => { + WasmPublicFunction { + name: $name, + parameter: $parameter, + return_kind: WasmReturnKind::$return_kind, + low_level: LowLevelSignature { + parameters: $low_parameters, + returns: $low_returns, + }, + } + }; +} + +macro_rules! memory_export { + () => { + WasmSupportExport { + name: "memory", + export_type: WasmSupportExportType::Memory, + } + }; +} + +macro_rules! table_export { + ($name:literal) => { + WasmSupportExport { + name: $name, + export_type: WasmSupportExportType::Table, + } + }; +} + +macro_rules! void_function_export { + ($name:literal, $parameters:literal) => { + WasmSupportExport { + name: $name, + export_type: WasmSupportExportType::Function(LowLevelSignature { + parameters: $parameters, + returns: 0, + }), + } + }; +} + +macro_rules! number_function_export { + ($name:literal, $parameters:literal) => { + WasmSupportExport { + name: $name, + export_type: WasmSupportExportType::NumberFunction($parameters), + } + }; +} + +pub fn write_declaration_files(root: &Path, spec: WasmPackageSpec) -> Result<(), String> { + let package_dir = root.join(spec.package_dir); + for file in declaration_files(spec)? { + crate::sdk_generation::fs::write_if_changed( + &package_dir.join(&file.relative_path), + &file.contents, + )?; + } + Ok(()) +} + +pub fn declaration_files(spec: WasmPackageSpec) -> Result<Vec<WasmDeclarationFile>, String> { + let inventory = inventory(spec)?; + Ok(vec![ + WasmDeclarationFile { + relative_path: format!("dist/{}.d.ts", spec.out_name), + contents: render_declaration_module(primary_module(spec, inventory)), + }, + WasmDeclarationFile { + relative_path: format!("dist/{}_bg.wasm.d.ts", spec.out_name), + contents: render_declaration_module(sidecar_module(spec, inventory)), + }, + ]) +} + +fn inventory(spec: WasmPackageSpec) -> Result<WasmDeclarationInventory, String> { + match spec.key { + "event_codec" => Ok(WasmDeclarationInventory { + public_functions: EVENT_CODEC_FUNCTIONS, + support_exports: EVENT_CODEC_SUPPORT_EXPORTS, + }), + "replica_store" => Ok(WasmDeclarationInventory { + public_functions: REPLICA_STORE_FUNCTIONS, + support_exports: SQL_WASM_SUPPORT_EXPORTS, + }), + "replica_sync" => Ok(WasmDeclarationInventory { + public_functions: REPLICA_SYNC_FUNCTIONS, + support_exports: SQL_WASM_SUPPORT_EXPORTS, + }), + _ => Err(format!( + "missing wasm declaration inventory for {}", + spec.key + )), + } +} + +fn primary_module(spec: WasmPackageSpec, inventory: WasmDeclarationInventory) -> TypeScriptModule { + let mut module = TypeScriptModule::new(format!("dist/{}.d.ts", spec.out_name)); + for function in inventory.public_functions { + module.push_declaration(TypeScriptDeclaration::function( + function.name, + public_parameters(function.parameter), + public_return_type(function.return_kind), + )); + } + module.push_declaration(TypeScriptDeclaration::type_alias( + "InitInput", + init_input_type(), + )); + module.push_declaration(TypeScriptDeclaration::type_alias( + "InitOutput", + init_output_type(inventory), + )); + module.push_declaration(TypeScriptDeclaration::type_alias( + "SyncInitInput", + sync_init_input_type(), + )); + module.push_declaration(TypeScriptDeclaration::function( + "initSync", + vec![TypeScriptParameter::new( + "module", + TypeScriptType::union(vec![ + TypeScriptType::object(vec![TypeScriptProperty::new( + "module", + TypeScriptType::named("SyncInitInput"), + )]), + TypeScriptType::named("SyncInitInput"), + ]), + )], + TypeScriptType::named("InitOutput"), + )); + module.push_declaration(TypeScriptDeclaration::default_function( + "__wbg_init", + vec![ + TypeScriptParameter::new( + "module_or_path", + TypeScriptType::union(vec![ + TypeScriptType::object(vec![TypeScriptProperty::new( + "module_or_path", + TypeScriptType::union(vec![ + TypeScriptType::named("InitInput"), + promise_of(TypeScriptType::named("InitInput")), + ]), + )]), + TypeScriptType::named("InitInput"), + promise_of(TypeScriptType::named("InitInput")), + ]), + ) + .optional(), + ], + promise_of(TypeScriptType::named("InitOutput")), + )); + module +} + +fn sidecar_module(spec: WasmPackageSpec, inventory: WasmDeclarationInventory) -> TypeScriptModule { + let mut module = TypeScriptModule::new(format!("dist/{}_bg.wasm.d.ts", spec.out_name)); + for export in inventory.support_exports { + if matches!(export.export_type, WasmSupportExportType::Memory) { + module.push_declaration(support_export_declaration(*export)); + } + } + for function in inventory.public_functions { + module.push_declaration(TypeScriptDeclaration::constant( + function.name, + Some(low_level_function_type(function.low_level)), + TypeScriptValue::string(""), + )); + } + for export in inventory.support_exports { + if !matches!(export.export_type, WasmSupportExportType::Memory) { + module.push_declaration(support_export_declaration(*export)); + } + } + module +} + +fn render_declaration_module(module: TypeScriptModule) -> String { + let mut rendered = String::from(WASM_DECLARATION_HEADER); + rendered.push_str(&module.render_declaration()); + rendered +} + +fn public_parameters(parameter: Option<&'static str>) -> Vec<TypeScriptParameter> { + parameter + .map(|name| vec![TypeScriptParameter::new(name, TypeScriptType::String)]) + .unwrap_or_default() +} + +fn public_return_type(kind: WasmReturnKind) -> TypeScriptType { + match kind { + WasmReturnKind::String => TypeScriptType::String, + WasmReturnKind::Any => TypeScriptType::Any, + WasmReturnKind::Void => TypeScriptType::Void, + } +} + +fn init_input_type() -> TypeScriptType { + TypeScriptType::union(vec![ + TypeScriptType::named("RequestInfo"), + TypeScriptType::named("URL"), + TypeScriptType::named("Response"), + TypeScriptType::named("BufferSource"), + TypeScriptType::named("WebAssembly.Module"), + ]) +} + +fn sync_init_input_type() -> TypeScriptType { + TypeScriptType::union(vec![ + TypeScriptType::named("BufferSource"), + TypeScriptType::named("WebAssembly.Module"), + ]) +} + +fn init_output_type(inventory: WasmDeclarationInventory) -> TypeScriptType { + let mut properties = vec![TypeScriptProperty::new("memory", memory_type()).readonly()]; + properties.extend(inventory.public_functions.iter().map(|function| { + TypeScriptProperty::new(function.name, low_level_function_type(function.low_level)) + .readonly() + })); + properties.extend( + inventory + .support_exports + .iter() + .filter(|export| !matches!(export.export_type, WasmSupportExportType::Memory)) + .map(|export| { + TypeScriptProperty::new(export.name, support_export_type(*export)).readonly() + }), + ); + TypeScriptType::object(properties) +} + +fn support_export_declaration(export: WasmSupportExport) -> TypeScriptDeclaration { + TypeScriptDeclaration::constant( + export.name, + Some(support_export_type(export)), + TypeScriptValue::string(""), + ) +} + +fn support_export_type(export: WasmSupportExport) -> TypeScriptType { + match export.export_type { + WasmSupportExportType::Memory => memory_type(), + WasmSupportExportType::Table => TypeScriptType::named("WebAssembly.Table"), + WasmSupportExportType::Function(signature) => low_level_function_type(signature), + WasmSupportExportType::NumberFunction(parameters) => { + low_level_number_function_type(parameters) + } + } +} + +fn memory_type() -> TypeScriptType { + TypeScriptType::named("WebAssembly.Memory") +} + +fn low_level_number_function_type(parameters: usize) -> TypeScriptType { + TypeScriptType::function(number_parameters(parameters), TypeScriptType::Number) +} + +fn low_level_function_type(signature: LowLevelSignature) -> TypeScriptType { + TypeScriptType::function( + number_parameters(signature.parameters), + low_level_return_type(signature.returns), + ) +} + +fn number_parameters(count: usize) -> Vec<TypeScriptParameter> { + (0..count) + .map(|index| { + TypeScriptParameter::new(low_level_parameter_name(index), TypeScriptType::Number) + }) + .collect() +} + +fn low_level_parameter_name(index: usize) -> String { + match index { + 0 => "a".to_owned(), + 1 => "b".to_owned(), + 2 => "c".to_owned(), + 3 => "d".to_owned(), + _ => format!("arg{index}"), + } +} + +fn low_level_return_type(count: usize) -> TypeScriptType { + match count { + 0 => TypeScriptType::Void, + 1 => TypeScriptType::Number, + _ => TypeScriptType::tuple(vec![TypeScriptType::Number; count]), + } +} + +fn promise_of(type_expr: TypeScriptType) -> TypeScriptType { + TypeScriptType::generic("Promise", vec![type_expr]) +} + +const EVENT_CODEC_FUNCTIONS: &[WasmPublicFunction] = &[ + public_function!("article_tags", Some("article_json"), String, 2, 4), + public_function!("coop_tags", Some("coop_json"), String, 2, 4), + public_function!("document_tags", Some("document_json"), String, 2, 4), + public_function!("farm_crdt_change_tags", Some("input_json"), String, 2, 4), + public_function!("farm_file_metadata_tags", Some("file_json"), String, 2, 4), + public_function!("farm_tags", Some("farm_json"), String, 2, 4), + public_function!( + "farm_workspace_manifest_tags", + Some("workspace_json"), + String, + 2, + 4 + ), + public_function!("file_metadata_tags", Some("metadata_json"), String, 2, 4), + public_function!("follow_tags", Some("follow_json"), String, 2, 4), + public_function!("generic_repost_tags", Some("repost_json"), String, 2, 4), + public_function!("gift_wrap_tags", Some("gift_wrap_json"), String, 2, 4), + public_function!("group_admins_tags", Some("group_json"), String, 2, 4), + public_function!("group_create_group_tags", Some("group_json"), String, 2, 4), + public_function!("group_create_invite_tags", Some("group_json"), String, 2, 4), + public_function!("group_delete_event_tags", Some("group_json"), String, 2, 4), + public_function!("group_delete_group_tags", Some("group_json"), String, 2, 4), + public_function!("group_edit_metadata_tags", Some("group_json"), String, 2, 4), + public_function!("group_join_request_tags", Some("group_json"), String, 2, 4), + public_function!("group_leave_request_tags", Some("group_json"), String, 2, 4), + public_function!("group_members_tags", Some("group_json"), String, 2, 4), + public_function!("group_metadata_tags", Some("group_json"), String, 2, 4), + public_function!("group_put_user_tags", Some("group_json"), String, 2, 4), + public_function!("group_remove_user_tags", Some("group_json"), String, 2, 4), + public_function!("group_roles_tags", Some("group_json"), String, 2, 4), + public_function!("http_auth_tags", Some("auth_json"), String, 2, 4), + public_function!("job_feedback_tags", Some("job_json"), String, 2, 4), + public_function!("job_request_tags", Some("job_json"), String, 2, 4), + public_function!("job_result_tags", Some("job_json"), String, 2, 4), + public_function!("knowledge_claim_tags", Some("claim_json"), String, 2, 4), + public_function!( + "knowledge_field_report_tags", + Some("report_json"), + String, + 2, + 4 + ), + public_function!( + "knowledge_relation_tags", + Some("relation_json"), + String, + 2, + 4 + ), + public_function!("knowledge_review_tags", Some("review_json"), String, 2, 4), + public_function!("knowledge_source_tags", Some("source_json"), String, 2, 4), + public_function!("list_set_tags", Some("list_json"), String, 2, 4), + public_function!("list_tags", Some("list_json"), String, 2, 4), + public_function!( + "operational_listing_tags", + Some("listing_json"), + String, + 2, + 4 + ), + public_function!( + "operational_listing_tags_full", + Some("listing_json"), + String, + 2, + 4 + ), + public_function!("message_file_tags", Some("message_json"), String, 2, 4), + public_function!("message_tags", Some("message_json"), String, 2, 4), + public_function!("plot_tags", Some("plot_json"), String, 2, 4), + public_function!("reaction_tags", Some("reaction_json"), String, 2, 4), + public_function!("relay_auth_tags", Some("auth_json"), String, 2, 4), + public_function!("report_tags", Some("report_json"), String, 2, 4), + public_function!("repost_tags", Some("repost_json"), String, 2, 4), + public_function!("seal_tags", Some("seal_json"), String, 2, 4), + public_function!( + "social_ask_build_authored_draft", + Some("input_json"), + String, + 2, + 4 + ), + public_function!( + "social_comment_build_authored_draft", + Some("input_json"), + String, + 2, + 4 + ), + public_function!( + "social_photo_update_build_authored_draft", + Some("input_json"), + String, + 2, + 4 + ), + public_function!( + "social_update_build_authored_draft", + Some("input_json"), + String, + 2, + 4 + ), + public_function!("wiki_article_tags", Some("article_json"), String, 2, 4), + public_function!( + "wiki_merge_request_tags", + Some("request_json"), + String, + 2, + 4 + ), + public_function!("wiki_redirect_tags", Some("redirect_json"), String, 2, 4), + public_function!( + "verify_and_decode_event_json", + Some("event_json"), + String, + 2, + 4 + ), + public_function!("contract_manifest_json", None, String, 0, 4), +]; + +const EVENT_CODEC_SUPPORT_EXPORTS: &[WasmSupportExport] = &[ + memory_export!(), + table_export!("__wbindgen_externrefs"), + number_function_export!("__wbindgen_malloc", 2), + number_function_export!("__wbindgen_realloc", 4), + void_function_export!("__externref_table_dealloc", 1), + void_function_export!("__wbindgen_free", 3), + void_function_export!("__wbindgen_start", 0), +]; + +const REPLICA_STORE_FUNCTIONS: &[WasmPublicFunction] = &[ + public_function!("replica_store_export_begin", None, Any, 0, 3), + public_function!("replica_store_export_finish", None, Void, 0, 2), + public_function!("replica_store_export_json", None, Any, 0, 3), + public_function!("replica_store_farm_create", Some("opts_json"), Any, 2, 3), + public_function!("replica_store_farm_delete", Some("opts_json"), Any, 2, 3), + public_function!("replica_store_farm_find_many", Some("opts_json"), Any, 2, 3), + public_function!("replica_store_farm_find_one", Some("opts_json"), Any, 2, 3), + public_function!("replica_store_farm_update", Some("opts_json"), Any, 2, 3), + public_function!( + "replica_store_farm_gcs_location_create", + Some("opts_json"), + Any, + 2, + 3 + ), + public_function!( + "replica_store_farm_gcs_location_delete", + Some("opts_json"), + Any, + 2, + 3 + ), + public_function!( + "replica_store_farm_gcs_location_find_many", + Some("opts_json"), + Any, + 2, + 3 + ), + public_function!( + "replica_store_farm_gcs_location_find_one", + Some("opts_json"), + Any, + 2, + 3 + ), + public_function!( + "replica_store_farm_gcs_location_update", + Some("opts_json"), + Any, + 2, + 3 + ), + public_function!( + "replica_store_farm_member_claim_create", + Some("opts_json"), + Any, + 2, + 3 + ), + public_function!( + "replica_store_farm_member_claim_delete", + Some("opts_json"), + Any, + 2, + 3 + ), + public_function!( + "replica_store_farm_member_claim_find_many", + Some("opts_json"), + Any, + 2, + 3 + ), + public_function!( + "replica_store_farm_member_claim_find_one", + Some("opts_json"), + Any, + 2, + 3 + ), + public_function!( + "replica_store_farm_member_claim_update", + Some("opts_json"), + Any, + 2, + 3 + ), + public_function!( + "replica_store_farm_member_create", + Some("opts_json"), + Any, + 2, + 3 + ), + public_function!( + "replica_store_farm_member_delete", + Some("opts_json"), + Any, + 2, + 3 + ), + public_function!( + "replica_store_farm_member_find_many", + Some("opts_json"), + Any, + 2, + 3 + ), + public_function!( + "replica_store_farm_member_find_one", + Some("opts_json"), + Any, + 2, + 3 + ), + public_function!( + "replica_store_farm_member_update", + Some("opts_json"), + Any, + 2, + 3 + ), + public_function!( + "replica_store_farm_tag_create", + Some("opts_json"), + Any, + 2, + 3 + ), + public_function!( + "replica_store_farm_tag_delete", + Some("opts_json"), + Any, + 2, + 3 + ), + public_function!( + "replica_store_farm_tag_find_many", + Some("opts_json"), + Any, + 2, + 3 + ), + public_function!( + "replica_store_farm_tag_find_one", + Some("opts_json"), + Any, + 2, + 3 + ), + public_function!( + "replica_store_farm_tag_update", + Some("opts_json"), + Any, + 2, + 3 + ), + public_function!( + "replica_store_gcs_location_create", + Some("opts_json"), + Any, + 2, + 3 + ), + public_function!( + "replica_store_gcs_location_delete", + Some("opts_json"), + Any, + 2, + 3 + ), + public_function!( + "replica_store_gcs_location_find_many", + Some("opts_json"), + Any, + 2, + 3 + ), + public_function!( + "replica_store_gcs_location_find_one", + Some("opts_json"), + Any, + 2, + 3 + ), + public_function!( + "replica_store_gcs_location_update", + Some("opts_json"), + Any, + 2, + 3 + ), + public_function!("replica_store_import_json", Some("dump_json"), Void, 2, 2), + public_function!( + "replica_store_log_error_create", + Some("opts_json"), + Any, + 2, + 3 + ), + public_function!( + "replica_store_log_error_delete", + Some("opts_json"), + Any, + 2, + 3 + ), + public_function!( + "replica_store_log_error_find_many", + Some("opts_json"), + Any, + 2, + 3 + ), + public_function!( + "replica_store_log_error_find_one", + Some("opts_json"), + Any, + 2, + 3 + ), + public_function!( + "replica_store_log_error_update", + Some("opts_json"), + Any, + 2, + 3 + ), + public_function!( + "replica_store_media_image_create", + Some("opts_json"), + Any, + 2, + 3 + ), + public_function!( + "replica_store_media_image_delete", + Some("opts_json"), + Any, + 2, + 3 + ), + public_function!( + "replica_store_media_image_find_many", + Some("opts_json"), + Any, + 2, + 3 + ), + public_function!( + "replica_store_media_image_find_one", + Some("opts_json"), + Any, + 2, + 3 + ), + public_function!( + "replica_store_media_image_update", + Some("opts_json"), + Any, + 2, + 3 + ), + public_function!( + "replica_store_nostr_event_head_create", + Some("opts_json"), + Any, + 2, + 3 + ), + public_function!( + "replica_store_nostr_event_head_delete", + Some("opts_json"), + Any, + 2, + 3 + ), + public_function!( + "replica_store_nostr_event_head_find_many", + Some("opts_json"), + Any, + 2, + 3 + ), + public_function!( + "replica_store_nostr_event_head_find_one", + Some("opts_json"), + Any, + 2, + 3 + ), + public_function!( + "replica_store_nostr_event_head_update", + Some("opts_json"), + Any, + 2, + 3 + ), + public_function!( + "replica_store_nostr_profile_create", + Some("opts_json"), + Any, + 2, + 3 + ), + public_function!( + "replica_store_nostr_profile_delete", + Some("opts_json"), + Any, + 2, + 3 + ), + public_function!( + "replica_store_nostr_profile_find_many", + Some("opts_json"), + Any, + 2, + 3 + ), + public_function!( + "replica_store_nostr_profile_find_one", + Some("opts_json"), + Any, + 2, + 3 + ), + public_function!( + "replica_store_nostr_profile_relay_set", + Some("opts_json"), + Any, + 2, + 3 + ), + public_function!( + "replica_store_nostr_profile_relay_unset", + Some("opts_json"), + Any, + 2, + 3 + ), + public_function!( + "replica_store_nostr_profile_update", + Some("opts_json"), + Any, + 2, + 3 + ), + public_function!( + "replica_store_nostr_relay_create", + Some("opts_json"), + Any, + 2, + 3 + ), + public_function!( + "replica_store_nostr_relay_delete", + Some("opts_json"), + Any, + 2, + 3 + ), + public_function!( + "replica_store_nostr_relay_find_many", + Some("opts_json"), + Any, + 2, + 3 + ), + public_function!( + "replica_store_nostr_relay_find_one", + Some("opts_json"), + Any, + 2, + 3 + ), + public_function!( + "replica_store_nostr_relay_update", + Some("opts_json"), + Any, + 2, + 3 + ), + public_function!("replica_store_plot_create", Some("opts_json"), Any, 2, 3), + public_function!("replica_store_plot_delete", Some("opts_json"), Any, 2, 3), + public_function!("replica_store_plot_find_many", Some("opts_json"), Any, 2, 3), + public_function!("replica_store_plot_find_one", Some("opts_json"), Any, 2, 3), + public_function!("replica_store_plot_update", Some("opts_json"), Any, 2, 3), + public_function!( + "replica_store_plot_gcs_location_create", + Some("opts_json"), + Any, + 2, + 3 + ), + public_function!( + "replica_store_plot_gcs_location_delete", + Some("opts_json"), + Any, + 2, + 3 + ), + public_function!( + "replica_store_plot_gcs_location_find_many", + Some("opts_json"), + Any, + 2, + 3 + ), + public_function!( + "replica_store_plot_gcs_location_find_one", + Some("opts_json"), + Any, + 2, + 3 + ), + public_function!( + "replica_store_plot_gcs_location_update", + Some("opts_json"), + Any, + 2, + 3 + ), + public_function!( + "replica_store_plot_tag_create", + Some("opts_json"), + Any, + 2, + 3 + ), + public_function!( + "replica_store_plot_tag_delete", + Some("opts_json"), + Any, + 2, + 3 + ), + public_function!( + "replica_store_plot_tag_find_many", + Some("opts_json"), + Any, + 2, + 3 + ), + public_function!( + "replica_store_plot_tag_find_one", + Some("opts_json"), + Any, + 2, + 3 + ), + public_function!( + "replica_store_plot_tag_update", + Some("opts_json"), + Any, + 2, + 3 + ), + public_function!("replica_store_reset_database", None, Void, 0, 2), + public_function!("replica_store_run_migrations", None, Void, 0, 2), + public_function!( + "replica_store_trade_product_create", + Some("opts_json"), + Any, + 2, + 3 + ), + public_function!( + "replica_store_trade_product_delete", + Some("opts_json"), + Any, + 2, + 3 + ), + public_function!( + "replica_store_trade_product_find_many", + Some("opts_json"), + Any, + 2, + 3 + ), + public_function!( + "replica_store_trade_product_find_one", + Some("opts_json"), + Any, + 2, + 3 + ), + public_function!( + "replica_store_trade_product_location_set", + Some("opts_json"), + Any, + 2, + 3 + ), + public_function!( + "replica_store_trade_product_location_unset", + Some("opts_json"), + Any, + 2, + 3 + ), + public_function!( + "replica_store_trade_product_media_set", + Some("opts_json"), + Any, + 2, + 3 + ), + public_function!( + "replica_store_trade_product_media_unset", + Some("opts_json"), + Any, + 2, + 3 + ), + public_function!( + "replica_store_trade_product_update", + Some("opts_json"), + Any, + 2, + 3 + ), +]; + +const REPLICA_SYNC_FUNCTIONS: &[WasmPublicFunction] = &[ + public_function!("replica_sync_ingest_event", Some("event_json"), Any, 2, 3), + public_function!("replica_sync_sync_all", Some("request_json"), Any, 2, 3), +]; + +const SQL_WASM_SUPPORT_EXPORTS: &[WasmSupportExport] = &[ + memory_export!(), + number_function_export!("__wbindgen_malloc", 2), + number_function_export!("__wbindgen_realloc", 4), + void_function_export!("__wbindgen_exn_store", 1), + number_function_export!("__externref_table_alloc", 0), + table_export!("__wbindgen_externrefs"), + void_function_export!("__externref_table_dealloc", 1), + void_function_export!("__wbindgen_start", 0), +]; + +#[cfg(test)] +mod tests { + use crate::sdk_generation::package_matrix::wasm_package_specs; + + use super::{WASM_DECLARATION_HEADER, declaration_files}; + + #[test] + fn declaration_files_are_dto_owned_and_cover_primary_and_sidecar_paths() { + for spec in wasm_package_specs() { + let files = declaration_files(*spec).expect("declaration files"); + assert_eq!(files.len(), 2); + assert!( + files + .iter() + .any(|file| file.relative_path == format!("dist/{}.d.ts", spec.out_name)) + ); + assert!( + files.iter().any( + |file| file.relative_path == format!("dist/{}_bg.wasm.d.ts", spec.out_name) + ) + ); + for file in files { + assert!(file.contents.starts_with(WASM_DECLARATION_HEADER)); + assert!(!file.contents.contains("tslint:disable")); + assert!(!file.contents.contains("eslint-disable")); + } + } + } + + #[test] + fn generated_declarations_include_public_wasm_entrypoints() { + let specs = wasm_package_specs(); + let events = declaration_files(specs[0]).expect("event declarations"); + assert!( + events[0] + .contents + .contains("export function operational_listing_tags") + ); + assert!( + events[1] + .contents + .contains("export declare const operational_listing_tags") + ); + for authored in [ + "social_ask_build_authored_draft", + "social_comment_build_authored_draft", + "social_photo_update_build_authored_draft", + "social_update_build_authored_draft", + ] { + assert!( + events[0] + .contents + .contains(&format!("export function {authored}")) + ); + assert!( + events[1] + .contents + .contains(&format!("export declare const {authored}")) + ); + } + for retired in [ + "calendar_date_event_tags", + "calendar_event_rsvp_tags", + "calendar_tags", + "calendar_time_event_tags", + ] { + assert!(!events[0].contents.contains(retired)); + assert!(!events[1].contents.contains(retired)); + } + for retired in ["comment_tags", "post_tags"] { + assert!( + !events[0] + .contents + .contains(&format!("export function {retired}(")) + ); + assert!( + !events[1] + .contents + .contains(&format!("export declare const {retired}:")) + ); + } + + let replica_store = declaration_files(specs[1]).expect("replica store declarations"); + assert!( + replica_store[0] + .contents + .contains("export function replica_store_farm_create") + ); + assert!( + replica_store[0] + .contents + .contains("export function replica_store_farm_update") + ); + assert!( + replica_store[0] + .contents + .contains("export function replica_store_plot_update") + ); + assert!( + replica_store[1] + .contents + .contains("export declare const replica_store_farm_create") + ); + assert!( + replica_store[1] + .contents + .contains("export declare const replica_store_farm_update") + ); + assert!( + replica_store[1] + .contents + .contains("export declare const replica_store_plot_update") + ); + + let replica_sync = declaration_files(specs[2]).expect("replica sync declarations"); + assert!( + replica_sync[0] + .contents + .contains("export function replica_sync_sync_all") + ); + assert!( + replica_sync[1] + .contents + .contains("export declare const replica_sync_sync_all") + ); + } +} diff --git a/treefmt.nix b/treefmt.nix @@ -3,6 +3,7 @@ settings.global.excludes = [ ".direnv/**" + "contracts/crates/generated/**" "target/**" ];