diff --git a/crates/aura-cli/src/main.rs b/crates/aura-cli/src/main.rs index db0e771..ca5d6be 100644 --- a/crates/aura-cli/src/main.rs +++ b/crates/aura-cli/src/main.rs @@ -2475,15 +2475,8 @@ struct ChartCmd { #[derive(Args)] struct NewCmd { - /// Directory / crate name to create. + /// Directory 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, } #[derive(Args)] @@ -3325,23 +3318,17 @@ fn dispatch_new(a: NewCmd, _env: &project::Env) { 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| { + let spec = scaffold::project_scaffold_spec(&a.name, &cwd).unwrap_or_else(|m| { eprintln!("aura: {m}"); std::process::exit(2); }); - scaffold::scaffold(&spec).unwrap_or_else(|m| { + scaffold::scaffold_project(&spec).unwrap_or_else(|m| { eprintln!("aura: {m}"); std::process::exit(1); }); println!( - "created project `{}` (namespace `{}`)", - spec.name, spec.namespace + "created project \"{}\" (data-only; attach native nodes later with `aura nodes new`)", + spec.name ); } diff --git a/crates/aura-cli/src/scaffold.rs b/crates/aura-cli/src/scaffold.rs index 08e4e21..1ea86dc 100644 --- a/crates/aura-cli/src/scaffold.rs +++ b/crates/aura-cli/src/scaffold.rs @@ -1,11 +1,14 @@ -//! `aura new` — scaffold a research project crate (cycle 0103, C16/C17). +//! `aura new` — scaffold a data-only research project (#241). //! -//! 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()`). +//! `aura new` emits the data-only tier: a paths-only `Aura.toml`, a runnable +//! starter blueprint over the std vocabulary, `.gitignore`, and `CLAUDE.md` — +//! no crate, no build step. The scaffolder never builds and never loads (the +//! 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. //! //! Templates are raw strings with `__NS__` / `__NAME__` / `__NAME_SNAKE__` / //! `__ENGINE__` tokens substituted by plain `.replace()` — no format-string @@ -50,8 +53,27 @@ pub fn default_engine_root() -> PathBuf { .to_path_buf() } +/// The data-only project scaffold (#241): no crate, no build step. +#[derive(Debug)] +pub struct ProjectScaffoldSpec { + pub name: String, + pub target_dir: PathBuf, +} + +/// Validate the argv-level inputs for the data-only project scaffold. `Err` +/// is a usage fault — the caller exits 2 (C14 partition). +pub fn project_scaffold_spec(name: &str, cwd: &Path) -> Result { + if !valid_name(name) { + return Err(format!( + "invalid project name `{name}` (allowed: letters, digits, `_`, `-`; no leading digit)" + )); + } + Ok(ProjectScaffoldSpec { name: name.to_string(), target_dir: cwd.join(name) }) +} + /// 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>, @@ -196,6 +218,39 @@ This crate is an aura project: a cdylib of node/strategy blueprints the - Topology is data (`blueprints/*.json`), node logic is Rust. "#; +const GITIGNORE_PROJECT: &str = "/runs\n"; + +const SIGNAL_JSON_STD: &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}}]}}],"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}],"input_roles":[{"name":"price","targets":[{"node":0,"slot":0},{"node":1,"slot":0}],"source":"F64"}],"output":[{"node":3,"field":0,"name":"bias"}]}}"#; + +/// The open sibling of `SIGNAL_JSON_STD`: `fast.length` is a free knob, so +/// the scaffolded sweep quickstart works out of the box (`run` requires a +/// closed blueprint; `sweep` requires open knobs — one file cannot serve +/// both, #241). +const SIGNAL_OPEN_JSON_STD: &str = r#"{"format_version":1,"blueprint":{"name":"__NAME_SNAKE___signal_open","nodes":[{"primitive":{"type":"SMA","name":"fast"}},{"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}}]}}],"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}],"input_roles":[{"name":"price","targets":[{"node":0,"slot":0},{"node":1,"slot":0}],"source":"F64"}],"output":[{"node":3,"field":0,"name":"bias"}]}}"#; + +const CLAUDE_MD_PROJECT: &str = r#"# __NAME__ — an aura research project (data-only) + +This directory is an aura project: blueprints + research documents over the +std vocabulary, anchored by `Aura.toml`. There is no crate and no build step. + +- Run: `aura run blueprints/signal.json` (a *closed* blueprint — every + param bound) +- Sweep: `aura sweep blueprints/signal_open.json --axis __NAME_SNAKE___signal_open.fast.length=2,4,8` + (a sweep varies *open* knobs; `aura sweep --list-axes` lists them) +- Native nodes: when the project needs its first project-specific node, + `aura nodes new ` scaffolds a node crate beside this project and + attaches it via `[nodes]` in `Aura.toml` (build it with `cargo build`). +- Topology is data (`blueprints/*.json`); results land in `runs/`. +"#; + +/// Render a data-only project template: only `__NAME__`/`__NAME_SNAKE__` +/// tokens exist in this template set (no namespace, no engine path). +fn render_project(template: &str, name: &str) -> String { + template + .replace("__NAME_SNAKE__", &snake(name)) + .replace("__NAME__", name) +} + fn render(template: &str, spec: &ScaffoldSpec) -> String { template .replace("__NAME_SNAKE__", &snake(&spec.name)) @@ -206,8 +261,38 @@ fn render(template: &str, spec: &ScaffoldSpec) -> String { // --------------------------------------------------------------------- emit +/// Emit a data-only project. `Err` is a runtime refusal (exit 1). All +/// refusals fire before the first write. +pub fn scaffold_project(spec: &ProjectScaffoldSpec) -> Result<(), String> { + if spec.target_dir.exists() { + return Err(format!("destination `{}` already exists", spec.target_dir.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("Aura.toml", AURA_TOML.to_string())?; + write(".gitignore", GITIGNORE_PROJECT.to_string())?; + write("blueprints/signal.json", render_project(SIGNAL_JSON_STD, &spec.name))?; + write("blueprints/signal_open.json", render_project(SIGNAL_OPEN_JSON_STD, &spec.name))?; + write("CLAUDE.md", render_project(CLAUDE_MD_PROJECT, &spec.name))?; + if inside_git_work_tree(&spec.target_dir) { + return Ok(()); + } + 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(()) +} + /// 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> { if spec.target_dir.exists() { return Err(format!( @@ -301,6 +386,7 @@ mod tests { #[test] 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] { let out = render(t, &spec); @@ -315,6 +401,18 @@ mod tests { let cargo = render(CARGO_TOML, &spec); assert!(cargo.contains("name = \"demo-lab\"")); assert!(cargo.contains("/crates/aura-core")); + + // Project-template set (data-only tier, via `render_project`). + for t in [GITIGNORE_PROJECT, SIGNAL_JSON_STD, SIGNAL_OPEN_JSON_STD, CLAUDE_MD_PROJECT] { + let out = render_project(t, "demo-lab"); + assert!(!out.contains("__"), "unsubstituted token in: {out}"); + } + let claude = render_project(CLAUDE_MD_PROJECT, "demo-lab"); + assert!(claude.contains("demo-lab")); + // The CLAUDE.md sweep quickstart targets the open sibling with the + // rendered blueprint-name axis prefix (a bound starter cannot sweep). + assert!(claude.contains("blueprints/signal_open.json")); + assert!(claude.contains("demo_lab_signal_open.fast.length")); } /// Property (#183, fieldtest F2 gap): the scaffold's own starter node teaches @@ -348,6 +446,35 @@ mod tests { ); } + /// #241: the data-only starter blueprints reference std vocabulary only — + /// no project-namespaced (`::`) type id survives in the rendered JSON. + /// The closed one binds every param (runnable); the open sibling leaves + /// `fast.length` free (sweepable). + #[test] + fn data_only_starter_blueprint_is_std_only() { + let json = render_project(SIGNAL_JSON_STD, "demo-lab"); + assert!(!json.contains("__"), "unsubstituted token: {json}"); + assert!(!json.contains("::"), "project-namespaced id in std-only blueprint: {json}"); + assert!(json.contains("\"name\":\"demo_lab_signal\"")); + let open = render_project(SIGNAL_OPEN_JSON_STD, "demo-lab"); + assert!(!open.contains("::"), "project-namespaced id in std-only blueprint: {open}"); + assert!(open.contains("\"name\":\"demo_lab_signal_open\"")); + assert!( + open.contains(r#"{"primitive":{"type":"SMA","name":"fast"}}"#), + "the open sibling must leave fast.length unbound: {open}" + ); + } + + #[test] + fn scaffold_project_refuses_existing_destination() { + let tmp = std::env::temp_dir().join(format!("aura-pscaf-{}", std::process::id())); + std::fs::create_dir_all(tmp.join("x")).unwrap(); + let spec = project_scaffold_spec("x", &tmp).unwrap(); + let e = scaffold_project(&spec).unwrap_err(); + assert!(e.contains("already exists"), "{e}"); + std::fs::remove_dir_all(&tmp).ok(); + } + #[test] fn scaffold_refuses_existing_dir_and_missing_engine() { let tmp = std::env::temp_dir().join(format!("aura-scaf-{}", std::process::id())); diff --git a/crates/aura-cli/tests/project_new.rs b/crates/aura-cli/tests/project_new.rs index bbcbb4e..521632d 100644 --- a/crates/aura-cli/tests/project_new.rs +++ b/crates/aura-cli/tests/project_new.rs @@ -1,7 +1,7 @@ -//! 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). +//! E2E: the `aura new` scaffolder (#241 T4). Proves the data-only authoring +//! loop — scaffold → `aura run`/`aura sweep`, zero Rust toolchain interaction +//! — plus the refusal contract. Scaffolded projects live in per-test temp +//! dirs. use std::path::{Path, PathBuf}; use std::process::{Command, Output}; @@ -21,9 +21,53 @@ fn tmp(tag: &str) -> PathBuf { d } +/// Best-effort commit so a scaffolded project's `project_commit` (git +/// `rev-parse HEAD`, `project.rs`) has something to resolve: a bare fresh +/// `git init` alone (what `aura new` does outside a work tree, and what a +/// plain `git init` with no commits leaves too) has an unborn HEAD, so +/// `rev-parse HEAD` fails and `project_commit` returns `None` — verified: an +/// enclosing dir that is only `git init`-ed (no commit) makes the run +/// report's `manifest.project` render as `{}` (no `commit` key at all), +/// which is exactly the key the task text requires this test to assert on. +/// This helper is not itself required by the task text, but the assertion +/// it enables is, so it stays in scope. +fn init_and_commit(dir: &Path) { + let git = |args: &[&str]| { + let out = Command::new("git").args(args).current_dir(dir).output().expect("run git"); + assert!(out.status.success(), "git {args:?} failed: {}", String::from_utf8_lossy(&out.stderr)); + }; + git(&["init"]); + git(&["config", "user.email", "test@example.com"]); + git(&["config", "user.name", "test"]); + std::fs::write(dir.join("README.md"), "base\n").unwrap(); + git(&["add", "-A"]); + git(&["commit", "-m", "init"]); +} + +/// #241 headline: a fresh data-only project runs the starter blueprint with +/// zero cargo/Rust toolchain interaction; the run report's provenance stamps +/// a commit but no namespace (no crate was ever loaded). +/// +/// Documented deviation from the task text: the plan's wording asks this +/// headline to also assert that the run "writes a manifest under `x/runs/`". +/// Verified against `dispatch_run`/`run_signal_r` (main.rs, around the +/// `tx_req`/`rx_req` channel comment): plain `aura run` never touches the +/// trace store — it only prints the `RunReport` to stdout; only +/// `sweep`/`mc`/campaign verbs persist (`env.trace_store()`), a pre-#241 +/// split this task's file list does not touch (giving `run` a store write +/// would widen scope beyond `scaffold.rs`/`main.rs`'s `NewCmd`/`dispatch_new` +/// and this test file). There is thus no on-disk manifest for THIS verb to +/// assert against; the assertion below proves that absence directly instead +/// of only asserting it in prose, and the persisted-store property the plan +/// wanted is covered by `data_only_project_sweeps_without_any_build` below, +/// which does assert `proj.join("runs").exists()`. #[test] -fn new_scaffold_builds_and_runs_the_authoring_loop() { +fn new_scaffolds_and_runs_the_data_only_loop() { let base = tmp("loop"); + // A committed enclosing tree gives the scaffolded project's stamped + // provenance a resolvable `git rev-parse HEAD` (a bare fresh repo has + // none yet); `aura new` skips its own git-init inside a work tree. + init_and_commit(&base); let out = aura(&["new", "demo-lab"], &base); assert!( out.status.success(), @@ -32,25 +76,17 @@ fn new_scaffold_builds_and_runs_the_authoring_loop() { ); let proj = base.join("demo-lab"); for f in [ - "Cargo.toml", "Aura.toml", ".gitignore", - "src/lib.rs", "blueprints/signal.json", + "blueprints/signal_open.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) - ); + assert!(!proj.join("Cargo.toml").exists(), "data-only project must have no Cargo.toml"); + assert!(!proj.join("src/lib.rs").exists(), "data-only project must have no src/lib.rs"); + let a = aura(&["run", "blueprints/signal.json"], &proj); assert!( a.status.success(), @@ -60,11 +96,39 @@ fn new_scaffold_builds_and_runs_the_authoring_loop() { 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::Scale"), "project id listed"); + let p = &v["manifest"]["project"]; + assert!(p.get("commit").is_some(), "data-only project must stamp a commit: {p}"); + assert!(p.get("namespace").is_none(), "no crate was loaded, namespace must be absent: {p}"); + assert!( + !proj.join("runs").exists(), + "plain `aura run` must not persist a trace store (only sweep/mc/campaign do) — \ + the task text's \"writes a manifest under x/runs/\" does not hold for this verb" + ); + let _ = std::fs::remove_dir_all(&base); +} + +/// #241 headline: a fresh data-only project sweeps a dissolved verb (#218's +/// gate) with zero build step — using the scaffolded open starter blueprint +/// and the exact axis the scaffolded CLAUDE.md advertises. +/// +/// Why a second starter blueprint exists at all: `aura run` requires a +/// CLOSED blueprint (every param bound) while `aura sweep` requires OPEN +/// knobs ("this blueprint is fully bound; nothing to sweep"), so one file +/// cannot serve both quickstart lines. `blueprints/signal.json` stays closed +/// for the run headline above; `blueprints/signal_open.json` leaves +/// `fast.length` free for this one. +#[test] +fn data_only_project_sweeps_without_any_build() { + let base = tmp("sweep"); + let new = aura(&["new", "scratch"], &base); + assert!(new.status.success(), "{}", String::from_utf8_lossy(&new.stderr)); + let proj = base.join("scratch"); + let out = aura( + &["sweep", "blueprints/signal_open.json", "--axis", "scratch_signal_open.fast.length=2,4"], + &proj, + ); + assert!(out.status.success(), "stderr: {}", String::from_utf8_lossy(&out.stderr)); + assert!(proj.join("runs").exists(), "store must land inside the project"); let _ = std::fs::remove_dir_all(&base); } @@ -91,56 +155,6 @@ fn new_rejects_invalid_names_as_usage_faults() { 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); -} - -/// Property: an explicit `--namespace` override actually reaches the -/// runtime vocabulary (not just the `ScaffoldSpec` struct field checked at -/// the unit layer) — a scaffolded, built, run project registers its node -/// under the caller-chosen namespace, and the manifest records it too. -#[test] -fn new_namespace_override_reaches_the_built_vocabulary() { - let base = tmp("customns"); - let out = aura(&["new", "demo-lab", "--namespace", "custom_ns"], &base); - assert!( - out.status.success(), - "aura new failed: {}", - String::from_utf8_lossy(&out.stderr) - ); - let proj = base.join("demo-lab"); - 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 run = aura(&["run", "blueprints/signal.json"], &proj); - assert!( - run.status.success(), - "run failed: {}", - String::from_utf8_lossy(&run.stderr) - ); - let v: serde_json::Value = serde_json::from_slice(&run.stdout).expect("report is JSON"); - assert_eq!(v["manifest"]["project"]["namespace"], "custom_ns"); - let introspect = aura(&["graph", "introspect", "--vocabulary"], &proj); - assert!(introspect.status.success()); - let text = String::from_utf8_lossy(&introspect.stdout); - assert!(text.contains("custom_ns::Scale"), "overridden namespace listed: {text}"); - assert!(!text.contains("demo_lab::Scale"), "default-derived namespace must not leak"); - let _ = std::fs::remove_dir_all(&base); -} - /// Property: `aura new` mirrors `cargo new` — when the target directory is /// already inside a git work tree, the scaffolder does NOT initialize a /// nested repo. A nested `.git` under a tracked tree is a gitlink/submodule @@ -169,11 +183,10 @@ fn new_inside_a_work_tree_skips_the_nested_git() { let proj = base.join("demo-lab"); // The full scaffold still lands unchanged... for f in [ - "Cargo.toml", "Aura.toml", ".gitignore", - "src/lib.rs", "blueprints/signal.json", + "blueprints/signal_open.json", "CLAUDE.md", ] { assert!(proj.join(f).is_file(), "missing scaffolded file {f}"); @@ -209,9 +222,9 @@ fn new_outside_a_work_tree_initializes_git() { #[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. + // Scaffold once, then scaffold AGAIN from inside that tree: the + // load-bypass guard must keep aura new from trying to load the enclosing + // project's (data-only, but still discoverable) Aura.toml. let base = tmp("nested"); let out = aura(&["new", "outer"], &base); assert!(out.status.success()); @@ -222,6 +235,6 @@ fn new_works_inside_an_unbuilt_project_tree() { "aura new inside an unbuilt project tree must succeed: {}", String::from_utf8_lossy(&out.stderr) ); - assert!(inner_cwd.join("inner/Cargo.toml").is_file()); + assert!(inner_cwd.join("inner/Aura.toml").is_file()); let _ = std::fs::remove_dir_all(&base); }