feat(cli,engine): data-only project tier at the load boundary (#241 T1-T2)
- ProjectProvenance namespace/dylib_sha256 widen to Option (Tier-1 additive serde): a data-only run stamps commit-only provenance; old string-field manifests keep parsing. - The load boundary tier-selects: a [nodes] pointer list in Aura.toml (one entry honored; MultiCrate / pointer-context refusals) loads the pointed-at crate; a root Cargo.toml stays the pre-#241 native project, unchanged; neither means data-only (std vocabulary, project-local runs/, both-tier provenance). - ProjectEnv splits into directory facts + Option<NativeEnv>; the crate pipeline (metadata -> artifact -> staleness -> ABI -> charter) relocates byte-identical into load_crate. - e2e fixtures: dataonly-project, multicrate-project, nested-nodes-project (a pointer crate resolves and stamps its namespace end to end). refs #241
This commit is contained in:
+128
-20
@@ -25,6 +25,8 @@ use std::time::SystemTime;
|
|||||||
pub struct AuraToml {
|
pub struct AuraToml {
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub paths: AuraPaths,
|
pub paths: AuraPaths,
|
||||||
|
#[serde(default)]
|
||||||
|
pub nodes: AuraNodes,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Default, PartialEq, serde::Deserialize)]
|
#[derive(Debug, Default, PartialEq, serde::Deserialize)]
|
||||||
@@ -37,6 +39,15 @@ pub struct AuraPaths {
|
|||||||
pub runs: Option<PathBuf>,
|
pub runs: Option<PathBuf>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Node-crate pointers (C17: static context). Paths resolve relative to the
|
||||||
|
/// project root (`Path::join`, so absolute paths pass through). The format
|
||||||
|
/// carries a list; this iteration loads exactly one entry and refuses more.
|
||||||
|
#[derive(Debug, Default, PartialEq, serde::Deserialize)]
|
||||||
|
pub struct AuraNodes {
|
||||||
|
#[serde(default)]
|
||||||
|
pub crates: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub enum ProjectError {
|
pub enum ProjectError {
|
||||||
Toml(PathBuf, String),
|
Toml(PathBuf, String),
|
||||||
@@ -46,6 +57,8 @@ pub enum ProjectError {
|
|||||||
NotAProjectDylib(PathBuf),
|
NotAProjectDylib(PathBuf),
|
||||||
Incompatible { what: &'static str, dylib: String, host: String },
|
Incompatible { what: &'static str, dylib: String, host: String },
|
||||||
Charter(String),
|
Charter(String),
|
||||||
|
MultiCrate(usize),
|
||||||
|
NodesPointer { pointer: String, inner: Box<ProjectError> },
|
||||||
}
|
}
|
||||||
|
|
||||||
impl fmt::Display for ProjectError {
|
impl fmt::Display for ProjectError {
|
||||||
@@ -73,25 +86,41 @@ impl fmt::Display for ProjectError {
|
|||||||
the host's toolchain/aura-core"
|
the host's toolchain/aura-core"
|
||||||
),
|
),
|
||||||
Self::Charter(e) => write!(f, "project vocabulary rejected: {e}"),
|
Self::Charter(e) => write!(f, "project vocabulary rejected: {e}"),
|
||||||
|
Self::MultiCrate(n) => write!(
|
||||||
|
f,
|
||||||
|
"multi-crate loading is not yet supported (found {n} entries \
|
||||||
|
in [nodes].crates — only one is accepted)"
|
||||||
|
),
|
||||||
|
Self::NodesPointer { pointer, inner } => write!(
|
||||||
|
f,
|
||||||
|
"node crate at `{pointer}` (from [nodes] in Aura.toml): {inner}"
|
||||||
|
),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A successfully loaded project. The `Library` behind `resolver`/`type_ids`
|
/// A discovered project. `native` is present only when a node crate is
|
||||||
/// is leaked (load-and-hold): the fn pointers are valid for 'static.
|
/// loaded (the crate's `Library` is leaked — load-and-hold, 'static).
|
||||||
|
#[derive(Debug)]
|
||||||
pub struct ProjectEnv {
|
pub struct ProjectEnv {
|
||||||
pub root: PathBuf,
|
pub root: PathBuf,
|
||||||
pub toml: AuraToml,
|
pub toml: AuraToml,
|
||||||
|
pub commit: Option<String>,
|
||||||
|
pub native: Option<NativeEnv>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The loaded node crate: vocabulary + identity stamps.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct NativeEnv {
|
||||||
pub namespace: String,
|
pub namespace: String,
|
||||||
pub dylib_sha256: String,
|
pub dylib_sha256: String,
|
||||||
pub commit: Option<String>,
|
|
||||||
resolver: fn(&str) -> Option<PrimitiveBuilder>,
|
resolver: fn(&str) -> Option<PrimitiveBuilder>,
|
||||||
type_id_list: &'static [&'static str],
|
type_id_list: &'static [&'static str],
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ProjectEnv {
|
impl ProjectEnv {
|
||||||
fn resolve(&self, type_id: &str) -> Option<PrimitiveBuilder> {
|
fn resolve(&self, type_id: &str) -> Option<PrimitiveBuilder> {
|
||||||
(self.resolver)(type_id)
|
self.native.as_ref().and_then(|n| (n.resolver)(type_id))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -121,8 +150,8 @@ impl Env {
|
|||||||
/// project ∪ std type ids, project first (for `introspect --vocabulary`).
|
/// project ∪ std type ids, project first (for `introspect --vocabulary`).
|
||||||
pub fn type_ids(&self) -> Vec<&'static str> {
|
pub fn type_ids(&self) -> Vec<&'static str> {
|
||||||
let mut out: Vec<&'static str> = Vec::new();
|
let mut out: Vec<&'static str> = Vec::new();
|
||||||
if let Some(p) = &self.project {
|
if let Some(n) = self.project.as_ref().and_then(|p| p.native.as_ref()) {
|
||||||
out.extend_from_slice(p.type_id_list);
|
out.extend_from_slice(n.type_id_list);
|
||||||
}
|
}
|
||||||
out.extend_from_slice(std_vocabulary_types());
|
out.extend_from_slice(std_vocabulary_types());
|
||||||
out
|
out
|
||||||
@@ -159,8 +188,8 @@ impl Env {
|
|||||||
|
|
||||||
pub fn provenance(&self) -> Option<ProjectProvenance> {
|
pub fn provenance(&self) -> Option<ProjectProvenance> {
|
||||||
self.project.as_ref().map(|p| ProjectProvenance {
|
self.project.as_ref().map(|p| ProjectProvenance {
|
||||||
namespace: p.namespace.clone(),
|
namespace: p.native.as_ref().map(|n| n.namespace.clone()),
|
||||||
dylib_sha256: p.dylib_sha256.clone(),
|
dylib_sha256: p.native.as_ref().map(|n| n.dylib_sha256.clone()),
|
||||||
commit: p.commit.clone(),
|
commit: p.commit.clone(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -185,6 +214,17 @@ fn parse_aura_toml(root: &Path) -> Result<AuraToml, ProjectError> {
|
|||||||
toml::from_str(&text).map_err(|e| ProjectError::Toml(path, e.to_string()))
|
toml::from_str(&text).map_err(|e| ProjectError::Toml(path, e.to_string()))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Read a project's `Aura.toml` (for scaffold-side tooling that must not
|
||||||
|
/// trigger a crate load, e.g. `aura nodes new`).
|
||||||
|
///
|
||||||
|
/// `dead_code` is allowed here only until that consumer lands — remove the
|
||||||
|
/// allow with the first caller, at which point genuine unuse must surface
|
||||||
|
/// again.
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub fn read_aura_toml(root: &Path) -> Result<AuraToml, ProjectError> {
|
||||||
|
parse_aura_toml(root)
|
||||||
|
}
|
||||||
|
|
||||||
/// Derive the cdylib artifact path from `cargo metadata` output (pure —
|
/// Derive the cdylib artifact path from `cargo metadata` output (pure —
|
||||||
/// unit-testable on a canned JSON document).
|
/// unit-testable on a canned JSON document).
|
||||||
fn artifact_from_metadata(
|
fn artifact_from_metadata(
|
||||||
@@ -440,15 +480,41 @@ fn newest_source_mtime(root: &Path) -> Option<SystemTime> {
|
|||||||
newest
|
newest
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Locate, load (load-and-hold), verify, and charter-check the project dylib.
|
/// Discover the project's tier and load it. Data-only (no [nodes], no root
|
||||||
|
/// crate) loads nothing; a [nodes] pointer or a root Cargo.toml routes into
|
||||||
|
/// the unchanged crate pipeline (`load_crate`).
|
||||||
pub fn load(root: &Path, release: bool) -> Result<ProjectEnv, ProjectError> {
|
pub fn load(root: &Path, release: bool) -> Result<ProjectEnv, ProjectError> {
|
||||||
let toml = parse_aura_toml(root)?;
|
let toml = parse_aura_toml(root)?;
|
||||||
let dylib_path = artifact_path(root, release)?;
|
let native = match toml.nodes.crates.as_slice() {
|
||||||
|
[] => {
|
||||||
|
if root.join("Cargo.toml").is_file() {
|
||||||
|
Some(load_crate(root, release)?)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
[pointer] => Some(load_crate(&root.join(pointer), release).map_err(|e| {
|
||||||
|
ProjectError::NodesPointer { pointer: pointer.clone(), inner: Box::new(e) }
|
||||||
|
})?),
|
||||||
|
many => return Err(ProjectError::MultiCrate(many.len())),
|
||||||
|
};
|
||||||
|
Ok(ProjectEnv {
|
||||||
|
root: root.to_path_buf(),
|
||||||
|
toml,
|
||||||
|
commit: project_commit(root),
|
||||||
|
native,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Locate, load (load-and-hold), verify, and charter-check a node crate's
|
||||||
|
/// dylib.
|
||||||
|
fn load_crate(crate_root: &Path, release: bool) -> Result<NativeEnv, ProjectError> {
|
||||||
|
let dylib_path = artifact_path(crate_root, release)?;
|
||||||
if !dylib_path.is_file() {
|
if !dylib_path.is_file() {
|
||||||
return Err(ProjectError::ArtifactMissing(dylib_path));
|
return Err(ProjectError::ArtifactMissing(dylib_path));
|
||||||
}
|
}
|
||||||
if let Ok(dylib_mtime) = std::fs::metadata(&dylib_path).and_then(|m| m.modified())
|
if let Ok(dylib_mtime) = std::fs::metadata(&dylib_path).and_then(|m| m.modified())
|
||||||
&& let Some(source_mtime) = newest_source_mtime(root)
|
&& let Some(source_mtime) = newest_source_mtime(crate_root)
|
||||||
&& let Some(warning) = stale_warning(&dylib_path, dylib_mtime, source_mtime)
|
&& let Some(warning) = stale_warning(&dylib_path, dylib_mtime, source_mtime)
|
||||||
{
|
{
|
||||||
eprintln!("{warning}");
|
eprintln!("{warning}");
|
||||||
@@ -492,15 +558,7 @@ pub fn load(root: &Path, release: bool) -> Result<ProjectEnv, ProjectError> {
|
|||||||
let type_id_list = (desc.type_ids)();
|
let type_id_list = (desc.type_ids)();
|
||||||
check_charter(&namespace, type_id_list, &|t| resolver(t))?;
|
check_charter(&namespace, type_id_list, &|t| resolver(t))?;
|
||||||
|
|
||||||
Ok(ProjectEnv {
|
Ok(NativeEnv { namespace, dylib_sha256, resolver, type_id_list })
|
||||||
root: root.to_path_buf(),
|
|
||||||
toml,
|
|
||||||
namespace,
|
|
||||||
dylib_sha256,
|
|
||||||
commit: project_commit(root),
|
|
||||||
resolver,
|
|
||||||
type_id_list,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -519,6 +577,56 @@ mod tests {
|
|||||||
std::fs::remove_dir_all(&tmp).unwrap();
|
std::fs::remove_dir_all(&tmp).unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn aura_toml_parses_a_nodes_pointer_list() {
|
||||||
|
let t: AuraToml = toml::from_str("[nodes]\ncrates = [\"../my-nodes\"]").unwrap();
|
||||||
|
assert_eq!(t.nodes.crates, vec!["../my-nodes".to_string()]);
|
||||||
|
let t: AuraToml = toml::from_str("").unwrap();
|
||||||
|
assert!(t.nodes.crates.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// #241: an Aura.toml with neither a [nodes] pointer nor a root crate is
|
||||||
|
/// the data-only tier — it loads with no native env, resolves std-only,
|
||||||
|
/// and stamps commit-only provenance.
|
||||||
|
#[test]
|
||||||
|
fn data_only_project_loads_without_a_crate() {
|
||||||
|
let tmp = std::env::temp_dir().join(format!("aura-dataonly-{}", std::process::id()));
|
||||||
|
std::fs::create_dir_all(&tmp).unwrap();
|
||||||
|
std::fs::write(tmp.join("Aura.toml"), "[paths]\nruns = \"runs\"\n").unwrap();
|
||||||
|
let p = load(&tmp, false).expect("data-only load");
|
||||||
|
assert!(p.native.is_none());
|
||||||
|
let env = Env::with_project(p);
|
||||||
|
assert!(env.resolve("SMA").is_some());
|
||||||
|
assert!(env.resolve("demo::Identity").is_none());
|
||||||
|
assert_eq!(env.runs_root(), tmp.join("runs"));
|
||||||
|
let prov = env.provenance().expect("a data-only project is a project");
|
||||||
|
assert!(prov.namespace.is_none());
|
||||||
|
assert!(prov.dylib_sha256.is_none());
|
||||||
|
std::fs::remove_dir_all(&tmp).ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn two_nodes_pointers_refuse_with_multi_crate() {
|
||||||
|
let tmp = std::env::temp_dir().join(format!("aura-multi-{}", std::process::id()));
|
||||||
|
std::fs::create_dir_all(&tmp).unwrap();
|
||||||
|
std::fs::write(tmp.join("Aura.toml"), "[nodes]\ncrates = [\"a\", \"b\"]\n").unwrap();
|
||||||
|
let e = load(&tmp, false).unwrap_err();
|
||||||
|
assert!(e.to_string().contains("multi-crate loading is not yet supported"), "{e}");
|
||||||
|
std::fs::remove_dir_all(&tmp).ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_broken_nodes_pointer_names_the_pointer() {
|
||||||
|
let tmp = std::env::temp_dir().join(format!("aura-badptr-{}", std::process::id()));
|
||||||
|
std::fs::create_dir_all(&tmp).unwrap();
|
||||||
|
std::fs::write(tmp.join("Aura.toml"), "[nodes]\ncrates = [\"../nope\"]\n").unwrap();
|
||||||
|
let e = load(&tmp, false).unwrap_err();
|
||||||
|
let msg = e.to_string();
|
||||||
|
assert!(msg.contains("../nope"), "{msg}");
|
||||||
|
assert!(msg.contains("[nodes]"), "{msg}");
|
||||||
|
std::fs::remove_dir_all(&tmp).ok();
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn aura_toml_parses_empty_partial_and_unknown_keys() {
|
fn aura_toml_parses_empty_partial_and_unknown_keys() {
|
||||||
let t: AuraToml = toml::from_str("").unwrap();
|
let t: AuraToml = toml::from_str("").unwrap();
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
# data-only fixture (#241): neither [nodes] nor a root Cargo.toml — the
|
||||||
|
# project has no node crate to load at all, only static context.
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"format_version":1,"blueprint":{"name":"sma_signal","nodes":[{"primitive":{"type":"SMA","name":"fast","bound":[{"pos":0,"name":"length","kind":"I64","value":{"I64":2}}]}},{"primitive":{"type":"SMA","name":"slow","bound":[{"pos":0,"name":"length","kind":"I64","value":{"I64":4}}]}},{"primitive":{"type":"Sub"}},{"primitive":{"type":"Bias","name":"bias","bound":[{"pos":0,"name":"scale","kind":"F64","value":{"F64":0.5}}]}}],"edges":[{"from":0,"to":2,"slot":0,"from_field":0},{"from":1,"to":2,"slot":1,"from_field":0},{"from":2,"to":3,"slot":0,"from_field":0}],"input_roles":[{"name":"price","targets":[{"node":0,"slot":0},{"node":1,"slot":0}],"source":"F64"}],"output":[{"node":3,"field":0,"name":"bias"}]}}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
# multi-crate fixture (#241): two [nodes].crates entries — loading more than
|
||||||
|
# one node crate is not yet supported and must refuse before any file I/O
|
||||||
|
# on the run's blueprint argument.
|
||||||
|
[nodes]
|
||||||
|
crates = ["a", "b"]
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
# nested-nodes fixture (#241): the node crate lives at a [nodes] pointer, NOT
|
||||||
|
# at the project root (no root Cargo.toml here) — the load boundary must
|
||||||
|
# resolve the crate through the pointer, not assume root-collocation.
|
||||||
|
[nodes]
|
||||||
|
crates = ["nodecrate"]
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
/target
|
||||||
|
Cargo.lock
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
[package]
|
||||||
|
name = "nodecrate"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2024"
|
||||||
|
publish = false
|
||||||
|
|
||||||
|
[lib]
|
||||||
|
crate-type = ["cdylib"]
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
aura-core = { path = "../../../../../aura-core" }
|
||||||
|
|
||||||
|
# Standalone workspace root: never a member of the engine workspace
|
||||||
|
# (docs/project-layout.md, the empty-[workspace] note) — matches the
|
||||||
|
# demo-project fixture's convention.
|
||||||
|
[workspace]
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
//! Fixture node crate for the #241 [nodes] pointer e2e: lives one level
|
||||||
|
//! BELOW its Aura.toml (`../Aura.toml` points at this dir via
|
||||||
|
//! `[nodes].crates`), unlike the demo-project fixture which collocates the
|
||||||
|
//! crate at the project root. One pass-through node under the `ptr`
|
||||||
|
//! namespace, exported via the descriptor macro.
|
||||||
|
|
||||||
|
use aura_core::{
|
||||||
|
Cell, Ctx, FieldSpec, Firing, Node, NodeSchema, PortSpec, PrimitiveBuilder,
|
||||||
|
ScalarKind,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// One-input f64 pass-through. Emits `None` until its input has a value.
|
||||||
|
pub struct Identity {
|
||||||
|
out: [Cell; 1],
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Identity {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self { out: [Cell::from_f64(0.0)] }
|
||||||
|
}
|
||||||
|
pub fn builder() -> PrimitiveBuilder {
|
||||||
|
PrimitiveBuilder::new(
|
||||||
|
"ptr::Identity",
|
||||||
|
NodeSchema {
|
||||||
|
inputs: vec![PortSpec {
|
||||||
|
kind: ScalarKind::F64,
|
||||||
|
firing: Firing::Any,
|
||||||
|
name: "value".into(),
|
||||||
|
}],
|
||||||
|
output: vec![FieldSpec { name: "value".into(), kind: ScalarKind::F64 }],
|
||||||
|
params: vec![],
|
||||||
|
},
|
||||||
|
|_| Box::new(Identity::new()),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for Identity {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Node for Identity {
|
||||||
|
fn lookbacks(&self) -> Vec<usize> {
|
||||||
|
vec![1]
|
||||||
|
}
|
||||||
|
fn eval(&mut self, ctx: Ctx<'_>) -> Option<&[Cell]> {
|
||||||
|
let w = ctx.f64_in(0);
|
||||||
|
if w.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
self.out[0] = Cell::from_f64(w[0]);
|
||||||
|
Some(&self.out)
|
||||||
|
}
|
||||||
|
fn label(&self) -> String {
|
||||||
|
"ptr::Identity".to_string()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn vocabulary(type_id: &str) -> Option<PrimitiveBuilder> {
|
||||||
|
match type_id {
|
||||||
|
"ptr::Identity" => Some(Identity::builder()),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn type_ids() -> &'static [&'static str] {
|
||||||
|
&["ptr::Identity"]
|
||||||
|
}
|
||||||
|
|
||||||
|
aura_core::aura_project! {
|
||||||
|
namespace: "ptr",
|
||||||
|
vocabulary: vocabulary,
|
||||||
|
type_ids: type_ids,
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"format_version":1,"blueprint":{"name":"pointer_signal","nodes":[{"primitive":{"type":"SMA","name":"fast","bound":[{"pos":0,"name":"length","kind":"I64","value":{"I64":2}}]}},{"primitive":{"type":"SMA","name":"slow","bound":[{"pos":0,"name":"length","kind":"I64","value":{"I64":4}}]}},{"primitive":{"type":"Sub"}},{"primitive":{"type":"Bias","name":"bias","bound":[{"pos":0,"name":"scale","kind":"F64","value":{"F64":0.5}}]}},{"primitive":{"type":"ptr::Identity"}}],"edges":[{"from":0,"to":2,"slot":0,"from_field":0},{"from":1,"to":2,"slot":1,"from_field":0},{"from":2,"to":3,"slot":0,"from_field":0},{"from":3,"to":4,"slot":0,"from_field":0}],"input_roles":[{"name":"price","targets":[{"node":0,"slot":0},{"node":1,"slot":0}],"source":"F64"}],"output":[{"node":4,"field":0,"name":"bias"}]}}
|
||||||
@@ -248,6 +248,123 @@ fn stale_dylib_warns_naming_both_timestamps_but_still_runs() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn dataonly_dir() -> PathBuf {
|
||||||
|
Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/dataonly-project")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn multicrate_dir() -> PathBuf {
|
||||||
|
Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/multicrate-project")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn nested_nodes_dir() -> PathBuf {
|
||||||
|
Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/nested-nodes-project")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Property (#241, tier selection at the load boundary): a project whose
|
||||||
|
/// `Aura.toml` has neither a `[nodes]` pointer nor a root `Cargo.toml` is the
|
||||||
|
/// data-only tier — `aura run` resolves entirely against the std vocabulary
|
||||||
|
/// (no crate to load, no dylib to build) and still succeeds. The widened
|
||||||
|
/// `ProjectProvenance` (Tier-1 optional fields) must reflect that: the
|
||||||
|
/// manifest's `project` object carries no `namespace`/`dylib_sha256` (there is
|
||||||
|
/// no loaded crate to stamp) while still being present as an object (a project
|
||||||
|
/// WAS discovered — `Aura.toml` exists).
|
||||||
|
#[test]
|
||||||
|
fn data_only_project_runs_and_stamps_commit_only_provenance() {
|
||||||
|
let dir = dataonly_dir();
|
||||||
|
let out = Command::new(env!("CARGO_BIN_EXE_aura"))
|
||||||
|
.args(["run", "signal.json"])
|
||||||
|
.current_dir(&dir)
|
||||||
|
.output()
|
||||||
|
.expect("spawn aura run");
|
||||||
|
assert!(
|
||||||
|
out.status.success(),
|
||||||
|
"data-only run failed: {}",
|
||||||
|
String::from_utf8_lossy(&out.stderr)
|
||||||
|
);
|
||||||
|
let v: serde_json::Value =
|
||||||
|
serde_json::from_slice(&out.stdout).expect("run report is JSON");
|
||||||
|
let p = &v["manifest"]["project"];
|
||||||
|
assert!(p.is_object(), "a project WAS discovered (Aura.toml exists): {p}");
|
||||||
|
assert!(
|
||||||
|
p.get("namespace").is_none(),
|
||||||
|
"no crate was loaded, so namespace must be absent, not null: {p}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
p.get("dylib_sha256").is_none(),
|
||||||
|
"no crate was loaded, so dylib_sha256 must be absent, not null: {p}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
p["commit"].as_str().is_some_and(|s| !s.is_empty()),
|
||||||
|
"a data-only project still stamps its own repo commit: {p}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Property (#241, tier selection refuses ambiguity): more than one entry
|
||||||
|
/// under `[nodes].crates` refuses at the real CLI entrypoint — before any
|
||||||
|
/// subcommand-specific file I/O runs (main.rs resolves the project `Env`
|
||||||
|
/// ahead of dispatch) — naming both the count and the `[nodes]` source, not a
|
||||||
|
/// raw parse/index panic.
|
||||||
|
#[test]
|
||||||
|
fn multi_crate_pointer_refuses_end_to_end() {
|
||||||
|
let dir = multicrate_dir();
|
||||||
|
let out = Command::new(env!("CARGO_BIN_EXE_aura"))
|
||||||
|
.args(["run", "x.json"])
|
||||||
|
.current_dir(&dir)
|
||||||
|
.output()
|
||||||
|
.expect("spawn aura run");
|
||||||
|
assert_eq!(
|
||||||
|
out.status.code(),
|
||||||
|
Some(1),
|
||||||
|
"a multi-crate [nodes] config is a runtime refusal (exit 1): {:?}",
|
||||||
|
out.status
|
||||||
|
);
|
||||||
|
let err = String::from_utf8_lossy(&out.stderr);
|
||||||
|
assert!(
|
||||||
|
err.contains("multi-crate loading is not yet supported"),
|
||||||
|
"stderr must name the cause: {err}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Property (#241, [nodes] pointer indirection): the node crate need not be
|
||||||
|
/// collocated with `Aura.toml` at the project root — a single `[nodes].crates`
|
||||||
|
/// entry names a relative path to load instead. Driven through the REAL load
|
||||||
|
/// path (cargo metadata + libloading + charter, exactly like the root-crate
|
||||||
|
/// case in `project_run_manifest_carries_provenance`), not a pure unit call:
|
||||||
|
/// `aura run` from the project root resolves a node type that only the
|
||||||
|
/// pointed-to crate exports, and the manifest's provenance stamps that
|
||||||
|
/// crate's namespace.
|
||||||
|
#[test]
|
||||||
|
fn nodes_pointer_crate_resolves_and_stamps_its_namespace() {
|
||||||
|
let dir = nested_nodes_dir();
|
||||||
|
let crate_dir = dir.join("nodecrate");
|
||||||
|
let build = Command::new("cargo")
|
||||||
|
.arg("build")
|
||||||
|
.current_dir(&crate_dir)
|
||||||
|
.output()
|
||||||
|
.expect("spawn cargo build for the nodecrate fixture");
|
||||||
|
assert!(
|
||||||
|
build.status.success(),
|
||||||
|
"nodecrate fixture build failed:\n{}",
|
||||||
|
String::from_utf8_lossy(&build.stderr)
|
||||||
|
);
|
||||||
|
let out = Command::new(env!("CARGO_BIN_EXE_aura"))
|
||||||
|
.args(["run", "pointer_signal.json"])
|
||||||
|
.current_dir(&dir)
|
||||||
|
.output()
|
||||||
|
.expect("spawn aura run");
|
||||||
|
assert!(
|
||||||
|
out.status.success(),
|
||||||
|
"pointer-crate run failed: {}",
|
||||||
|
String::from_utf8_lossy(&out.stderr)
|
||||||
|
);
|
||||||
|
let v: serde_json::Value =
|
||||||
|
serde_json::from_slice(&out.stdout).expect("run report is JSON");
|
||||||
|
assert_eq!(
|
||||||
|
v["manifest"]["project"]["namespace"], "ptr",
|
||||||
|
"provenance names the POINTED-TO crate's namespace, not the project root: {v}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn missing_artifact_refuses_with_build_hint() {
|
fn missing_artifact_refuses_with_build_hint() {
|
||||||
// A minimal never-built project: valid cargo metadata, no dylib on disk.
|
// A minimal never-built project: valid cargo metadata, no dylib on disk.
|
||||||
|
|||||||
@@ -65,12 +65,16 @@ pub struct RunManifest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Provenance of the project cdylib a run was resolved through (C16/C18).
|
/// Provenance of the project cdylib a run was resolved through (C16/C18).
|
||||||
|
/// `namespace`/`dylib_sha256` are present only when a node crate is loaded
|
||||||
|
/// (native tier); a data-only project stamps only its commit.
|
||||||
#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
|
#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||||
pub struct ProjectProvenance {
|
pub struct ProjectProvenance {
|
||||||
/// The project's vocabulary namespace (from the descriptor).
|
/// The project's vocabulary namespace (from the descriptor).
|
||||||
pub namespace: String,
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub namespace: Option<String>,
|
||||||
/// SHA-256 (hex) of the loaded dylib file's bytes.
|
/// SHA-256 (hex) of the loaded dylib file's bytes.
|
||||||
pub dylib_sha256: String,
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub dylib_sha256: Option<String>,
|
||||||
/// The project repo's HEAD (+ `-dirty`), when derivable.
|
/// The project repo's HEAD (+ `-dirty`), when derivable.
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub commit: Option<String>,
|
pub commit: Option<String>,
|
||||||
@@ -344,8 +348,8 @@ mod tests {
|
|||||||
assert!(!out.contains("\"project\""));
|
assert!(!out.contains("\"project\""));
|
||||||
// Some(..) must round-trip.
|
// Some(..) must round-trip.
|
||||||
let p = ProjectProvenance {
|
let p = ProjectProvenance {
|
||||||
namespace: "demo".into(),
|
namespace: Some("demo".into()),
|
||||||
dylib_sha256: "ab".repeat(32),
|
dylib_sha256: Some("ab".repeat(32)),
|
||||||
commit: None,
|
commit: None,
|
||||||
};
|
};
|
||||||
let m2 = RunManifest { project: Some(p.clone()), ..m };
|
let m2 = RunManifest { project: Some(p.clone()), ..m };
|
||||||
@@ -354,6 +358,31 @@ mod tests {
|
|||||||
assert_eq!(back.project, Some(p));
|
assert_eq!(back.project, Some(p));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn runmanifest_project_field_commit_only_omits_namespace_and_sha() {
|
||||||
|
// #241: a data-only project (no node crate loaded) stamps a
|
||||||
|
// commit-only ProjectProvenance; namespace/dylib_sha256 must stay
|
||||||
|
// absent from the bytes (byte-stability of the native-tier shape)
|
||||||
|
// and the commit-only value must still round-trip.
|
||||||
|
let p = ProjectProvenance {
|
||||||
|
namespace: None,
|
||||||
|
dylib_sha256: None,
|
||||||
|
commit: Some("deadbeef".into()),
|
||||||
|
};
|
||||||
|
let m = RunManifest {
|
||||||
|
commit: "c".into(), params: vec![], window: (Timestamp(0), Timestamp(0)),
|
||||||
|
seed: 0, broker: "b".into(), selection: None, instrument: None,
|
||||||
|
topology_hash: None,
|
||||||
|
project: Some(p.clone()),
|
||||||
|
};
|
||||||
|
let s = serde_json::to_string(&m).expect("serializes");
|
||||||
|
assert!(!s.contains("\"namespace\""), "namespace omitted when None: {s}");
|
||||||
|
assert!(!s.contains("\"dylib_sha256\""), "dylib_sha256 omitted when None: {s}");
|
||||||
|
assert!(s.contains("\"commit\":\"deadbeef\""), "commit present: {s}");
|
||||||
|
let back: RunManifest = serde_json::from_str(&s).expect("round-trips");
|
||||||
|
assert_eq!(back.project, Some(p));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn family_selection_round_trips_on_the_manifest() {
|
fn family_selection_round_trips_on_the_manifest() {
|
||||||
let sel = FamilySelection {
|
let sel = FamilySelection {
|
||||||
|
|||||||
Reference in New Issue
Block a user