feat(cli): aura nodes new — node-crate scaffold + attach (#241 T5-T6)

- New clap family `aura nodes new <name> [--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 = ["../<name>"]` 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
  <ns>::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
This commit is contained in:
2026-07-12 17:08:47 +02:00
parent 3932333e19
commit 23bb978bf2
5 changed files with 329 additions and 71 deletions
+94 -3
View File
@@ -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<std::path::PathBuf>,
/// Vocabulary namespace override.
#[arg(long)]
namespace: Option<String>,
}
#[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),
}
-5
View File
@@ -216,11 +216,6 @@ fn parse_aura_toml(root: &Path) -> Result<AuraToml, ProjectError> {
/// 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)
}
+47 -54
View File
@@ -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 (`<root>/crates/aura-cli` → `<root>`). 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<ProjectScaffoldSp
/// Validate the argv-level inputs. `Err` is a usage fault — the caller
/// exits 2 (C14 partition).
#[allow(dead_code)] // #241: `aura nodes new` (next task) becomes the caller
pub fn scaffold_spec(
name: &str,
engine_path: Option<&Path>,
@@ -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();
}
+152
View File
@@ -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)
);
}
@@ -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<PathBuf> = 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)
);