diff --git a/docs/design/INDEX.md b/docs/design/INDEX.md index 57eefd5..6ec4053 100644 --- a/docs/design/INDEX.md +++ b/docs/design/INDEX.md @@ -1986,10 +1986,11 @@ construction-arg builders (`LinComb` / `CostSum` / `SimBroker` / `Session` — structural-axis args, a C20 concern), additively addable later (#156). The **project-as-crate load boundary landed in cycle 0102** (`Aura.toml` discovery + `cdylib` loading + merged project ∪ std vocabulary — see the C13 realization -note); of the two layers this paragraph used to name as sequencing-coupled, -what remains open is the `aura new` scaffolder (the milestone's next cycle) and -the **composable-orchestration** thread (#109): topology-as-data is the -substrate both stand on. +note; the `aura new` scaffolder followed in cycle 0103 — one command emits a +buildable project whose blueprint runs through the merged vocabulary); of the +two layers this paragraph used to name as sequencing-coupled, what remains +open is the **composable-orchestration** thread (#109): topology-as-data is +the substrate it stands on. **Op-script grammar (`aura graph build`, the #157 wire surface).** The construction op-script is a JSON **array of ops**, each object internally tagged by `"op"`, @@ -2088,8 +2089,9 @@ artifact, and any run is deterministic once instantiated" (C1). a user wires into an analysis workflow, rather than today's hard-wired CLI verbs (`sweep_family` / `walkforward_family` in `aura-cli`) — still open; and (2) the project-as-crate authoring layer above — its **load boundary landed in cycle 0102** - (`Aura.toml` discovery + cdylib loading + merged vocabulary, C13 realization note; - the `aura new` scaffolder and the experiment-builder API remain open, C16/C17). + (`Aura.toml` discovery + cdylib loading + merged vocabulary, C13 realization note) + and the **`aura new` scaffolder in cycle 0103**; of that layer only the + experiment-builder API remains open (C16/C17). On the meta-level, authoring a strategy is *wiring existing nodes* (C9 composition) + *defining the analysis framework* (C21), not writing new nodes; the user wants it driven via the CLI, later the interactive diff --git a/docs/plans/0103-aura-new-scaffolder.md b/docs/plans/0103-aura-new-scaffolder.md deleted file mode 100644 index 022c6ed..0000000 --- a/docs/plans/0103-aura-new-scaffolder.md +++ /dev/null @@ -1,667 +0,0 @@ -# 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}; - -#[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 (`/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`. diff --git a/docs/project-layout.md b/docs/project-layout.md index cb59d35..38652d8 100644 --- a/docs/project-layout.md +++ b/docs/project-layout.md @@ -57,8 +57,9 @@ ger40-lab/ # your research project — a separate Rust crate (cd > > The fix is cargo's own hint: an empty `[workspace]` table in the project's > `Cargo.toml`. It pins the crate as a standalone workspace root and costs -> nothing when the project later sits in its own repo. The future `aura new` -> scaffolder will emit this line; until then, add it by hand. +> nothing when the project later sits in its own repo. The `aura new` +> scaffolder emits this line (cycle 0103); a hand-rolled project adds it +> itself. ## Where reusable nodes live (three tiers) diff --git a/docs/specs/0103-aura-new-scaffolder.md b/docs/specs/0103-aura-new-scaffolder.md deleted file mode 100644 index 7c4d48e..0000000 --- a/docs/specs/0103-aura-new-scaffolder.md +++ /dev/null @@ -1,281 +0,0 @@ -# The `aura new` scaffolder — Design Spec - -**Date:** 2026-07-02 -**Status:** Draft — awaiting sign-off (boss run: grounding-check gate) -**Authors:** orchestrator + Claude -**Cycle:** 0103 — second implementation cycle of milestone "Project environment -— the project-as-crate authoring loop" (#180; fork decisions for this cycle are -the cycle-0103 reconciliation comment on #180) - -## Goal - -`aura new ` scaffolds a complete, immediately usable research project: -after the one command, `cargo build` succeeds and `aura run -blueprints/signal.json` runs a real blueprint through the project's own -loaded vocabulary — the full authoring loop (edit → build → run) with zero -hand-editing. The emitted files are the cycle-0102 fixture -(`crates/aura-cli/tests/fixtures/demo-project/`) parameterized by project -name and namespace. - -## Architecture - -One new subcommand in `aura-cli` (`Command::New` + `dispatch_new`), a -`scaffold` module that renders the fixed file set from templates -(format-string parameterization; no template engine), and one load-order -carve-out in `main()`: **`aura new` bypasses the project-load phase** — -scaffolding must work inside an unbuilt or foreign project tree, so the -eager `discover→load` step added in cycle 0102 is skipped for exactly this -verb (an `aura new` invocation never needs a vocabulary). - -Decisions carried from the #180 reconciliation comment (cycle 0103): - -- **Engine dependency = path deps on the engine checkout.** Default root: - the engine root baked into the binary at compile time - (`env!("CARGO_MANIFEST_DIR")` of aura-cli, two ancestors up), overridable - via `--engine-path`; a default that no longer exists on disk refuses with - the `--engine-path` hint (never a silently broken scaffold). Rationale - (recorded on #180): unpublished proprietary engine; a git-URL dep embeds a - host and can pin a revision whose aura-core diverges from the host binary - (provoking the C13 stamp refusal); the path dep makes the stamp match hold - by construction. -- **Namespace default = project name, dashes → underscores** (`ger40-lab` → - `ger40_lab`), overridable via `--namespace`. The load-time charter - validates it anyway. -- **cargo-new-mirror conventions:** refuse an existing target directory; - best-effort `git init`; no `cargo build` inside the scaffolder (the author - builds — the cycle-0102 "aura only loads" decision). - -## Concrete code shapes - -### The user-facing loop (the acceptance-criterion evidence) - -```console -$ aura new ger40-lab -created project `ger40-lab` (namespace `ger40_lab`) -$ cd ger40-lab && cargo build -$ aura run blueprints/signal.json # resolves ger40_lab::Identity -$ $EDITOR src/lib.rs # author a real node -$ cargo build && aura run blueprints/signal.json # the authoring loop -``` - -Emitted tree: - -``` -ger40-lab/ -├── Aura.toml # [paths] runs = "runs" -├── Cargo.toml # cdylib; path-deps on the engine; empty [workspace] -├── CLAUDE.md # minimal project authoring notes -├── .gitignore # /target, Cargo.lock, /runs -├── blueprints/ -│ └── signal.json # runnable demo blueprint using ::Identity -└── src/ - └── lib.rs # Identity node + vocabulary + aura_project! export -``` - -### Emitted file contents (templates, parameterized by `{name}`, `{ns}`, `{engine}`) - -`Cargo.toml`: - -```toml -[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] -``` - -`Aura.toml`: - -```toml -# Static project context only (C17); paths only (cycle 0102). -[paths] -runs = "runs" -``` - -`.gitignore`: - -```gitignore -/target -Cargo.lock -/runs -``` - -`src/lib.rs` — the cycle-0102 fixture's `lib.rs` verbatim with -`demo::Identity` → `{ns}::Identity` and namespace `"demo"` → `"{ns}"` -(same `Identity` node, `vocabulary` fn, `type_ids` fn, `aura_project!` -invocation; the fixture is a green, tested source — not aspirational code). - -`blueprints/signal.json` — the fixture's `demo_signal.json` verbatim with -`"demo::Identity"` → `"{ns}::Identity"` and the blueprint name -`"demo_signal"` → `"{name_snake}_signal"` (`{name_snake}` = the project name -with dashes mapped to underscores — the same mapping as the namespace -default). - -`CLAUDE.md`: - -```markdown -# {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. -``` - -### CLI shape (before → after, secondary) - -`Command` enum (aura-cli `main.rs`) gains: - -```rust - /// Scaffold a new research project crate. - New(NewCmd), -``` - -```rust -#[derive(clap::Args)] -struct NewCmd { - /// Directory / crate name to create. - name: String, - /// Engine checkout the project depends on (path deps). - #[arg(long)] - engine_path: Option, - /// Vocabulary namespace (default: name with dashes as underscores). - #[arg(long)] - namespace: Option, -} -``` - -`main()` load-order carve-out — before: - -```rust - let env = match std::env::current_dir().ok().and_then(|d| project::discover_from(&d)) { - Some(root) => match project::load(&root, cli.release) { ... }, - None => project::Env::std(), - }; -``` - -After (only the guard is new; the load arm is byte-unchanged): - -```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) { ... }, - None => project::Env::std(), - } - }; -``` - -New module `aura-cli/src/scaffold.rs`: - -```rust -pub struct ScaffoldSpec { - pub name: String, // validated: [A-Za-z0-9_-]+, no leading digit - pub namespace: String, // validated: [A-Za-z0-9_]+, no leading digit - pub engine_root: PathBuf, - pub target_dir: PathBuf, // cwd/ -} - -/// Validate argv-level inputs (bad name/namespace = usage fault, exit 2). -pub fn scaffold_spec(cmd: &NewCmd) -> Result; - -/// Emit the file set; every failure is a runtime refusal (exit 1): -/// target dir exists, engine root missing (with --engine-path hint), -/// io errors. Ends with a best-effort `git init` (a warning on failure, -/// never an error). -pub fn scaffold(spec: &ScaffoldSpec) -> Result<(), String>; - -/// The compile-time default engine root: two ancestors above aura-cli's -/// manifest dir. A binary running on the box it was built on resolves to -/// the live checkout; anywhere else the existence check refuses. -pub fn default_engine_root() -> PathBuf; // env!("CARGO_MANIFEST_DIR")/../.. -``` - -## Components - -| Component | Crate | New/changed | -|---|---|---| -| `Command::New` + `NewCmd` + `dispatch_new` | aura-cli | new variant + fn | -| `scaffold` module (templates + validation + emit) | aura-cli | new module | -| `main()` New-bypass guard | aura-cli | 3-line edit | -| e2e `tests/project_new.rs` | aura-cli | new test file | - -No engine/registry/analysis crate is touched. No new dependencies (`git init` -via subprocess, matching the build.rs git-subprocess precedent). - -## Data flow - -``` -aura new [--engine-path P] [--namespace NS] - → validate name/ns (usage fault → exit 2) - → engine root = P | baked default; must exist (else exit 1 + hint) - → target dir cwd/; must not exist (else exit 1) - → emit Cargo.toml/Aura.toml/.gitignore/src/lib.rs/blueprints/signal.json/CLAUDE.md - → best-effort git init - → print "created project `` (namespace ``)" -``` - -## Error handling - -- Invalid `name` / `--namespace` (empty, path separators, leading digit, - chars outside `[A-Za-z0-9_-]` / `[A-Za-z0-9_]`) → **exit 2** (argv fault, - C14 partition), message naming the offending value. -- Target directory already exists → **exit 1** (environment state), message - naming the path. -- Engine root absent (baked default on a foreign box, or a wrong - `--engine-path`) → **exit 1**, message naming the missing path and the - `--engine-path` flag. -- `git init` failure → a `warning:` line on stderr, exit unaffected. -- No partial-tree cleanup promise: refusals happen before the first write - (all validation precedes emission); an io error mid-emit leaves the - partial dir for inspection, named in the message. - -## Testing strategy - -1. **Unit (scaffold module):** name/namespace validation table (valid, - empty, `a/b`, leading digit, dash-vs-underscore rules); namespace - derivation (`ger40-lab` → `ger40_lab`); `default_engine_root` ends in - the checkout root (its `crates/aura-core` exists in this workspace); - template rendering contains the parameterized ids (`{ns}::Identity` in - lib.rs and signal.json bytes). -2. **E2E (`tests/project_new.rs`, the authoring-loop proof):** in a temp - dir, `aura new demo-lab`; assert the six files exist; `cargo build` in - the scaffolded dir (path-deps resolve into this checkout); `aura run - blueprints/signal.json` from inside it exits 0, twice byte-identically - (C1), with `manifest.project.namespace == "demo_lab"`; `aura graph - introspect --vocabulary` lists `demo_lab::Identity`. -3. **Refusals (e2e):** second `aura new demo-lab` in the same cwd → exit 1 - naming the dir; `aura new "bad/name"` → exit 2; `aura new x - --engine-path /nonexistent` → exit 1 with the hint; `aura new` inside an - UNBUILT scaffolded project dir succeeds (the load-bypass guard). -4. **Regression:** full workspace suite green unchanged (the verb is - additive; no existing path is touched beyond the 3-line main() guard). - -## Acceptance criteria - -1. `aura new ` → `cargo build` → `aura run blueprints/signal.json` - succeeds end-to-end with zero manual edits, bit-deterministically, and - the run's manifest records the project provenance under the derived - namespace. -2. Every refusal in Error handling fires with the documented exit class and - a message naming the cause; `aura new` works inside an unbuilt project - tree (no project load precedes scaffolding). -3. The emitted file set byte-matches the templates (fixture-derived), with - name/namespace/engine-path parameterization and nothing else varying. -4. The full existing suite stays green unchanged.