From 23bb978bf214d69f88ef0753f918f4bb69b5f324 Mon Sep 17 00:00:00 2001 From: claude Date: Sun, 12 Jul 2026 17:08:47 +0200 Subject: [PATCH] =?UTF-8?q?feat(cli):=20aura=20nodes=20new=20=E2=80=94=20n?= =?UTF-8?q?ode-crate=20scaffold=20+=20attach=20(#241=20T5-T6)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - New clap family `aura nodes new [--engine-path] [--namespace]`: scaffolds a node crate as a SIBLING of the project root (Cargo.toml with the aura-core path-dep, the parameterized Scale starter node, roster macro, crate-scoped .gitignore + CLAUDE.md, own git repo) and appends the `[nodes] crates = ["../"]` pointer to the project's Aura.toml. Refuses outside a project, on any existing [nodes] section (incl. an empty crates array), and on an existing destination; joins the load-phase bypass beside `aura new` (a scaffolder must work in an unbuilt tree). - The old single-tier scaffold() is gone; the crate templates live on in scaffold_node_crate; dead-code bridges removed with their callers. - tests/project_nodes.rs: attach + refusals + namespace override + the full role-2 loop (scaffold, cargo build, vocabulary resolves ::Scale end to end). - tests/project_sweep_campaign.rs fixture reworked to the two-tier shape (aura new + aura nodes new --namespace knob_lab); the two campaign acceptance tests are byte-unchanged and green. refs #241 --- crates/aura-cli/src/main.rs | 97 ++++++++++- crates/aura-cli/src/project.rs | 5 - crates/aura-cli/src/scaffold.rs | 101 ++++++------ crates/aura-cli/tests/project_nodes.rs | 152 ++++++++++++++++++ .../aura-cli/tests/project_sweep_campaign.rs | 45 ++++-- 5 files changed, 329 insertions(+), 71 deletions(-) create mode 100644 crates/aura-cli/tests/project_nodes.rs diff --git a/crates/aura-cli/src/main.rs b/crates/aura-cli/src/main.rs index ca5d6be..17a400d 100644 --- a/crates/aura-cli/src/main.rs +++ b/crates/aura-cli/src/main.rs @@ -2455,6 +2455,8 @@ enum Command { Reproduce(ReproduceCmd), /// Scaffold a new research project crate. New(NewCmd), + /// Scaffold and attach node crates (native-node development). + Nodes(NodesCmd), /// Validate, introspect, and register process documents (methodology). Process(research_docs::ProcessCmd), /// Validate, introspect, and register campaign documents (experiment intent). @@ -2479,6 +2481,30 @@ struct NewCmd { name: String, } +#[derive(Args)] +struct NodesCmd { + #[command(subcommand)] + command: NodesCommand, +} + +#[derive(Subcommand)] +enum NodesCommand { + /// Scaffold a node crate beside this project and attach it via [nodes]. + New(NodesNewCmd), +} + +#[derive(Args)] +struct NodesNewCmd { + /// Crate name; also the default namespace (dashes become underscores). + name: String, + /// Engine checkout for the crate's aura-core path-dep. + #[arg(long)] + engine_path: Option, + /// Vocabulary namespace override. + #[arg(long)] + namespace: Option, +} + #[derive(Args)] #[command(args_conflicts_with_subcommands = true)] struct GraphCmd { @@ -3332,6 +3358,70 @@ fn dispatch_new(a: NewCmd, _env: &project::Env) { ); } +fn dispatch_nodes(cmd: NodesCmd) { + match cmd.command { + NodesCommand::New(a) => dispatch_nodes_new(a), + } +} + +fn dispatch_nodes_new(a: NodesNewCmd) { + let cwd = std::env::current_dir().unwrap_or_else(|e| { + eprintln!("aura: {e}"); + std::process::exit(1); + }); + let Some(root) = project::discover_from(&cwd) else { + eprintln!( + "aura: `aura nodes new` needs a project (no Aura.toml found up from {})", + cwd.display() + ); + std::process::exit(1); + }; + let toml = match project::read_aura_toml(&root) { + Ok(t) => t, + Err(e) => { + eprintln!("aura: {e}"); + std::process::exit(1); + } + }; + // Mirror `append_nodes_pointer`'s textual `[nodes]`-section check (not just + // `crates.is_empty()`) so a `[nodes]` section with an empty crates array + // is caught here too, before scaffold_node_crate writes a crate that + // append_nodes_pointer would then refuse to attach. + let already_attached = !toml.nodes.crates.is_empty() + || std::fs::read_to_string(root.join("Aura.toml")).is_ok_and(|t| t.contains("[nodes]")); + if already_attached { + eprintln!("aura: a node crate is already attached (multi-crate loading is not yet supported)"); + std::process::exit(1); + } + let parent = root.parent().unwrap_or(&root).to_path_buf(); + let spec = match scaffold::scaffold_spec( + &a.name, + a.engine_path.as_deref(), + a.namespace.as_deref(), + &parent, + ) { + Ok(s) => s, + Err(m) => { + eprintln!("aura: {m}"); + std::process::exit(2); + } + }; + if let Err(m) = scaffold::scaffold_node_crate(&spec) { + eprintln!("aura: {m}"); + std::process::exit(1); + } + if let Err(m) = scaffold::append_nodes_pointer(&root, &format!("../{}", a.name)) { + eprintln!("aura: {m}"); + std::process::exit(1); + } + println!( + "created node crate \"../{}\" (namespace \"{}\") and attached it to {}", + a.name, + spec.namespace, + root.file_name().map(|s| s.to_string_lossy().into_owned()).unwrap_or_default() + ); +} + /// `aura sweep`: loaded-blueprint by-name axis sweep (or `--list-axes` probe) when the /// first-positional is an existing `.json`, else the built-in `--strategy` grid sweep. fn dispatch_sweep(a: SweepCmd, env: &project::Env) { @@ -3701,9 +3791,9 @@ fn main() { libc::signal(libc::SIGPIPE, libc::SIG_DFL); } let cli = Cli::parse(); - // `aura new` scaffolds; it must not require a loadable project even - // when invoked inside one (e.g. an unbuilt tree). - let env = if matches!(cli.command, Command::New(_)) { + // `aura new`/`aura nodes new` scaffold; they must not require a + // loadable project even when invoked inside one (e.g. an unbuilt tree). + let env = if matches!(cli.command, Command::New(_) | Command::Nodes(_)) { project::Env::std() } else { match std::env::current_dir() @@ -3731,6 +3821,7 @@ fn main() { Command::Runs(a) => dispatch_runs(a, &env), Command::Reproduce(a) => dispatch_reproduce(a, &env), Command::New(a) => dispatch_new(a, &env), + Command::Nodes(a) => dispatch_nodes(a), Command::Process(a) => research_docs::process_cmd(a, &env), Command::Campaign(a) => research_docs::campaign_cmd(a, &env), } diff --git a/crates/aura-cli/src/project.rs b/crates/aura-cli/src/project.rs index ad86bd0..4bfca6c 100644 --- a/crates/aura-cli/src/project.rs +++ b/crates/aura-cli/src/project.rs @@ -216,11 +216,6 @@ fn parse_aura_toml(root: &Path) -> Result { /// 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 { parse_aura_toml(root) } diff --git a/crates/aura-cli/src/scaffold.rs b/crates/aura-cli/src/scaffold.rs index 1ea86dc..ceb8385 100644 --- a/crates/aura-cli/src/scaffold.rs +++ b/crates/aura-cli/src/scaffold.rs @@ -6,9 +6,9 @@ //! project-load phase in `main()` sees a plain data-only project just like //! any other). //! -//! The crate-scaffold templates (`ScaffoldSpec`/`scaffold`/`scaffold_spec`, -//! cycle 0103, C16/C17) survive for the native-node tier: `aura nodes new` -//! (a follow-up task) attaches a node crate beside a project. +//! The crate-scaffold templates (`ScaffoldSpec`/`scaffold_node_crate`/ +//! `scaffold_spec`, cycle 0103, C16/C17) serve the native-node tier: `aura +//! nodes new` attaches a node crate beside a project. //! //! Templates are raw strings with `__NS__` / `__NAME__` / `__NAME_SNAKE__` / //! `__ENGINE__` tokens substituted by plain `.replace()` — no format-string @@ -44,7 +44,8 @@ pub fn snake(name: &str) -> String { /// The compile-time default engine root: two ancestors above aura-cli's /// manifest dir (`/crates/aura-cli` → ``). Valid on the box the -/// binary was built on; `scaffold` refuses when it no longer exists. +/// binary was built on; `scaffold_node_crate` refuses when it no longer +/// exists. pub fn default_engine_root() -> PathBuf { Path::new(env!("CARGO_MANIFEST_DIR")) .ancestors() @@ -73,7 +74,6 @@ pub fn project_scaffold_spec(name: &str, cwd: &Path) -> Result, @@ -128,7 +128,7 @@ runs = "runs" # data = "/path/to/archive" # the recorded-data root; defaults to the built-in path "#; -const GITIGNORE: &str = "/target\nCargo.lock\n/runs\n"; +const GITIGNORE_CRATE: &str = "/target\nCargo.lock\n"; const LIB_RS: &str = r#"//! __NAME__ — an aura research project: node logic + the exported vocabulary. //! @@ -204,18 +204,17 @@ aura_core::aura_project! { } "#; -const SIGNAL_JSON: &str = r#"{"format_version":1,"blueprint":{"name":"__NAME_SNAKE___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":"__NS__::Scale","bound":[{"pos":0,"name":"factor","kind":"F64","value":{"F64":1.0}}]}}],"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"}]}}"#; +const CLAUDE_MD_CRATE: &str = r#"# __NAME__ — an aura node crate -const CLAUDE_MD: &str = r#"# __NAME__ — an aura research project +Native node logic for an aura research project (extension-dev role). This +crate is attached to its project via `[nodes]` in the project's `Aura.toml`. -This crate is an aura project: a cdylib of node/strategy blueprints the -`aura` host loads during research (see the engine's docs/project-layout.md). - -- Build: `cargo build` (the next `aura` invocation loads the fresh dylib) -- Run: `aura run blueprints/signal.json` (from anywhere inside this dir) -- Nodes live in `src/`; each is registered in `vocabulary()`/`type_ids()` - under the `__NS__::` namespace prefix. -- Topology is data (`blueprints/*.json`), node logic is Rust. +- Build: `cargo build` (the next `aura` invocation in the project loads the + fresh dylib) +- Each node is registered in `vocabulary()`/`type_ids()` under the + `__NS__::` namespace prefix; blueprints reference nodes by that id. +- This crate has no data-plane dependencies — nodes are pure stream + transformers over read-only input windows. "#; const GITIGNORE_PROJECT: &str = "/runs\n"; @@ -290,15 +289,12 @@ pub fn scaffold_project(spec: &ProjectScaffoldSpec) -> Result<(), String> { Ok(()) } -/// Emit the project. `Err` is a runtime refusal — the caller exits 1 (C14 -/// partition). All refusals fire before the first write. -#[allow(dead_code)] // #241: `aura nodes new` (next task) becomes the caller -pub fn scaffold(spec: &ScaffoldSpec) -> Result<(), String> { +/// Emit a node crate (#241): the native half of the old single-tier scaffold +/// — Cargo.toml + src/lib.rs + crate-scoped .gitignore + CLAUDE.md, no +/// Aura.toml and no blueprints/ (those belong to the research project). +pub fn scaffold_node_crate(spec: &ScaffoldSpec) -> Result<(), String> { if spec.target_dir.exists() { - return Err(format!( - "destination `{}` already exists", - spec.target_dir.display() - )); + return Err(format!("destination `{}` already exists", spec.target_dir.display())); } if !spec.engine_root.join("crates/aura-core").is_dir() { return Err(format!( @@ -315,11 +311,9 @@ pub fn scaffold(spec: &ScaffoldSpec) -> Result<(), String> { std::fs::write(&path, body).map_err(|e| format!("writing `{}`: {e}", path.display())) }; write("Cargo.toml", render(CARGO_TOML, spec))?; - write("Aura.toml", render(AURA_TOML, spec))?; - write(".gitignore", render(GITIGNORE, spec))?; + write(".gitignore", GITIGNORE_CRATE.to_string())?; write("src/lib.rs", render(LIB_RS, spec))?; - write("blueprints/signal.json", render(SIGNAL_JSON, spec))?; - write("CLAUDE.md", render(CLAUDE_MD, spec))?; + write("CLAUDE.md", render(CLAUDE_MD_CRATE, spec))?; // Best-effort git init: a warning, never an error (cargo-new mirror). // Also the cargo-new mirror: inside an existing work tree the init is // SKIPPED silently — a nested .git is a gitlink/submodule hazard when the @@ -327,17 +321,29 @@ pub fn scaffold(spec: &ScaffoldSpec) -> Result<(), String> { if inside_git_work_tree(&spec.target_dir) { return Ok(()); } - match std::process::Command::new("git") - .arg("init") - .current_dir(&spec.target_dir) - .output() - { + match std::process::Command::new("git").arg("init").current_dir(&spec.target_dir).output() { Ok(o) if o.status.success() => {} - _ => eprintln!("aura: warning: git init failed (project created without a repo)"), + _ => eprintln!("aura: warning: git init failed (node crate created without a repo)"), } Ok(()) } +/// Append the `[nodes]` pointer to a project's Aura.toml. Refuses when a +/// `[nodes]` section already exists (single-crate iteration). +pub fn append_nodes_pointer(project_root: &Path, pointer: &str) -> Result<(), String> { + let path = project_root.join("Aura.toml"); + let text = std::fs::read_to_string(&path) + .map_err(|e| format!("reading `{}`: {e}", path.display()))?; + if text.contains("[nodes]") { + return Err( + "Aura.toml already has a [nodes] section (multi-crate loading is not yet supported)" + .to_string(), + ); + } + std::fs::write(&path, format!("{text}\n[nodes]\ncrates = [\"{pointer}\"]\n")) + .map_err(|e| format!("writing `{}`: {e}", path.display())) +} + /// Whether `dir` already sits inside a git work tree (`git rev-parse`, the /// same oracle cargo-new consults). A missing git binary or any failure reads /// as "not in a work tree" — the init stays best-effort either way. @@ -388,16 +394,13 @@ mod tests { fn templates_render_without_leftover_tokens() { // Crate-template set (native tier, via `ScaffoldSpec`). let spec = spec_for("demo-lab", None); - for t in [CARGO_TOML, AURA_TOML, GITIGNORE, LIB_RS, SIGNAL_JSON, CLAUDE_MD] { + for t in [CARGO_TOML, GITIGNORE_CRATE, LIB_RS, CLAUDE_MD_CRATE] { let out = render(t, &spec); assert!(!out.contains("__"), "unsubstituted token in: {out}"); } let lib = render(LIB_RS, &spec); assert!(lib.contains("\"demo_lab::Scale\"")); assert!(lib.contains("namespace: \"demo_lab\"")); - let json = render(SIGNAL_JSON, &spec); - assert!(json.contains("\"type\":\"demo_lab::Scale\"")); - assert!(json.contains("\"name\":\"demo_lab_signal\"")); let cargo = render(CARGO_TOML, &spec); assert!(cargo.contains("name = \"demo-lab\"")); assert!(cargo.contains("/crates/aura-core")); @@ -418,16 +421,13 @@ mod tests { /// Property (#183, fieldtest F2 gap): the scaffold's own starter node teaches /// the bound-param build closure. Its rendered `src/lib.rs` declares a param in /// the node schema and reads a bound value in the build closure (not the - /// param-less `|_|` Identity the template shipped before), and the rendered - /// starter blueprint binds `factor` on the project's own node — so `aura run` - /// exercises the bound-param path with zero hand-editing. (The scaffold→build→ - /// run e2e in tests/project_new.rs re-validates the swap end to end; this pins - /// the copyable template content a newcomer learns from directly.) + /// param-less `|_|` Identity the template shipped before) — the copyable + /// template content a newcomer learns from directly. #[test] fn starter_node_declares_and_reads_a_bound_param() { let spec = spec_for("demo-lab", None); let lib = render(LIB_RS, &spec); - // (a) the node schema declares a bindable param... + // the node schema declares a bindable param... assert!( lib.contains("ParamSpec"), "starter node must declare a param in its schema, not `params: vec![]`: {lib}" @@ -437,13 +437,6 @@ mod tests { lib.contains("p[0].f64()"), "starter build closure must consume a bound param (p[0].f64()), not `|_|`: {lib}" ); - // (b) the starter blueprint binds `factor` on the project's own node, so - // `aura run` drives the bound-param path out of the box. - let json = render(SIGNAL_JSON, &spec); - assert!( - json.contains("\"name\":\"factor\""), - "starter blueprint must bind the `factor` param on the project node: {json}" - ); } /// #241: the data-only starter blueprints reference std vocabulary only — @@ -476,18 +469,18 @@ mod tests { } #[test] - fn scaffold_refuses_existing_dir_and_missing_engine() { + fn scaffold_node_crate_refuses_existing_dir_and_missing_engine() { let tmp = std::env::temp_dir().join(format!("aura-scaf-{}", std::process::id())); std::fs::create_dir_all(&tmp).unwrap(); // existing destination let mut spec = scaffold_spec("x", None, None, &tmp).unwrap(); std::fs::create_dir_all(tmp.join("x")).unwrap(); - let e = scaffold(&spec).unwrap_err(); + let e = scaffold_node_crate(&spec).unwrap_err(); assert!(e.contains("already exists"), "{e}"); // missing engine root std::fs::remove_dir_all(tmp.join("x")).unwrap(); spec.engine_root = PathBuf::from("/nonexistent-engine"); - let e = scaffold(&spec).unwrap_err(); + let e = scaffold_node_crate(&spec).unwrap_err(); assert!(e.contains("--engine-path"), "{e}"); std::fs::remove_dir_all(&tmp).ok(); } diff --git a/crates/aura-cli/tests/project_nodes.rs b/crates/aura-cli/tests/project_nodes.rs new file mode 100644 index 0000000..1b2a618 --- /dev/null +++ b/crates/aura-cli/tests/project_nodes.rs @@ -0,0 +1,152 @@ +//! `aura nodes new` (#241): sibling node-crate scaffold + [nodes] attach. + +use std::path::Path; +use std::process::Command; + +fn temp_cwd(tag: &str) -> std::path::PathBuf { + let d = std::env::temp_dir().join(format!("aura-nodes-{tag}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&d); + std::fs::create_dir_all(&d).unwrap(); + d +} + +fn aura(args: &[&str], cwd: &Path) -> std::process::Output { + Command::new(env!("CARGO_BIN_EXE_aura")) + .args(args) + .current_dir(cwd) + .output() + .expect("spawn aura") +} + +#[test] +fn nodes_new_scaffolds_a_sibling_crate_and_attaches_it() { + let cwd = temp_cwd("attach"); + assert!(aura(&["new", "lab"], &cwd).status.success()); + let proj = cwd.join("lab"); + let engine = env!("CARGO_MANIFEST_DIR").to_string() + "/../.."; + let out = aura(&["nodes", "new", "lab-nodes", "--engine-path", &engine], &proj); + assert!(out.status.success(), "stderr: {}", String::from_utf8_lossy(&out.stderr)); + let stdout = String::from_utf8_lossy(&out.stdout); + assert!(stdout.contains("lab_nodes"), "names the namespace: {stdout}"); + let crate_dir = cwd.join("lab-nodes"); + assert!(crate_dir.join("Cargo.toml").is_file()); + assert!(crate_dir.join("src/lib.rs").is_file()); + assert!(!crate_dir.join("Aura.toml").exists(), "a node crate is not a project"); + let toml = std::fs::read_to_string(proj.join("Aura.toml")).unwrap(); + assert!(toml.contains("[nodes]"), "{toml}"); + assert!(toml.contains("../lab-nodes"), "{toml}"); +} + +/// Property (#241): `--namespace` sets the vocabulary prefix independently +/// of the crate's own (directory) name — a crate can be named anything, but +/// the blueprints that reference its nodes address them under the namespace +/// the flag chose, never a name silently derived from the crate name. This +/// is exercised transitively by the big sweep/campaign fixture +/// (`project_sweep_campaign.rs`, `--namespace knob_lab` on a `knob-lab-nodes` +/// crate) but a failure there would not cleanly bisect to this property; this +/// test isolates it. +#[test] +fn nodes_new_namespace_flag_overrides_crate_derived_default() { + let cwd = temp_cwd("ns-override"); + assert!(aura(&["new", "lab"], &cwd).status.success()); + let proj = cwd.join("lab"); + let engine = env!("CARGO_MANIFEST_DIR").to_string() + "/../.."; + let out = aura( + &["nodes", "new", "weird-crate-name", "--namespace", "custom_ns", "--engine-path", &engine], + &proj, + ); + assert!(out.status.success(), "stderr: {}", String::from_utf8_lossy(&out.stderr)); + let stdout = String::from_utf8_lossy(&out.stdout); + assert!(stdout.contains("custom_ns"), "reports the override namespace: {stdout}"); + assert!( + !stdout.contains("weird_crate_name"), + "must not fall back to the crate-derived default namespace: {stdout}" + ); + let lib = std::fs::read_to_string(cwd.join("weird-crate-name/src/lib.rs")).unwrap(); + assert!( + lib.contains("custom_ns::Scale") && lib.contains("namespace: \"custom_ns\""), + "the generated vocabulary is registered under the override namespace: {lib}" + ); + assert!( + !lib.contains("weird_crate_name"), + "the crate-derived name must not leak into the vocabulary: {lib}" + ); +} + +/// Property (#241): the single-crate attach guard checks for the *presence* +/// of a `[nodes]` section in `Aura.toml`, not merely a non-empty `crates` +/// list — a hand-edited `Aura.toml` carrying an empty `[nodes]` section still +/// refuses a second attach. Without this, `aura nodes new` would scaffold a +/// crate that `append_nodes_pointer` then refuses to wire in, leaving an +/// orphaned crate on disk. +#[test] +fn nodes_new_refuses_when_nodes_section_present_but_crates_empty() { + let cwd = temp_cwd("empty-section"); + assert!(aura(&["new", "lab"], &cwd).status.success()); + let proj = cwd.join("lab"); + let toml_path = proj.join("Aura.toml"); + let mut toml = std::fs::read_to_string(&toml_path).unwrap(); + toml.push_str("\n[nodes]\ncrates = []\n"); + std::fs::write(&toml_path, toml).unwrap(); + let engine = env!("CARGO_MANIFEST_DIR").to_string() + "/../.."; + let out = aura(&["nodes", "new", "x-nodes", "--engine-path", &engine], &proj); + assert_eq!(out.status.code(), Some(1)); + assert!( + String::from_utf8_lossy(&out.stderr).contains("already attached"), + "{}", + String::from_utf8_lossy(&out.stderr) + ); + assert!( + !cwd.join("x-nodes").exists(), + "the guard must fire before scaffolding the crate (no orphaned crate on disk)" + ); +} + +#[test] +fn a_second_nodes_new_refuses_single_crate() { + let cwd = temp_cwd("second"); + assert!(aura(&["new", "lab"], &cwd).status.success()); + let proj = cwd.join("lab"); + let engine = env!("CARGO_MANIFEST_DIR").to_string() + "/../.."; + assert!(aura(&["nodes", "new", "a-nodes", "--engine-path", &engine], &proj).status.success()); + let out = aura(&["nodes", "new", "b-nodes", "--engine-path", &engine], &proj); + assert_eq!(out.status.code(), Some(1)); + assert!( + String::from_utf8_lossy(&out.stderr).contains("already attached"), + "{}", String::from_utf8_lossy(&out.stderr) + ); +} + +#[test] +fn nodes_new_outside_a_project_refuses() { + let cwd = temp_cwd("outside"); + let out = aura(&["nodes", "new", "x-nodes"], &cwd); + assert_eq!(out.status.code(), Some(1)); + assert!( + String::from_utf8_lossy(&out.stderr).contains("needs a project"), + "{}", String::from_utf8_lossy(&out.stderr) + ); +} + +/// The full role-2 loop: scaffold, build, and the project resolves the +/// crate's namespace end to end (vocabulary listing names the node). +#[test] +fn attached_crate_namespace_resolves_after_build() { + let cwd = temp_cwd("resolve"); + assert!(aura(&["new", "lab"], &cwd).status.success()); + let proj = cwd.join("lab"); + let engine = env!("CARGO_MANIFEST_DIR").to_string() + "/../.."; + assert!(aura(&["nodes", "new", "lab-nodes", "--engine-path", &engine], &proj).status.success()); + let build = Command::new("cargo") + .arg("build") + .current_dir(cwd.join("lab-nodes")) + .output() + .expect("cargo build"); + assert!(build.status.success(), "{}", String::from_utf8_lossy(&build.stderr)); + let out = aura(&["graph", "introspect", "--vocabulary"], &proj); + assert!(out.status.success(), "stderr: {}", String::from_utf8_lossy(&out.stderr)); + assert!( + String::from_utf8_lossy(&out.stdout).contains("lab_nodes::Scale"), + "{}", String::from_utf8_lossy(&out.stdout) + ); +} diff --git a/crates/aura-cli/tests/project_sweep_campaign.rs b/crates/aura-cli/tests/project_sweep_campaign.rs index a8ad06a..941cda1 100644 --- a/crates/aura-cli/tests/project_sweep_campaign.rs +++ b/crates/aura-cli/tests/project_sweep_campaign.rs @@ -32,8 +32,11 @@ const GER40_TO_MS: &str = "1727740799999"; /// The OPEN blueprint: the scaffold's SMA-cross bias graph, but its terminal /// node is the project's OWN `knob_lab::Scale` (named `gain`), left UNBOUND — so /// `factor` is the one open sweep axis (`scaled_signal.gain.factor` wrapped / -/// `gain.factor` raw). `knob_lab` is the snake namespace `aura new knob-lab` -/// derives; `Scale` is the starter node every scaffold emits (#183). +/// `gain.factor` raw). Two-tier fixture (#241): `aura new knob-lab` scaffolds +/// the data-only project, and a sibling `knob-lab-nodes` node crate (attached +/// via `aura nodes new ... --namespace knob_lab`) supplies the `knob_lab` +/// vocabulary namespace — `Scale` is the starter node every node-crate +/// scaffold emits (#183). const OPEN_BLUEPRINT: &str = r#"{"format_version":1,"blueprint":{"name":"scaled_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":"knob_lab::Scale","name":"gain"}}],"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"}]}}"#; /// The minimal executable process pipeline (one sweep stage) — the campaign @@ -52,10 +55,14 @@ fn aura_in(dir: &Path, args: &[&str]) -> Output { .expect("run aura") } -/// Scaffold `knob-lab` once via `aura new`, cargo-build its cdylib, and drop the -/// OPEN blueprint into `blueprints/scaled_open.json`. Returns the project dir. -/// `OnceLock` so the expensive scaffold+build happens once per test binary; the -/// project's path-deps resolve into this checkout (the baked engine root), like +/// Scaffold `knob-lab` once via the two-tier project path (#241): a data-only +/// `aura new` project, a sibling `knob-lab-nodes` node crate attached via +/// `aura nodes new` (with `--namespace knob_lab` overriding the crate-derived +/// default so the blueprint's `knob_lab::` prefix stays byte-identical), then +/// `cargo build` the node crate. Drops the OPEN blueprint into +/// `blueprints/scaled_open.json`. Returns the project dir. `OnceLock` so the +/// expensive scaffold+build happens once per test binary; the node crate's +/// path-dep resolves into this checkout (the baked engine root), like /// `project_new.rs`. fn built_scale_project() -> &'static PathBuf { static BUILT: OnceLock = OnceLock::new(); @@ -72,14 +79,34 @@ fn built_scale_project() -> &'static PathBuf { ); let proj = base.join("knob-lab"); + let engine = env!("CARGO_MANIFEST_DIR").to_string() + "/../.."; + let nodes_new = aura_in( + &proj, + &[ + "nodes", + "new", + "knob-lab-nodes", + "--namespace", + "knob_lab", + "--engine-path", + &engine, + ], + ); + assert!( + nodes_new.status.success(), + "aura nodes new knob-lab-nodes failed: {}", + String::from_utf8_lossy(&nodes_new.stderr) + ); + let nodes_crate = base.join("knob-lab-nodes"); + let build = Command::new("cargo") .arg("build") - .current_dir(&proj) + .current_dir(&nodes_crate) .output() - .expect("cargo build the scaffolded project"); + .expect("cargo build the scaffolded node crate"); assert!( build.status.success(), - "scaffolded project build failed:\n{}", + "scaffolded node crate build failed:\n{}", String::from_utf8_lossy(&build.stderr) );