diff --git a/crates/aura-cli/src/main.rs b/crates/aura-cli/src/main.rs index 7256cc5..83c8880 100644 --- a/crates/aura-cli/src/main.rs +++ b/crates/aura-cli/src/main.rs @@ -2487,13 +2487,16 @@ fn parse_axes( Ok(axes) } -/// The two distinguishable ways `validate_and_register_axes` can fail, carrying -/// the two distinct exit codes its four call sites (sweep/generalize/walkforward/mc) -/// preserved before the dedup: an unknown axis name is a usage error (exit 2, echoed -/// before the archive is touched); a registry write failure is a runtime error (exit 1). +/// The three distinguishable ways `validate_and_register_axes` can fail: an +/// unknown axis name is a usage error (exit 2, echoed before the archive is +/// touched); a registry write failure is a runtime error (exit 1); running +/// outside a project (no `Aura.toml` found up from cwd, #218's gate) is also +/// a runtime error (exit 1) — the strategy document cannot resolve against a +/// project store/vocabulary that doesn't exist. enum AxisRegisterError { UnknownAxis(String), Registry(String), + NoProject(String), } /// Validate every `--axis` name against the blueprint's WRAPPED probe namespace @@ -2507,6 +2510,7 @@ enum AxisRegisterError { /// same block", rule-of-three now exceeded 4x). #[allow(clippy::type_complexity)] fn validate_and_register_axes( + verb: &str, doc: &str, axes: &[(String, Vec)], env: &project::Env, @@ -2521,6 +2525,15 @@ fn validate_and_register_axes( ))); } } + if env.provenance().is_none() { + let cwd = std::env::current_dir() + .map(|d| d.display().to_string()) + .unwrap_or_default(); + return Err(AxisRegisterError::NoProject(format!( + "{verb} needs a project: strategies resolve against the project \ + store and vocabulary (no Aura.toml found up from {cwd})" + ))); + } let blueprint = blueprint_from_json(doc, &|t| env.resolve(t)) .expect("doc parse-validated at the dispatch boundary"); let canonical = blueprint_to_json(&blueprint).expect("a loaded blueprint re-serializes"); @@ -2538,7 +2551,8 @@ fn validate_and_register_axes( /// Exit-map for a `validate_and_register_axes` failure — the stderr line and /// exit code every campaign dispatcher (sweep/generalize/walkforward/mc) /// preserves: an unknown axis is a usage error (exit 2, echoed before the -/// archive is touched), a registry write failure a runtime error (exit 1). +/// archive is touched); a registry write failure or a missing project (no +/// `Aura.toml` found up from cwd, #218's gate) is a runtime error (exit 1). fn exit_axis_register_error(e: AxisRegisterError) -> ! { match e { AxisRegisterError::UnknownAxis(m) => { @@ -2549,6 +2563,10 @@ fn exit_axis_register_error(e: AxisRegisterError) -> ! { eprintln!("aura: {m}"); std::process::exit(1) } + AxisRegisterError::NoProject(m) => { + eprintln!("aura: {m}"); + std::process::exit(1) + } } } @@ -2702,7 +2720,7 @@ fn dispatch_generalize(a: GeneralizeCmd, env: &project::Env) { std::process::exit(2); } } - let (canonical, raw_axes) = validate_and_register_axes(&doc, &axes, env) + let (canonical, raw_axes) = validate_and_register_axes("generalize", &doc, &axes, env) .unwrap_or_else(|e| exit_axis_register_error(e)); // Window: the explicit --from/--to when both present (byte-identical to the // retired welded path, verified by the exact-grade anchor); otherwise the @@ -2843,7 +2861,7 @@ fn dispatch_sweep(a: SweepCmd, env: &project::Env) { // Axis validation + canonicalize/register + the wrapped->raw // strip are single-sourced in `validate_and_register_axes` // (data-free, so this fires before the archive is touched). - let (canonical, raw_axes) = validate_and_register_axes(&doc, &axes, env) + let (canonical, raw_axes) = validate_and_register_axes("sweep", &doc, &axes, env) .unwrap_or_else(|e| exit_axis_register_error(e)); let symbol = symbol.clone(); // Archive-clipped Unix-ms window; the ns->ms seam-crossing @@ -2924,7 +2942,7 @@ fn dispatch_walkforward(a: WalkforwardCmd, env: &project::Env) { // Axis validation + canonicalize/register + the wrapped->raw // strip are single-sourced in `validate_and_register_axes` // (data-free, so this fires before the archive is touched). - let (canonical, raw_axes) = validate_and_register_axes(&doc, &axes, env) + let (canonical, raw_axes) = validate_and_register_axes("walkforward", &doc, &axes, env) .unwrap_or_else(|e| exit_axis_register_error(e)); // Unlike `dispatch_generalize` (a single run per instrument, insensitive // to a day's edge shift), `blueprint_walkforward_family` sources its span @@ -3027,7 +3045,7 @@ fn dispatch_mc(a: McCmd, env: &project::Env) { // Axis validation + canonicalize/register + the wrapped->raw // strip are single-sourced in `validate_and_register_axes` // (data-free, so this fires before the archive is touched). - let (canonical, raw_axes) = validate_and_register_axes(&doc, &axes, env) + let (canonical, raw_axes) = validate_and_register_axes("mc", &doc, &axes, env) .unwrap_or_else(|e| exit_axis_register_error(e)); // `blueprint_walkforward_family` sources its span from // `DataSource::wf_full_span`, which for Real ALWAYS clips `--from`/`--to` diff --git a/crates/aura-cli/tests/cli_run.rs b/crates/aura-cli/tests/cli_run.rs index afc1e5d..00d6280 100644 --- a/crates/aura-cli/tests/cli_run.rs +++ b/crates/aura-cli/tests/cli_run.rs @@ -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);