//! E2E: the `aura new` scaffolder (#241 T4). Proves the data-only authoring //! loop — scaffold → `aura exec`, 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}; mod common; fn aura(args: &[&str], cwd: &Path) -> Output { Command::new(env!("CARGO_BIN_EXE_aura")) .args(args) .current_dir(cwd) .output() .expect("run aura") } /// Fixed, tag-keyed, pid-free name (#258) — kept under `env::temp_dir()` /// rather than the `CARGO_TARGET_TMPDIR` build-tree anchor used by the other /// sandbox helpers: two of this file's tests (`new_outside_a_work_tree_*`) /// require a base genuinely detached from any git work tree, and /// `CARGO_TARGET_TMPDIR` resolves inside this very checkout's `target/`. /// The checkout discriminator keeps concurrently running checkouts/worktrees /// (which share `env::temp_dir()`) off each other's fixed names while the /// name stays deterministic per checkout, so each run still reclaims the last. fn tmp(tag: &str) -> PathBuf { use std::hash::{Hash, Hasher}; let mut h = std::collections::hash_map::DefaultHasher::new(); env!("CARGO_MANIFEST_DIR").hash(&mut h); let d = std::env::temp_dir().join(format!("aura-new-{tag}-{:08x}", h.finish() as u32)); let _ = std::fs::remove_dir_all(&d); std::fs::create_dir_all(&d).unwrap(); 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_exec`/`exec_blueprint_leg`/`run_signal_r` /// (main.rs): a single-blueprint `aura exec` never touches the trace store — /// it only prints the `RunReport` to stdout; only exec's campaign leg /// persists (`env.trace_store()`), a pre-#241 split this task's file list /// does not touch (giving the blueprint leg 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 leg 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_execs_a_campaign_without_any_build` /// below, which does assert `proj.join("runs").exists()`. #[test] 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(), "aura new failed: {}", String::from_utf8_lossy(&out.stderr) ); let proj = base.join("demo-lab"); for f in [ "Aura.toml", ".gitignore", "blueprints/signal.json", "CLAUDE.md", ] { assert!(proj.join(f).is_file(), "missing scaffolded file {f}"); } assert_eq!( std::fs::read_dir(proj.join("blueprints")).unwrap().count(), 1, "blueprints/signal.json must be the ONLY starter blueprint (#246)" ); 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(&["exec", "blueprints/signal.json"], &proj); assert!( a.status.success(), "exec failed: {}", String::from_utf8_lossy(&a.stderr) ); let b = aura(&["exec", "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"); 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(), "a plain single-blueprint `aura exec` must not persist a trace store (only exec's \ campaign leg does) — the task text's \"writes a manifest under x/runs/\" does not \ hold for this leg" ); let _ = std::fs::remove_dir_all(&base); } /// The minimal executable pipeline (one sweep stage) — copied verbatim from /// `research_docs.rs`'s constant of the same name. const SWEEP_ONLY_PROCESS_DOC: &str = r#"{ "format_version": 1, "kind": "process", "name": "sweep-only", "pipeline": [ { "block": "std::sweep", "metric": "sqn_normalized", "select": "argmax" } ] }"#; /// Writes a tiny, self-contained January-2024 `SYMA` archive into /// `/data` and points the scaffolded `Aura.toml`'s `[paths]` table at /// it. #319: `exec`'s campaign leg (the surviving surface for what `aura /// sweep --axis` used to do inline) needs a registered instrument, unlike a /// plain blueprint run — this keeps the scaffolder's "zero host setup" /// headline intact (no real archive mount) by minting the archive straight /// into the scaffold instead. fn seed_synthetic_archive(proj: &Path) { let data_dir = proj.join("data"); std::fs::create_dir_all(&data_dir).expect("create synthetic archive dir"); common::synthetic_data::write_symbol_archive(&data_dir, "SYMA", (2024, 1), (2024, 1)); let toml_path = proj.join("Aura.toml"); let toml = std::fs::read_to_string(&toml_path).expect("read scaffolded Aura.toml"); let toml = toml.replacen("runs = \"runs\"\n", "runs = \"runs\"\ndata = \"data\"\n", 1); std::fs::write(&toml_path, toml).expect("point Aura.toml at the synthetic archive"); } /// The id extracted from a `registered ()` line (#194: bare /// id, no `content:` prefix — tolerant of the pre-#194 prefix all the same). fn registered_id(stdout: &str, kind: &str) -> String { let needle = format!("registered {kind} "); stdout .lines() .find(|l| l.starts_with(&needle)) .unwrap_or_else(|| panic!("no \"{needle}\" line: {stdout}")) .trim_start_matches(&needle) .split(' ') .next() .expect("id") .trim_start_matches("content:") .to_string() } /// A one-strategy, one-axis campaign document over `bp_id`/`proc_id` — the /// surviving surface for what `aura sweep --axis` used to do inline over the /// scaffolded starter's bound `fast.length` (#246: a bound param is a default /// an axis overrides). Windowed fully inside `seed_synthetic_archive`'s /// January-2024 `SYMA` archive. fn one_axis_campaign_doc(bp_id: &str, proc_id: &str, axis_name: &str, axis_values: &str) -> String { format!( r#"{{ "format_version": 1, "kind": "campaign", "name": "quickstart", "data": {{ "instruments": ["SYMA"], "windows": [ {{ "from_ms": 1704067200000, "to_ms": 1706745599999 }} ] }}, "strategies": [ {{ "ref": {{ "content_id": "{bp_id}" }}, "axes": {{ "{axis_name}": {{ "kind": "I64", "values": [{axis_values}] }} }} }} ], "process": {{ "ref": {{ "content_id": "{proc_id}" }} }}, "seed": 1, "presentation": {{ "persist_taps": [], "emit": ["family_table"] }} }}"# ) } /// #241 headline: a fresh data-only project executes a small campaign — #319's /// surviving surface for the retired `aura sweep` — with zero build step, /// using the scaffolded (closed) starter blueprint and the exact axis the /// scaffolded CLAUDE.md advertises. Registering the blueprint straight (`aura /// graph register`, not a sweep side-effect) and seeding a tiny synthetic /// archive (`seed_synthetic_archive`) keeps the loop free of any host data /// mount, matching the pre-retirement headline. /// /// #246: the closed starter IS the campaign axis target — a bound param is a /// default an axis overrides. #[test] fn data_only_project_execs_a_campaign_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"); seed_synthetic_archive(&proj); let reg = aura(&["graph", "register", "blueprints/signal.json"], &proj); assert!(reg.status.success(), "graph register stderr: {}", String::from_utf8_lossy(®.stderr)); let bp_id = registered_id(&String::from_utf8_lossy(®.stdout), "blueprint"); std::fs::write(proj.join("quickstart.process.json"), SWEEP_ONLY_PROCESS_DOC) .expect("write process doc"); let preg = aura(&["process", "register", "quickstart.process.json"], &proj); assert!(preg.status.success(), "process register stderr: {}", String::from_utf8_lossy(&preg.stderr)); let proc_id = registered_id(&String::from_utf8_lossy(&preg.stdout), "process"); let doc = one_axis_campaign_doc(&bp_id, &proc_id, "fast.length", "2, 4"); std::fs::write(proj.join("quickstart.campaign.json"), &doc).expect("write campaign doc"); let out = aura(&["exec", "quickstart.campaign.json"], &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); } /// Property (#246): the scaffold's campaign quickstart genuinely re-opens the /// bound `fast.length` param — the persisted family has exactly one member /// per axis value, not a single collapsed member. A silent regression where /// the bound-param override is dropped (member always built from the /// blueprint's own default) would still exit 0 and still create a `runs` /// store, so `data_only_project_execs_a_campaign_without_any_build` above /// cannot catch it; only the member count can. #[test] fn data_only_project_campaign_over_the_starter_opens_one_member_per_axis_value() { let base = tmp("sweep-members"); let new = aura(&["new", "scratch"], &base); assert!(new.status.success(), "{}", String::from_utf8_lossy(&new.stderr)); let proj = base.join("scratch"); seed_synthetic_archive(&proj); let reg = aura(&["graph", "register", "blueprints/signal.json"], &proj); assert!(reg.status.success(), "graph register stderr: {}", String::from_utf8_lossy(®.stderr)); let bp_id = registered_id(&String::from_utf8_lossy(®.stdout), "blueprint"); std::fs::write(proj.join("members.process.json"), SWEEP_ONLY_PROCESS_DOC) .expect("write process doc"); let preg = aura(&["process", "register", "members.process.json"], &proj); assert!(preg.status.success(), "process register stderr: {}", String::from_utf8_lossy(&preg.stderr)); let proc_id = registered_id(&String::from_utf8_lossy(&preg.stdout), "process"); let doc = one_axis_campaign_doc(&bp_id, &proc_id, "fast.length", "2, 4, 8"); std::fs::write(proj.join("members.campaign.json"), &doc).expect("write campaign doc"); let out = aura(&["exec", "members.campaign.json"], &proj); assert!(out.status.success(), "stderr: {}", String::from_utf8_lossy(&out.stderr)); let fams = aura(&["runs", "families"], &proj); assert!(fams.status.success(), "runs families exit: {:?}", fams.status); let fams_out = String::from_utf8_lossy(&fams.stdout).into_owned(); assert_eq!(fams_out.lines().count(), 1, "exactly one family persisted: {fams_out}"); assert!( fams_out.contains("\"members\":3"), "the three-value bound-param axis grid must yield three members, one per \ value (2,4,8) — not a collapsed default-only sweep: {fams_out}" ); 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); } /// 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 /// hazard (it bit the 0107 fieldtest corpus); the enclosing repo owns the /// scaffold's history. #[test] fn new_inside_a_work_tree_skips_the_nested_git() { let base = tmp("insidegit"); let init = Command::new("git") .arg("init") .current_dir(&base) .output() .expect("git init the enclosing work tree"); assert!( init.status.success(), "git init base failed: {}", String::from_utf8_lossy(&init.stderr) ); 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"); // The full scaffold still lands unchanged... for f in [ "Aura.toml", ".gitignore", "blueprints/signal.json", "CLAUDE.md", ] { assert!(proj.join(f).is_file(), "missing scaffolded file {f}"); } assert_eq!( std::fs::read_dir(proj.join("blueprints")).unwrap().count(), 1, "blueprints/signal.json must be the ONLY starter blueprint (#246)" ); // ...but without a nested repo of its own. assert!( !proj.join(".git").exists(), "scaffold nested a .git inside an existing work tree (gitlink hazard)" ); let _ = std::fs::remove_dir_all(&base); } /// Guard against over-removal of the git-init: OUTSIDE any work tree the /// scaffold still initializes its own repo. `std::env::temp_dir()` is not /// inside a git work tree (the e2e helpers here already rely on that), so the /// scaffold's parent is genuinely detached from this checkout's tree. #[test] fn new_outside_a_work_tree_initializes_git() { let base = tmp("outsidegit"); 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"); assert!( proj.join(".git").is_dir(), "scaffold outside a work tree must initialize its own repo" ); let _ = std::fs::remove_dir_all(&base); } /// Property (#243): a freshly scaffolded project stamps resolvable /// provenance from its very first run. Outside any enclosing work tree /// `aura new` owns the scaffold's history, so it must leave the project with /// a HEAD — `git rev-parse HEAD` resolves — and the first advertised /// quickstart `aura run` therefore stamps a `manifest.project.commit`, rather /// than the empty `"project":{}` that a bare `git init` with no commit yields /// (a first manifest that is silently un-reproducible-by-commit). This is the /// property the neighbouring loop headline can only reach today via a manual /// `init_and_commit` on the enclosing tree. #[test] fn new_outside_a_work_tree_leaves_a_resolvable_head() { let base = tmp("headprov"); 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"); // The scaffold has its own repo (guarded elsewhere) AND that repo has a // HEAD: `git rev-parse HEAD` resolves. A bare `git init` with no commit // leaves HEAD unborn and this fails. let head = Command::new("git") .args(["rev-parse", "HEAD"]) .current_dir(&proj) .output() .expect("run git rev-parse HEAD"); assert!( head.status.success(), "fresh scaffold has no HEAD (`git rev-parse HEAD` failed): {}", String::from_utf8_lossy(&head.stderr) ); // ...so the first quickstart run stamps a commit into the manifest, not // an empty provenance block. let run = aura(&["exec", "blueprints/signal.json"], &proj); assert!( run.status.success(), "exec failed: {}", String::from_utf8_lossy(&run.stderr) ); let v: serde_json::Value = serde_json::from_slice(&run.stdout).expect("report is JSON"); let p = &v["manifest"]["project"]; assert!( p.get("commit").is_some(), "first run of a fresh scaffold must stamp a commit, not empty provenance: {p}" ); let _ = std::fs::remove_dir_all(&base); } #[test] fn new_works_inside_an_unbuilt_project_tree() { // 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()); 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/Aura.toml").is_file()); let _ = std::fs::remove_dir_all(&base); }