fix(cli): gate dissolved verbs on project presence, matching campaign run (#218)
A dissolved verb (sweep/generalize/walkforward/mc --real) run outside a project silently created a content-addressed runs/ store in the cwd and ran to completion — Env::runs_root falls back to a relative ./runs with no project, and the four dispatchers skipped the provenance gate that `campaign run` applies first (campaign_run.rs:322-333). It is now refused up front: one gate in the shared validate_and_register_axes chokepoint, after axis validation and before the first store touch (put_blueprint), via a new AxisRegisterError::NoProject mapped to exit 1 carrying "<verb> needs a project …". Placement is load-bearing: axis validation (an UnknownAxis usage error, exit 2) stays ahead of the project gate, so a malformed --axis still exits 2 regardless of project — a malformed command is malformed in any environment and already writes no store. Four per-verb pins assert the refusal (exit 1, "needs a project", no store); the six exit-2 usage-refusal tests stay green. Workspace suite green (62 groups / 0 failed), clippy -D warnings clean. closes #218
This commit is contained in:
@@ -71,6 +71,78 @@ fn fresh_project() -> (&'static PathBuf, (RunsCleanup, MutexGuard<'static, ()>))
|
||||
(dir, (RunsCleanup(runs), lock))
|
||||
}
|
||||
|
||||
/// #218: a well-formed dissolved verb run outside a project refuses up front
|
||||
/// (exit 1, "needs a project") and leaves no store — parity with `campaign run`.
|
||||
/// The refusal names the INVOKED verb, not a copy-pasted generic string — a
|
||||
/// per-verb literal shared across four call sites (`validate_and_register_axes`
|
||||
/// takes `verb` as a parameter) is exactly the kind of edit that silently
|
||||
/// regresses to one hardcoded verb name; this pins that each call site still
|
||||
/// threads its own name through.
|
||||
#[test]
|
||||
fn sweep_outside_project_refuses_and_leaves_no_store() {
|
||||
let cwd = temp_cwd("sweep-outside-project-refuses");
|
||||
let bp = format!("{}/examples/r_sma_open.json", env!("CARGO_MANIFEST_DIR"));
|
||||
let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura"))
|
||||
.args(["sweep", &bp, "--real", "GER40", "--axis", "sma_signal.fast.length=2,4"])
|
||||
.current_dir(&cwd)
|
||||
.output()
|
||||
.expect("spawn aura");
|
||||
assert_eq!(out.status.code(), Some(1), "stderr: {}", String::from_utf8_lossy(&out.stderr));
|
||||
assert!(String::from_utf8_lossy(&out.stderr).contains("sweep needs a project"));
|
||||
assert!(!cwd.join("runs").exists(), "a refused run must leave no store");
|
||||
}
|
||||
|
||||
/// #218: same refusal as `sweep_outside_project_refuses_and_leaves_no_store`,
|
||||
/// for `generalize`'s instrument-list form — the message names `generalize`,
|
||||
/// not `sweep` (see that test's doc comment for the property).
|
||||
#[test]
|
||||
fn generalize_outside_project_refuses_and_leaves_no_store() {
|
||||
let cwd = temp_cwd("generalize-outside-project-refuses");
|
||||
let bp = format!("{}/examples/r_sma_open.json", env!("CARGO_MANIFEST_DIR"));
|
||||
let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura"))
|
||||
.args(["generalize", &bp, "--real", "GER40,USDJPY", "--axis", "sma_signal.fast.length=2"])
|
||||
.current_dir(&cwd)
|
||||
.output()
|
||||
.expect("spawn aura");
|
||||
assert_eq!(out.status.code(), Some(1), "stderr: {}", String::from_utf8_lossy(&out.stderr));
|
||||
assert!(String::from_utf8_lossy(&out.stderr).contains("generalize needs a project"));
|
||||
assert!(!cwd.join("runs").exists(), "a refused run must leave no store");
|
||||
}
|
||||
|
||||
/// #218: same refusal as `sweep_outside_project_refuses_and_leaves_no_store`,
|
||||
/// for `walkforward`'s valid `--axis` form — the message names `walkforward`,
|
||||
/// not `sweep` (see that test's doc comment for the property).
|
||||
#[test]
|
||||
fn walkforward_outside_project_refuses_and_leaves_no_store() {
|
||||
let cwd = temp_cwd("walkforward-outside-project-refuses");
|
||||
let bp = format!("{}/examples/r_sma_open.json", env!("CARGO_MANIFEST_DIR"));
|
||||
let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura"))
|
||||
.args(["walkforward", &bp, "--real", "GER40", "--axis", "sma_signal.fast.length=2,4"])
|
||||
.current_dir(&cwd)
|
||||
.output()
|
||||
.expect("spawn aura");
|
||||
assert_eq!(out.status.code(), Some(1), "stderr: {}", String::from_utf8_lossy(&out.stderr));
|
||||
assert!(String::from_utf8_lossy(&out.stderr).contains("walkforward needs a project"));
|
||||
assert!(!cwd.join("runs").exists(), "a refused run must leave no store");
|
||||
}
|
||||
|
||||
/// #218: same refusal as `sweep_outside_project_refuses_and_leaves_no_store`,
|
||||
/// for `mc`'s valid `--axis` form — the message names `mc`, not `sweep` (see
|
||||
/// that test's doc comment for the property).
|
||||
#[test]
|
||||
fn mc_outside_project_refuses_and_leaves_no_store() {
|
||||
let cwd = temp_cwd("mc-outside-project-refuses");
|
||||
let bp = format!("{}/examples/r_sma_open.json", env!("CARGO_MANIFEST_DIR"));
|
||||
let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura"))
|
||||
.args(["mc", &bp, "--real", "GER40", "--axis", "sma_signal.fast.length=2,4"])
|
||||
.current_dir(&cwd)
|
||||
.output()
|
||||
.expect("spawn aura");
|
||||
assert_eq!(out.status.code(), Some(1), "stderr: {}", String::from_utf8_lossy(&out.stderr));
|
||||
assert!(String::from_utf8_lossy(&out.stderr).contains("mc needs a project"));
|
||||
assert!(!cwd.join("runs").exists(), "a refused run must leave no store");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_prints_json_and_exits_zero() {
|
||||
// `run` is blueprint-only now (#159 cut 4 retired the bare built-in default);
|
||||
|
||||
Reference in New Issue
Block a user