24782caaec
Slice 5 of the #319 retirement. The scaffolder's project CLAUDE.md and the CLI concepts help now teach only the surviving surface (exec over both document classes, --override, graph introspect --params for discovery; traces via exec --tap / presentation.persist_taps). Sweep vehicles moved onto campaign documents driven by exec (project_new's starter quickstarts, cli_broken_pipe — whose bare-sweep EPIPE probe was latently vacuous and now streams a real 4-member campaign, project_sweep_campaign's real-data leg, graph_construct's gang-axis parity pair as paired one-cell campaigns); pure discovery sites became graph introspect --params, and the line-identity test kept its literal --params expectations minus the sweep half. Help pins rewritten; the scaffold template's in-src pin follows the new bytes. refs #319
428 lines
19 KiB
Rust
428 lines
19 KiB
Rust
//! 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
|
|
/// `<proj>/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 <kind> <id> (<path>)` 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);
|
|
}
|