diff --git a/.config/hakari.toml b/.config/hakari.toml index 05e5365..51825f5 100644 --- a/.config/hakari.toml +++ b/.config/hakari.toml @@ -32,7 +32,10 @@ platforms = [ [final-excludes] # `nexus` is the no_std kernel — it must not depend on the (std) workspace-hack. # hakari removes the edge; `hakari verify` enforces its absence. (#279) -workspace-members = ["nexus"] +# +# `nexus-nostd-smoketest` is the no_std derive-macro compile-gate (#304): it is +# built for thumbv7em/wasm32, so a std workspace-hack edge would break it. +workspace-members = ["nexus", "nexus-nostd-smoketest"] third-party = [ { name = "futures-core" }, ] diff --git a/Cargo.lock b/Cargo.lock index 7c5c443..c680495 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1270,6 +1270,14 @@ dependencies = [ "workspace-hack", ] +[[package]] +name = "nexus-nostd-smoketest" +version = "0.0.0" +dependencies = [ + "nexus", + "thiserror", +] + [[package]] name = "nexus-postgres" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 9f2d3e2..34249e9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,6 +5,7 @@ members = [ "crates/nexus-fjall", "crates/nexus-macros", "crates/nexus-macros/tests/cross_crate_test", + "crates/nexus-nostd-smoketest", "crates/nexus-postgres", "crates/nexus-store", "crates/nexus-store-testing", diff --git a/crates/nexus-nostd-smoketest/Cargo.toml b/crates/nexus-nostd-smoketest/Cargo.toml new file mode 100644 index 0000000..c81c26d --- /dev/null +++ b/crates/nexus-nostd-smoketest/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "nexus-nostd-smoketest" +version = "0.0.0" +edition = "2024" +publish = false + +# Compile-gate crate for #304: proves the `#[nexus::aggregate]` / +# `#[derive(DomainEvent)]` macro OUTPUT (not just its source) is `core`-clean by +# building it for `thumbv7em-none-eabihf` + `wasm32-unknown-unknown` under +# `nix flake check`. Deliberately excluded from `workspace-hack` (see +# .config/hakari.toml) — a std workspace-hack edge would defeat the bare-metal +# build. No `[lints] workspace = true` for the same reason it needs no hack edge: +# it is a target-compile probe, exercised only through the two flake gates. + +[dependencies] +nexus = { path = "../nexus", default-features = false, optional = true } +thiserror = { workspace = true, optional = true } + +[features] +default = [] +# The gate builds `--no-default-features --features derive`: with `derive` off +# this is an empty `#![no_std]` lib; with it on, the macro output is compiled for +# the target. `nexus/derive` is additive and does NOT pull `nexus/std`. +derive = ["dep:nexus", "nexus/derive", "dep:thiserror"] diff --git a/crates/nexus-nostd-smoketest/src/lib.rs b/crates/nexus-nostd-smoketest/src/lib.rs new file mode 100644 index 0000000..9250ea0 --- /dev/null +++ b/crates/nexus-nostd-smoketest/src/lib.rs @@ -0,0 +1,24 @@ +//! `no_std` compile smoke-test for the nexus derive macros (#304). +//! +//! #279 (PR #303) made the `nexus` kernel `no_std` and added flake gates that +//! build the kernel **bare** (`--no-default-features`). Those gates prove the +//! *core* compiles `no_std`, but they never compile the **output** of +//! `#[nexus::aggregate]` / `#[derive(DomainEvent)]` for a `no_std` target — the +//! macro source was only grepped for `std::` paths, which is weak. +//! +//! This crate closes that gap: it defines a real aggregate using BOTH macros +//! plus a [`Handle`] impl, entirely in `core` (no allocator — `Events` is +//! the single-event `ArrayVec` path). The two flake gates build it for +//! `thumbv7em-none-eabihf` and `wasm32-unknown-unknown`; if a macro ever emits a +//! `std::` path, the generated code fails to compile for `thumbv7em` and the +//! gate goes red. Logic correctness is already covered on the host by the +//! `nexus-cross-crate-test` nextest suite — this crate is a *compile* probe. +#![no_std] + +// The whole probe lives behind `derive`: with the feature off this is an empty +// `#![no_std]` lib (trivially portable); with it on, the macro output is what +// gets compiled for the target. `pub` so the probe's items are reachable API — +// otherwise every generated/hand-written item reads as dead code (a hard error +// under the flake's `clippy --deny warnings`). +#[cfg(feature = "derive")] +pub mod smoke; diff --git a/crates/nexus-nostd-smoketest/src/smoke.rs b/crates/nexus-nostd-smoketest/src/smoke.rs new file mode 100644 index 0000000..1e74c7a --- /dev/null +++ b/crates/nexus-nostd-smoketest/src/smoke.rs @@ -0,0 +1,115 @@ +//! The `no_std` aggregate whose macro output the flake gates compile. +//! +//! Mirrors `nexus-cross-crate-test` (the std cross-crate probe) but stays in +//! `core`: `core::fmt` for `Display`, a fixed-size `[u8; 8]` id (no `String`), +//! and `Events` at the default `N = 0` (single event, no allocator). + +use core::fmt; +use nexus::{AggregateRoot, AggregateState, DomainEvent, Events, Handle, Version, events}; + +// --- Id (core-only: fixed array, `core::fmt` Display) --- +#[derive(Debug, Clone, Hash, PartialEq, Eq)] +pub struct TaskId([u8; 8]); + +impl TaskId { + pub fn new(id: u64) -> Self { + Self(id.to_be_bytes()) + } +} + +impl fmt::Display for TaskId { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "task-{}", u64::from_be_bytes(self.0)) + } +} + +impl AsRef<[u8]> for TaskId { + fn as_ref(&self) -> &[u8] { + &self.0 + } +} + +// --- Events (derive macro output compiled for the target) --- +#[derive(Debug, Clone, DomainEvent)] +pub enum TaskEvent { + Started, + Finished, +} + +// --- State --- +#[derive(Default, Debug, Clone)] +pub struct TaskState { + started: bool, + done: bool, +} + +impl AggregateState for TaskState { + type Event = TaskEvent; + fn initial() -> Self { + Self::default() + } + fn apply(mut self, event: &TaskEvent) -> Self { + match event { + TaskEvent::Started => self.started = true, + TaskEvent::Finished => self.done = true, + } + self + } +} + +// --- Error (thiserror, no_std — no `std::error::Error` bridge on target) --- +#[derive(Debug, thiserror::Error)] +pub enum TaskError { + #[error("already started")] + AlreadyStarted, + #[error("not started")] + NotStarted, + #[error("already done")] + AlreadyDone, +} + +// --- Aggregate (attribute macro output compiled for the target) --- +#[nexus::aggregate(state = TaskState, error = TaskError, id = TaskId)] +pub struct TaskAggregate; + +// --- Commands + decide (single-event `Events<_, 0>`, no allocator) --- +pub struct StartTask; +pub struct FinishTask; + +impl Handle for TaskAggregate { + fn handle(state: &TaskState, _cmd: StartTask) -> Result, TaskError> { + if state.started { + return Err(TaskError::AlreadyStarted); + } + Ok(events![TaskEvent::Started]) + } +} + +impl Handle for TaskAggregate { + fn handle(state: &TaskState, _cmd: FinishTask) -> Result, TaskError> { + if !state.started { + return Err(TaskError::NotStarted); + } + if state.done { + return Err(TaskError::AlreadyDone); + } + Ok(events![TaskEvent::Finished]) + } +} + +// Exercise the generic `AggregateRoot` surface so the monomorphised driver +// methods (`new`/`handle`/`commit_persisted`/`version`) are instantiated for a +// `no_std` aggregate — a std leak in any of them would surface here at compile +// time on the target. +pub fn drive(id: u64) -> Option { + let mut root = AggregateRoot::::new(TaskId::new(id)); + if let Ok(events) = root.handle(StartTask) { + root.commit_persisted(Version::INITIAL, &events); + } + root.version() +} + +// Prove the `DomainEvent::name` codegen is reachable in `core`. +pub fn event_name(event: &TaskEvent) -> &'static str { + event.name() +} diff --git a/flake.nix b/flake.nix index bf658e6..78ef9e0 100644 --- a/flake.nix +++ b/flake.nix @@ -131,11 +131,16 @@ # `nexus-nostd` (thumbv7em-none-eabihf) is the STRONG gate: a fully # std-free bare-metal target. `wasm32-unknown-unknown` still ships std, # so it alone would not catch a std leak. Both build --no-default-features. + # Each gate also builds `nexus-nostd-smoketest` (#304): a crate that + # uses `#[nexus::aggregate]` + `#[derive(DomainEvent)]`, so the macro + # OUTPUT — not just its source — is compiled for the target. A macro + # emitting a `std::` path fails the thumbv7em build here. nexus-wasm = craneLib.mkCargoDerivation (commonArgs // { inherit cargoArtifacts; pname = "nexus-wasm"; buildPhaseCargoCommand = '' cargo build -p nexus --target wasm32-unknown-unknown --no-default-features + cargo build -p nexus-nostd-smoketest --target wasm32-unknown-unknown --no-default-features --features derive ''; }); @@ -144,6 +149,7 @@ pname = "nexus-nostd"; buildPhaseCargoCommand = '' cargo build -p nexus --target thumbv7em-none-eabihf --no-default-features + cargo build -p nexus-nostd-smoketest --target thumbv7em-none-eabihf --no-default-features --features derive ''; }); };