Files
Aura/docs/plans/0103-aura-new-scaffolder.md
T
Brummel 06ffc4f7a2 plan: 0103 fix — derive(Debug) on ScaffoldSpec
The Task-1 unit tests call unwrap_err() on Result<ScaffoldSpec, String>,
which requires ScaffoldSpec: Debug; the prescribed struct lacked the
derive, so the plan's own bytes could not compile. The implement loop
correctly blocked on the necessary deviation (spec-compliance holds
verbatim plan code); this aligns the plan with the only compiling form.

refs #180
2026-07-02 18:44:58 +02:00

22 KiB

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 <name> 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

//! `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};

#[derive(Debug)]
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 (`<root>/crates/aura-cli` → `<root>`). 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<ScaffoldSpec, String> {
    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<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 {
        "__NS__::Identity".to_string()
    }
}

fn vocabulary(type_id: &str) -> Option<PrimitiveBuilder> {
    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 <engine checkout>",
            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:

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:

    /// Scaffold a new research project crate.
    New(NewCmd),

Beside the other *Cmd structs (e.g. after ChartCmd), add:

#[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<std::path::PathBuf>,
    /// Vocabulary namespace (default: the name with dashes as underscores).
    #[arg(long)]
    namespace: Option<String>,
}
  • Step 2: The load-bypass guard in main()

At main.rs:4639-4651, the current shape is:

    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):

    // `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:

        Command::New(a) => dispatch_new(a, &env),

Beside the other dispatch_* fns, add:

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

//! 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.