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:
@@ -2487,13 +2487,16 @@ fn parse_axes(
|
|||||||
Ok(axes)
|
Ok(axes)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The two distinguishable ways `validate_and_register_axes` can fail, carrying
|
/// The three distinguishable ways `validate_and_register_axes` can fail: an
|
||||||
/// the two distinct exit codes its four call sites (sweep/generalize/walkforward/mc)
|
/// unknown axis name is a usage error (exit 2, echoed before the archive is
|
||||||
/// preserved before the dedup: an unknown axis name is a usage error (exit 2, echoed
|
/// touched); a registry write failure is a runtime error (exit 1); running
|
||||||
/// before the archive is touched); a registry write failure is a runtime error (exit 1).
|
/// 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 {
|
enum AxisRegisterError {
|
||||||
UnknownAxis(String),
|
UnknownAxis(String),
|
||||||
Registry(String),
|
Registry(String),
|
||||||
|
NoProject(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Validate every `--axis` name against the blueprint's WRAPPED probe namespace
|
/// 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).
|
/// same block", rule-of-three now exceeded 4x).
|
||||||
#[allow(clippy::type_complexity)]
|
#[allow(clippy::type_complexity)]
|
||||||
fn validate_and_register_axes(
|
fn validate_and_register_axes(
|
||||||
|
verb: &str,
|
||||||
doc: &str,
|
doc: &str,
|
||||||
axes: &[(String, Vec<Scalar>)],
|
axes: &[(String, Vec<Scalar>)],
|
||||||
env: &project::Env,
|
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))
|
let blueprint = blueprint_from_json(doc, &|t| env.resolve(t))
|
||||||
.expect("doc parse-validated at the dispatch boundary");
|
.expect("doc parse-validated at the dispatch boundary");
|
||||||
let canonical = blueprint_to_json(&blueprint).expect("a loaded blueprint re-serializes");
|
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-map for a `validate_and_register_axes` failure — the stderr line and
|
||||||
/// exit code every campaign dispatcher (sweep/generalize/walkforward/mc)
|
/// exit code every campaign dispatcher (sweep/generalize/walkforward/mc)
|
||||||
/// preserves: an unknown axis is a usage error (exit 2, echoed before the
|
/// 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) -> ! {
|
fn exit_axis_register_error(e: AxisRegisterError) -> ! {
|
||||||
match e {
|
match e {
|
||||||
AxisRegisterError::UnknownAxis(m) => {
|
AxisRegisterError::UnknownAxis(m) => {
|
||||||
@@ -2549,6 +2563,10 @@ fn exit_axis_register_error(e: AxisRegisterError) -> ! {
|
|||||||
eprintln!("aura: {m}");
|
eprintln!("aura: {m}");
|
||||||
std::process::exit(1)
|
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);
|
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));
|
.unwrap_or_else(|e| exit_axis_register_error(e));
|
||||||
// Window: the explicit --from/--to when both present (byte-identical to the
|
// Window: the explicit --from/--to when both present (byte-identical to the
|
||||||
// retired welded path, verified by the exact-grade anchor); otherwise 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
|
// Axis validation + canonicalize/register + the wrapped->raw
|
||||||
// strip are single-sourced in `validate_and_register_axes`
|
// strip are single-sourced in `validate_and_register_axes`
|
||||||
// (data-free, so this fires before the archive is touched).
|
// (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));
|
.unwrap_or_else(|e| exit_axis_register_error(e));
|
||||||
let symbol = symbol.clone();
|
let symbol = symbol.clone();
|
||||||
// Archive-clipped Unix-ms window; the ns->ms seam-crossing
|
// 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
|
// Axis validation + canonicalize/register + the wrapped->raw
|
||||||
// strip are single-sourced in `validate_and_register_axes`
|
// strip are single-sourced in `validate_and_register_axes`
|
||||||
// (data-free, so this fires before the archive is touched).
|
// (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));
|
.unwrap_or_else(|e| exit_axis_register_error(e));
|
||||||
// Unlike `dispatch_generalize` (a single run per instrument, insensitive
|
// Unlike `dispatch_generalize` (a single run per instrument, insensitive
|
||||||
// to a day's edge shift), `blueprint_walkforward_family` sources its span
|
// 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
|
// Axis validation + canonicalize/register + the wrapped->raw
|
||||||
// strip are single-sourced in `validate_and_register_axes`
|
// strip are single-sourced in `validate_and_register_axes`
|
||||||
// (data-free, so this fires before the archive is touched).
|
// (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));
|
.unwrap_or_else(|e| exit_axis_register_error(e));
|
||||||
// `blueprint_walkforward_family` sources its span from
|
// `blueprint_walkforward_family` sources its span from
|
||||||
// `DataSource::wf_full_span`, which for Real ALWAYS clips `--from`/`--to`
|
// `DataSource::wf_full_span`, which for Real ALWAYS clips `--from`/`--to`
|
||||||
|
|||||||
@@ -71,6 +71,78 @@ fn fresh_project() -> (&'static PathBuf, (RunsCleanup, MutexGuard<'static, ()>))
|
|||||||
(dir, (RunsCleanup(runs), lock))
|
(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]
|
#[test]
|
||||||
fn run_prints_json_and_exits_zero() {
|
fn run_prints_json_and_exits_zero() {
|
||||||
// `run` is blueprint-only now (#159 cut 4 retired the bare built-in default);
|
// `run` is blueprint-only now (#159 cut 4 retired the bare built-in default);
|
||||||
|
|||||||
Reference in New Issue
Block a user