diff --git a/docs/plans/0103-aura-new-scaffolder.md b/docs/plans/0103-aura-new-scaffolder.md new file mode 100644 index 0000000..7ec7e5f --- /dev/null +++ b/docs/plans/0103-aura-new-scaffolder.md @@ -0,0 +1,666 @@ +# The `aura new` scaffolder — Implementation Plan + +> **Parent spec:** `docs/specs/0103-aura-new-scaffolder.md` +> +> **For agentic workers:** REQUIRED SUB-SKILL: use the `implement` skill to +> run this plan. Steps use `- [ ]` checkboxes for tracking. + +**Goal:** `aura new ` emits a complete project crate (the cycle-0102 +fixture shape, parameterized) that builds and runs a blueprint with zero +hand-editing; the verb bypasses the eager project-load so it works inside +unbuilt trees. + +**Architecture:** one new `scaffold` module in aura-cli (validation + +token-replace templates + emit + best-effort git init), one new `Command::New` +variant with a `dispatch_new`, and a 3-line load-bypass guard in `main()`. +Everything else is untouched — the verb is additive. + +**Tech Stack:** aura-cli only. No new dependencies (`git init` via +`std::process::Command`, engine root via `env!("CARGO_MANIFEST_DIR")`). + +**Files this plan creates or modifies:** + +- Create: `crates/aura-cli/src/scaffold.rs` — validation, templates, emit +- Create: `crates/aura-cli/tests/project_new.rs` — authoring-loop e2e + refusals +- Modify: `crates/aura-cli/src/main.rs:14-16` (mod block), `:3742-3762` + (Command enum + NewCmd beside the other `*Cmd` structs), `:4638-4662` + (load-bypass guard + dispatch arm + `dispatch_new`) +- Test: unit tests inside `scaffold.rs`; e2e in `tests/project_new.rs` + +--- + +### Task 1: the scaffold module (validation + templates + emit) + +**Files:** +- Create: `crates/aura-cli/src/scaffold.rs` +- Modify: `crates/aura-cli/src/main.rs:14-16` (add `mod scaffold;`) + +- [ ] **Step 1: Create `crates/aura-cli/src/scaffold.rs`** + +```rust +//! `aura new` — scaffold a research project crate (cycle 0103, C16/C17). +//! +//! Emits the cycle-0102 fixture shape parameterized by name/namespace: a +//! cdylib crate with path-deps on the engine checkout (the baked default or +//! `--engine-path`), a paths-only `Aura.toml`, a sample node + runnable +//! blueprint, and a project `CLAUDE.md`. The scaffolder never builds and +//! never loads (the author builds; `aura new` bypasses the project-load +//! phase in `main()`). +//! +//! Templates are raw strings with `__NS__` / `__NAME__` / `__NAME_SNAKE__` / +//! `__ENGINE__` tokens substituted by plain `.replace()` — no format-string +//! brace escaping in Rust template bodies. + +use std::path::{Path, PathBuf}; + +pub struct ScaffoldSpec { + pub name: String, + pub namespace: String, + pub engine_root: PathBuf, + pub target_dir: PathBuf, +} + +fn valid_name(s: &str) -> bool { + !s.is_empty() + && !s.starts_with(|c: char| c.is_ascii_digit()) + && s.chars().all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-') +} + +fn valid_namespace(s: &str) -> bool { + !s.is_empty() + && !s.starts_with(|c: char| c.is_ascii_digit()) + && s.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') +} + +/// The name→namespace / name→blueprint-name mapping: dashes become +/// underscores (mirrors cargo's crate-name normalization). +pub fn snake(name: &str) -> String { + name.replace('-', "_") +} + +/// 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. +pub fn default_engine_root() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .ancestors() + .nth(2) + .expect("aura-cli manifest dir has two ancestors") + .to_path_buf() +} + +/// Validate the argv-level inputs. `Err` is a usage fault — the caller +/// exits 2 (C14 partition). +pub fn scaffold_spec( + name: &str, + engine_path: Option<&Path>, + namespace: Option<&str>, + cwd: &Path, +) -> Result { + if !valid_name(name) { + return Err(format!( + "invalid project name `{name}` (allowed: letters, digits, `_`, `-`; no leading digit)" + )); + } + let namespace = match namespace { + Some(ns) if !valid_namespace(ns) => { + return Err(format!( + "invalid namespace `{ns}` (allowed: letters, digits, `_`; no leading digit)" + )); + } + Some(ns) => ns.to_string(), + None => snake(name), + }; + Ok(ScaffoldSpec { + name: name.to_string(), + namespace, + engine_root: engine_path + .map(Path::to_path_buf) + .unwrap_or_else(default_engine_root), + target_dir: cwd.join(name), + }) +} + +// ---------------------------------------------------------------- templates + +const CARGO_TOML: &str = r#"[package] +name = "__NAME__" +version = "0.1.0" +edition = "2024" +publish = false + +[lib] +crate-type = ["cdylib"] + +[dependencies] +aura-core = { path = "__ENGINE__/crates/aura-core" } + +# Standalone workspace root: never a member of an enclosing workspace. +[workspace] +"#; + +const AURA_TOML: &str = r#"# Static project context only (C17); paths only. +[paths] +runs = "runs" +"#; + +const GITIGNORE: &str = "/target\nCargo.lock\n/runs\n"; + +const LIB_RS: &str = r#"//! __NAME__ — an aura research project: node logic + the exported vocabulary. +//! +//! Every node type this crate provides is registered in `vocabulary()` / +//! `type_ids()` under the `__NS__::` namespace prefix; the `aura` host loads +//! this cdylib per invocation and merges it with the std vocabulary. + +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( + "__NS__::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 { + 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 { + "__NS__::Identity".to_string() + } +} + +fn vocabulary(type_id: &str) -> Option { + match type_id { + "__NS__::Identity" => Some(Identity::builder()), + _ => None, + } +} + +fn type_ids() -> &'static [&'static str] { + &["__NS__::Identity"] +} + +aura_core::aura_project! { + namespace: "__NS__", + vocabulary: vocabulary, + type_ids: type_ids, +} +"#; + +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__::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"}]}}"#; + +const CLAUDE_MD: &str = r#"# __NAME__ — an aura research project + +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. +"#; + +fn render(template: &str, spec: &ScaffoldSpec) -> String { + template + .replace("__NAME_SNAKE__", &snake(&spec.name)) + .replace("__NAME__", &spec.name) + .replace("__NS__", &spec.namespace) + .replace("__ENGINE__", &spec.engine_root.display().to_string()) +} + +// --------------------------------------------------------------------- emit + +/// Emit the project. `Err` is a runtime refusal — the caller exits 1 (C14 +/// partition). All refusals fire before the first write. +pub fn scaffold(spec: &ScaffoldSpec) -> Result<(), String> { + if spec.target_dir.exists() { + return Err(format!( + "destination `{}` already exists", + spec.target_dir.display() + )); + } + if !spec.engine_root.join("crates/aura-core").is_dir() { + return Err(format!( + "engine checkout not found at `{}` — pass --engine-path ", + spec.engine_root.display() + )); + } + let write = |rel: &str, body: String| -> Result<(), String> { + let path = spec.target_dir.join(rel); + if let Some(dir) = path.parent() { + std::fs::create_dir_all(dir) + .map_err(|e| format!("creating `{}`: {e}", dir.display()))?; + } + 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("src/lib.rs", render(LIB_RS, spec))?; + write("blueprints/signal.json", render(SIGNAL_JSON, spec))?; + write("CLAUDE.md", render(CLAUDE_MD, spec))?; + // Best-effort git init: a warning, never an error (cargo-new mirror). + 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)"), + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn spec_for(name: &str, ns: Option<&str>) -> ScaffoldSpec { + scaffold_spec(name, None, ns, Path::new("/tmp/x")).expect("valid spec") + } + + #[test] + fn name_and_namespace_validation_table() { + assert!(scaffold_spec("ger40-lab", None, None, Path::new("/t")).is_ok()); + assert!(scaffold_spec("x_1", None, None, Path::new("/t")).is_ok()); + for bad in ["", "bad/name", "9lives", "a b", "ä"] { + let e = scaffold_spec(bad, None, None, Path::new("/t")).unwrap_err(); + assert!(e.contains("invalid project name"), "{bad}: {e}"); + } + for bad_ns in ["", "with-dash", "9x", "a::b"] { + let e = scaffold_spec("ok", None, Some(bad_ns), Path::new("/t")).unwrap_err(); + assert!(e.contains("invalid namespace"), "{bad_ns}: {e}"); + } + } + + #[test] + fn namespace_defaults_to_snake_name() { + assert_eq!(spec_for("ger40-lab", None).namespace, "ger40_lab"); + assert_eq!(spec_for("ger40-lab", Some("ger40")).namespace, "ger40"); + assert_eq!(snake("a-b-c"), "a_b_c"); + } + + #[test] + fn default_engine_root_is_this_checkout() { + assert!(default_engine_root().join("crates/aura-core").is_dir()); + } + + #[test] + fn templates_render_without_leftover_tokens() { + let spec = spec_for("demo-lab", None); + for t in [CARGO_TOML, AURA_TOML, GITIGNORE, LIB_RS, SIGNAL_JSON, CLAUDE_MD] { + let out = render(t, &spec); + assert!(!out.contains("__"), "unsubstituted token in: {out}"); + } + let lib = render(LIB_RS, &spec); + assert!(lib.contains("\"demo_lab::Identity\"")); + assert!(lib.contains("namespace: \"demo_lab\"")); + let json = render(SIGNAL_JSON, &spec); + assert!(json.contains("\"type\":\"demo_lab::Identity\"")); + 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")); + } + + #[test] + fn scaffold_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(); + 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(); + assert!(e.contains("--engine-path"), "{e}"); + std::fs::remove_dir_all(&tmp).ok(); + } +} +``` + +- [ ] **Step 2: Register the module** + +In `crates/aura-cli/src/main.rs`, in the mod block (lines 14-16, currently +`mod render;` / `mod graph_construct;` / `mod project;`), add: + +```rust +mod scaffold; +``` + +- [ ] **Step 3: Run the unit tests** + +Run: `cargo test -p aura-cli scaffold::` +Expected: PASS — `name_and_namespace_validation_table`, +`namespace_defaults_to_snake_name`, `default_engine_root_is_this_checkout`, +`templates_render_without_leftover_tokens`, +`scaffold_refuses_existing_dir_and_missing_engine`. (Interim dead-code +warnings are expected until Task 2 wires the verb; the plain build stays +warning-tolerant and Task 4 runs the clippy gate.) + +--- + +### Task 2: CLI wiring (`Command::New`, dispatch, load-bypass guard) + +**Files:** +- Modify: `crates/aura-cli/src/main.rs:3742-3762` (Command enum), beside the + other `*Cmd` structs (~3764), `:4638-4662` (main), beside the other + `dispatch_*` fns (~4236) + +- [ ] **Step 1: Add the variant and the args struct** + +In `enum Command` (main.rs:3742-3762), after the `Reproduce(...)` variant add: + +```rust + /// Scaffold a new research project crate. + New(NewCmd), +``` + +Beside the other `*Cmd` structs (e.g. after `ChartCmd`), add: + +```rust +#[derive(clap::Args)] +struct NewCmd { + /// Directory / crate name to create. + name: String, + /// Engine checkout the project depends on (path deps). Default: the + /// engine root this binary was built from. + #[arg(long)] + engine_path: Option, + /// Vocabulary namespace (default: the name with dashes as underscores). + #[arg(long)] + namespace: Option, +} +``` + +- [ ] **Step 2: The load-bypass guard in `main()`** + +At main.rs:4639-4651, the current shape is: + +```rust + let env = match std::env::current_dir() + .ok() + .and_then(|d| project::discover_from(&d)) + { + Some(root) => match project::load(&root, cli.release) { + Ok(p) => project::Env::with_project(p), + Err(e) => { + eprintln!("aura: {e}"); + std::process::exit(1); + } + }, + None => project::Env::std(), + }; +``` + +Wrap it (the inner load arm stays byte-identical): + +```rust + // `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(_)) { + project::Env::std() + } else { + match std::env::current_dir() + .ok() + .and_then(|d| project::discover_from(&d)) + { + Some(root) => match project::load(&root, cli.release) { + Ok(p) => project::Env::with_project(p), + Err(e) => { + eprintln!("aura: {e}"); + std::process::exit(1); + } + }, + None => project::Env::std(), + } + }; +``` + +- [ ] **Step 3: The dispatch arm and `dispatch_new`** + +In the `match cli.command` block (4652-4662), add the arm: + +```rust + Command::New(a) => dispatch_new(a, &env), +``` + +Beside the other `dispatch_*` fns, add: + +```rust +fn dispatch_new(a: NewCmd, _env: &project::Env) { + let cwd = std::env::current_dir().unwrap_or_else(|e| { + eprintln!("aura: {e}"); + std::process::exit(1); + }); + let spec = scaffold::scaffold_spec( + &a.name, + a.engine_path.as_deref(), + a.namespace.as_deref(), + &cwd, + ) + .unwrap_or_else(|m| { + eprintln!("aura: {m}"); + std::process::exit(2); + }); + scaffold::scaffold(&spec).unwrap_or_else(|m| { + eprintln!("aura: {m}"); + std::process::exit(1); + }); + println!( + "created project `{}` (namespace `{}`)", + spec.name, spec.namespace + ); +} +``` + +- [ ] **Step 4: Compile gate + smoke** + +Run: `cargo build --workspace` +Expected: 0 errors (the dispatch match is the one exhaustiveness site). + +Run: `cargo test -p aura-cli --test cli_run` +Expected: PASS unchanged (the verb is additive; no existing pin moves). + +--- + +### Task 3: the authoring-loop e2e + +**Files:** +- Create: `crates/aura-cli/tests/project_new.rs` + +- [ ] **Step 1: Create `crates/aura-cli/tests/project_new.rs`** + +```rust +//! E2E: the `aura new` scaffolder (cycle 0103). Proves the full authoring +//! loop — scaffold → cargo build → aura run — with zero hand-editing, plus +//! the refusal contract. Scaffolded projects live in per-test temp dirs; +//! their path-deps resolve into this checkout (the baked engine root). + +use std::path::{Path, PathBuf}; +use std::process::{Command, Output}; + +fn aura(args: &[&str], cwd: &Path) -> Output { + Command::new(env!("CARGO_BIN_EXE_aura")) + .args(args) + .current_dir(cwd) + .output() + .expect("run aura") +} + +fn tmp(tag: &str) -> PathBuf { + let d = std::env::temp_dir().join(format!("aura-new-{tag}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&d); + std::fs::create_dir_all(&d).unwrap(); + d +} + +#[test] +fn new_scaffold_builds_and_runs_the_authoring_loop() { + let base = tmp("loop"); + let out = aura(&["new", "demo-lab"], &base); + assert!( + out.status.success(), + "aura new failed: {}", + String::from_utf8_lossy(&out.stderr) + ); + let proj = base.join("demo-lab"); + for f in [ + "Cargo.toml", + "Aura.toml", + ".gitignore", + "src/lib.rs", + "blueprints/signal.json", + "CLAUDE.md", + ] { + assert!(proj.join(f).is_file(), "missing scaffolded file {f}"); + } + let build = Command::new("cargo") + .arg("build") + .current_dir(&proj) + .output() + .expect("cargo build scaffolded project"); + assert!( + build.status.success(), + "scaffolded build failed:\n{}", + String::from_utf8_lossy(&build.stderr) + ); + let a = aura(&["run", "blueprints/signal.json"], &proj); + assert!( + a.status.success(), + "run failed: {}", + String::from_utf8_lossy(&a.stderr) + ); + let b = aura(&["run", "blueprints/signal.json"], &proj); + assert_eq!(a.stdout, b.stdout, "two runs must be byte-identical (C1)"); + let v: serde_json::Value = serde_json::from_slice(&a.stdout).expect("report is JSON"); + assert_eq!(v["manifest"]["project"]["namespace"], "demo_lab"); + let introspect = aura(&["graph", "introspect", "--vocabulary"], &proj); + assert!(introspect.status.success()); + let text = String::from_utf8_lossy(&introspect.stdout); + assert!(text.contains("demo_lab::Identity"), "project id listed"); + let _ = std::fs::remove_dir_all(&base); +} + +#[test] +fn new_refuses_an_existing_destination() { + let base = tmp("exists"); + std::fs::create_dir_all(base.join("taken")).unwrap(); + let out = aura(&["new", "taken"], &base); + assert_eq!(out.status.code(), Some(1), "runtime refusal"); + let err = String::from_utf8_lossy(&out.stderr); + assert!(err.contains("already exists"), "stderr: {err}"); + let _ = std::fs::remove_dir_all(&base); +} + +#[test] +fn new_rejects_invalid_names_as_usage_faults() { + let base = tmp("badname"); + for bad in ["bad/name", "9lives"] { + let out = aura(&["new", bad], &base); + assert_eq!(out.status.code(), Some(2), "usage fault for {bad}"); + let err = String::from_utf8_lossy(&out.stderr); + assert!(err.contains("invalid project name"), "stderr: {err}"); + } + let _ = std::fs::remove_dir_all(&base); +} + +#[test] +fn new_refuses_a_missing_engine_path_with_hint() { + let base = tmp("noengine"); + let out = aura(&["new", "x", "--engine-path", "/nonexistent-engine"], &base); + assert_eq!(out.status.code(), Some(1), "runtime refusal"); + let err = String::from_utf8_lossy(&out.stderr); + assert!(err.contains("--engine-path"), "stderr: {err}"); + let _ = std::fs::remove_dir_all(&base); +} + +#[test] +fn new_works_inside_an_unbuilt_project_tree() { + // Scaffold once (unbuilt), then scaffold AGAIN from inside that tree: + // the load-bypass guard must keep aura new from trying to load the + // enclosing project's (absent) dylib. + let base = tmp("nested"); + let out = aura(&["new", "outer"], &base); + assert!(out.status.success()); + let inner_cwd = base.join("outer"); + let out = aura(&["new", "inner"], &inner_cwd); + assert!( + out.status.success(), + "aura new inside an unbuilt project tree must succeed: {}", + String::from_utf8_lossy(&out.stderr) + ); + assert!(inner_cwd.join("inner/Cargo.toml").is_file()); + let _ = std::fs::remove_dir_all(&base); +} +``` + +- [ ] **Step 2: Run the e2e suite** + +Run: `cargo test -p aura-cli --test project_new` +Expected: PASS, all five tests (the first pays the scaffolded project's +cargo build). + +--- + +### Task 4: full regression gate + +**Files:** none (verification only) + +- [ ] **Step 1: workspace build + suite** + +Run: `cargo build --workspace` +Expected: 0 errors. + +Run: `cargo test --workspace` +Expected: PASS — every pre-existing test green unchanged plus the new +scaffold unit tests and the five project_new e2e tests. + +- [ ] **Step 2: lint + docs** + +Run: `cargo clippy --workspace --all-targets -- -D warnings` +Expected: clean. + +Run: `cargo doc --workspace --no-deps 2>&1 | grep -c warning` +Expected: `0`.