Merge main into the clap-adoption cycle — re-integrate the two parallel CLI fixes

main advanced with two `fix(cli)` commits (dec0780, 6a775f7) landed by a parallel
session while the clap adoption (cycles 0098/0099) was built from 780d823. Both
touch behaviour the clap migration rewrote or sits beside; this merge preserves
them rather than letting the rewrite silently drop them:

- dec0780 (open-blueprint guard, #176): `aura run <open blueprint.json>` must
  refuse with a clean exit 2, never a `compile_with_params` arity panic (exit
  101). The fix lived in the old hand-rolled `run` .json-arm, which the clap
  migration replaced — re-integrated into `dispatch_run`'s .json branch (the same
  `blueprint_axis_probe().param_space()` check + "closed blueprint" diagnostic).
- 6a775f7 (walkforward axis-once, #177): axis validation hoisted to a single
  dispatch-boundary pre-flight, in the execution fn `blueprint_walkforward_family`
  — which the migration did not touch, so it auto-merged unchanged.

Both fixes are verified by their own RED tests
(`aura_run_rejects_an_open_blueprint_without_panicking`,
`aura_walkforward_emits_an_unknown_axis_rejection_once`), and both are consistent
with this cycle's iteration-2 exit-code classification: an open blueprint and an
unknown axis are argument faults, so usage errors → exit 2.

The conflict was confined to `fn main` in crates/aura-cli/src/main.rs (the clap
`Cli::parse()` dispatch vs the old argv `match`); resolved to the clap side plus
the re-integrated guard. cli_run.rs auto-merged (their two new tests + this
cycle's renegotiated pins).

Verified green (orchestrator, this session): cargo build --workspace, cargo clippy
--workspace --all-targets -- -D warnings, and cargo test --workspace all clean.
This commit is contained in:
2026-07-02 00:53:54 +02:00
2 changed files with 95 additions and 1 deletions
+29 -1
View File
@@ -3002,9 +3002,25 @@ fn blueprint_walkforward_family(
let space = blueprint_axis_probe(doc).param_space();
let topo = topology_hash(&blueprint_from_json(doc, &|t| std_vocabulary(t))
.expect("doc parse-validated at the dispatch boundary; reload is infallible"));
// Validate the `--axis` grid ONCE at the dispatch boundary, mirroring `aura sweep`
// (which resolves its axes a single time before any member runs). `walk_forward` fans
// the per-window closure out across the windows in parallel, so a `BindError` raised
// *inside* the closure would `eprintln!`+`exit(2)` from several windows before any one
// exit lands — a racy, duplicated rejection (#177). Axis resolution is window-agnostic,
// so pre-flighting the first IS window here surfaces the error exactly once (it fails in
// `resolve_axes`, before any member runs); a per-window resolve then cannot re-raise.
let first_is = WindowRoller::new(span, is_len, oos_len, step, RollMode::Rolling)
.expect("roller config validated just above")
.next()
.expect("roller yields >= 1 window (validated above)")
.is;
if let Err(e) = blueprint_sweep_over(doc, axes, first_is.0, first_is.1, data) {
eprintln!("aura: {e:?}");
std::process::exit(2);
}
walk_forward(roller, space.clone(), |w: WindowBounds| {
let (is_family, lattice) = blueprint_sweep_over(doc, axes, w.is.0, w.is.1, data)
.unwrap_or_else(|e| { eprintln!("aura: {e:?}"); std::process::exit(2); });
.expect("axes validated in the dispatch-boundary pre-flight");
let (best, selection) = match select_winner(&is_family, "sqn_normalized", select, Some(&lattice)) {
Ok(v) => v,
Err(msg) => { eprintln!("aura: {msg}"); std::process::exit(2); }
@@ -4129,6 +4145,18 @@ fn dispatch_run(a: RunCmd) {
eprintln!("aura: {path}: {e:?}");
std::process::exit(2);
});
// Refuse an open (free-knob) blueprint at the dispatch boundary, mirroring
// `blueprint_mc_family`'s closed-guard: `run` bootstraps over the EMPTY point, so a
// free knob would panic in `compile_with_params` — reject it clean instead (#176).
let free = blueprint_axis_probe(&doc).param_space();
if !free.is_empty() {
eprintln!(
"aura: run requires a closed blueprint (no free parameters); {} free knob(s) — \
bind them or use `aura sweep --axis`",
free.len()
);
std::process::exit(2);
}
let params = match a.params.as_deref() {
Some(j) => parse_param_cells(j).unwrap_or_else(|m| {
eprintln!("aura: {m}");
+66
View File
@@ -3666,6 +3666,41 @@ fn aura_walkforward_over_a_blueprint_rejects_an_unknown_axis() {
assert!(!stderr.contains("panicked"), "must reject cleanly, never panic: {stderr}");
}
/// E2E (#177): a rejected `--axis` on a loaded walk-forward blueprint is reported
/// EXACTLY ONCE, however many IS/OOS windows the roll would have spanned — mirroring
/// the sibling `aura sweep` / `aura mc` paths, which validate the axis ONCE up front
/// and emit their rejection a single time. The property this protects: axis
/// resolution is a single pre-flight, not a per-window re-raise.
///
/// The CLOSED fixture has an empty param_space, so ANY `--axis` name is an
/// `UnknownKnob`. The pre-fix code validates the axis INSIDE the per-window closure
/// that `walk_forward` fans out in parallel across the windows, so more than one
/// window `eprintln!`s the same rejection before the racing `exit(2)` tears the
/// process down — a race whose emit count is 1 or 2 per run. A single invocation
/// would therefore be a flaky assertion (it emits once ~1/3 of the time by luck), so
/// this drives the verb repeatedly: observing the double-emit at least once is then
/// near-certain, and the assertion is that EVERY run emits the rejection exactly
/// once. The fix (hoist axis resolution to one pre-flight before the windowed
/// fan-out) makes every run deterministically single.
#[test]
fn aura_walkforward_emits_an_unknown_axis_rejection_once() {
let bp = format!("{}/tests/fixtures/stage1_signal.json", env!("CARGO_MANIFEST_DIR"));
for attempt in 1..=20 {
let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura"))
.args(["walkforward", &bp, "--axis", "graph.fast.length=2,3"])
.output()
.expect("spawn aura walkforward (unknown axis)");
assert_eq!(out.status.code(), Some(2), "an unknown axis must fail clean, not panic");
let stderr = String::from_utf8(out.stderr).expect("utf-8");
assert!(!stderr.contains("panicked"), "must reject cleanly, never panic: {stderr}");
let emits = stderr.matches("UnknownKnob").count();
assert_eq!(
emits, 1,
"attempt {attempt}: the axis rejection must be emitted exactly once, got {emits}: {stderr:?}",
);
}
}
/// E2E (acc 2): `aura mc <open blueprint.json>` is rejected with a named error + exit 2
/// (NOT a panic / exit 101) — MC binds no axis, so a free knob has no binder.
#[test]
@@ -3683,6 +3718,37 @@ fn aura_mc_rejects_an_open_blueprint() {
let _ = std::fs::remove_dir_all(&cwd);
}
/// E2E (#176): `aura run <open blueprint.json>` refuses an open blueprint the same way the
/// sibling `aura mc` does — a clean named error + exit 2, NEVER a `compile_with_params`
/// arity panic (exit 101). The `run` dispatch bootstraps over the EMPTY point (a
/// closed-blueprint assumption), so a free knob must be rejected at the dispatch boundary
/// before that bootstrap, mirroring `blueprint_mc_family`'s closed-guard.
#[test]
fn aura_run_rejects_an_open_blueprint_without_panicking() {
let cwd = temp_cwd("run-blueprint-open-reject");
let fixture = format!("{}/tests/fixtures/stage1_signal_open.json", env!("CARGO_MANIFEST_DIR"));
let out = Command::new(BIN)
.args(["run", &fixture])
.current_dir(&cwd)
.output()
.expect("spawn aura run open-blueprint");
let stderr = String::from_utf8_lossy(&out.stderr).into_owned();
assert_eq!(
out.status.code(),
Some(2),
"an open blueprint fails clean (exit 2, not a panic/exit 101): stderr={stderr}"
);
assert!(
stderr.contains("closed blueprint"),
"names the closed-blueprint requirement (mirroring `aura mc`): {stderr}"
);
assert!(
!stderr.contains("panicked"),
"must refuse at the dispatch boundary, never panic: {stderr}"
);
let _ = std::fs::remove_dir_all(&cwd);
}
/// E2E (negative): `aura reproduce <unknown>` is a hard error (exit non-zero + named
/// cause on stderr), distinct from `runs family <id>`'s treat-as-empty exit 0.
#[test]