feat(0099): CLI exit-code split (iteration 2) — usage=2, runtime=1

Apply the clean exit-code partition (#175 deviation #8) on top of the clap
migration: runtime failures now exit 1, usage errors stay exit 2, with no
same-class inconsistency.

Partition (attribution principle): exit 2 = a command-line fault (a bad flag
value, an inapplicable combination, or the content of an argv-named file);
exit 1 = the command was well-formed but the environment / recorded state it
needs is missing, or piped stdin data is bad. The 6 boundary cases the spec's
"parse-time vs run-time" rule under-specified are resolved on #175
(malformed/open argv blueprint -> usage 2; stdin op-script content -> runtime 1;
applicability refusals -> usage 2; unknown --axis / --metric -> usage 2).

Flipped 2->1 (runtime): no local data, no recorded geometry, no recorded
run/family, "run has no tap named", trace-name collision, family/trace
persist-write failures (normalizing the lone generalize inconsistency -- all
persist -> 1 together), missing/corrupt content-addressed store state (the
reproduce path), chart-read failures, and graph stdin op-script content +
stdin-read I/O. reproduce-diverged stays exit 1. Usage sites (clap parse,
argv-applicability guards, the dual-grammar blueprint-file read/parse) stay
exit 2.

Tests: ~8 domain-refusal pins flipped Some(2)->Some(1) (message substrings
unchanged), the four runtime tests renamed _exit_2 -> _exit_1, and a partition
property test added. Orchestrator fix after the loop: 3 sibling no-data
skip-guards (run_real_with_trace + the two generalize cross-instrument tests)
that the plan's Task-1 list missed -- left at Some(2), they would fall through
to assert Some(0) and FAIL on a data-less machine; flipped to Some(1) for
consistency with the flipped code.

Deferred (cosmetic, filed forward): full "Usage:" / "usage:" / bare
error-message casing normalization -- no functional impact, high pin-churn.

Verified green (orchestrator, this session): cargo build --workspace, cargo
clippy --workspace --all-targets -- -D warnings, and cargo test --workspace all
clean (0 failed across every test binary).

This completes the #175 clap-adoption cycle (iteration 1 = clap migration at
366170a; iteration 2 = this exit-code split).

refs #175
This commit is contained in:
2026-07-01 20:05:42 +02:00
parent ffb2624dd7
commit fa42bf3878
4 changed files with 148 additions and 85 deletions
+52 -34
View File
@@ -131,7 +131,7 @@ fn run_with_trailing_token_is_strict_and_exits_two() {
}
#[test]
fn run_real_no_geometry_symbol_refuses_with_exit_2() {
fn run_real_no_geometry_symbol_refuses_with_exit_1() {
// The geometry-sidecar lookup precedes any bar-data access, so a symbol with no
// recorded geometry refuses with NO local data required (CI-safe). "NONEXISTENT"
// has no recorded geometry on any host.
@@ -139,7 +139,7 @@ fn run_real_no_geometry_symbol_refuses_with_exit_2() {
.args(["run", "--real", "NONEXISTENT"])
.output()
.expect("spawn aura");
assert_eq!(out.status.code(), Some(2), "expected exit 2");
assert_eq!(out.status.code(), Some(1), "expected exit 1");
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
stderr.contains("no recorded geometry for symbol 'NONEXISTENT'"),
@@ -163,7 +163,7 @@ fn run_real_refusal_names_no_authored_floor() {
.args(["run", "--real", "NONEXISTENT"])
.output()
.expect("spawn aura");
assert_eq!(out.status.code(), Some(2), "expected exit 2");
assert_eq!(out.status.code(), Some(1), "expected exit 1");
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
!stderr.contains("instrument table"),
@@ -182,7 +182,7 @@ fn run_real_refusal_names_no_authored_floor() {
/// Property: the un-specced-symbol refusal is a CLEAN refusal — it emits NOTHING
/// on stdout, never a partial `RunReport`. The #22 acceptance is "refuse INSTEAD
/// of emitting nonsense": the pip lookup precedes harness construction and data
/// access, so a `None` spec must short-circuit to `exit(2)` before any JSON line
/// access, so a `None` spec must short-circuit to `exit(1)` before any JSON line
/// can reach stdout. A regression that moved the lookup after a partial run would
/// leak a `{"manifest":...}` line here while still exiting non-zero; this pins
/// that it cannot. Archive-independent (the lookup never touches local data).
@@ -192,7 +192,7 @@ fn run_real_no_geometry_symbol_emits_no_stdout_report() {
.args(["run", "--real", "NONEXISTENT"])
.output()
.expect("spawn aura");
assert_eq!(out.status.code(), Some(2), "expected exit 2: {:?}", out.status);
assert_eq!(out.status.code(), Some(1), "expected exit 1: {:?}", out.status);
assert!(
out.stdout.is_empty(),
"the refusal must not leak a partial report to stdout, got: {:?}",
@@ -221,7 +221,7 @@ fn run_real_sidecar_symbol_never_hits_the_pip_refusal() {
"a symbol with a recorded sidecar must clear the pip lookup, never the pip-refusal; stderr: {stderr}"
);
// It resolves to exactly one of the two legitimate outcomes: a clean report
// (exit 0, data present) or the distinct no-local-data refusal (exit 2).
// (exit 0, data present) or the distinct no-local-data refusal (exit 1).
match out.status.code() {
Some(0) => assert!(
String::from_utf8_lossy(&out.stdout)
@@ -230,9 +230,9 @@ fn run_real_sidecar_symbol_never_hits_the_pip_refusal() {
"exit 0 must carry a JSON report, got: {:?}",
String::from_utf8_lossy(&out.stdout)
),
Some(2) => assert!(
Some(1) => assert!(
stderr.contains("no local data for symbol 'EURUSD'"),
"exit 2 for a symbol with a recorded sidecar must be the no-data path, got: {stderr}"
"exit 1 for a symbol with a recorded sidecar must be the no-data path, got: {stderr}"
),
other => panic!("unexpected exit for a real run with a recorded sidecar: {other:?}; stderr: {stderr}"),
}
@@ -246,7 +246,7 @@ fn run_real_sidecar_symbol_never_hits_the_pip_refusal() {
/// equity is honestly scaled per instrument. Gated on local GER40 data — skip
/// cleanly when the archive is absent (the project's skip-on-no-data convention),
/// so it never fails on a data-less machine; the no-data path is the distinct
/// "no local data" refusal (exit 2), not the pip-refusal, asserted above.
/// "no local data" refusal (exit 1), not the pip-refusal, asserted above.
#[test]
fn run_real_sidecar_index_pip_reaches_the_emitted_manifest() {
// The GER40 Sept-2024 window (inclusive Unix-ms), the same calendar
@@ -259,12 +259,12 @@ fn run_real_sidecar_index_pip_reaches_the_emitted_manifest() {
.expect("spawn aura");
// Skip on a data-less machine: a recorded-sidecar symbol with no local data takes
// the distinct no-data path (exit 2, "no local data"), never the pip-refusal.
if out.status.code() == Some(2) {
// the distinct no-data path (exit 1, "no local data"), never the pip-refusal.
if out.status.code() == Some(1) {
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
stderr.contains("no local data for symbol 'GER40'"),
"exit 2 for GER40 must be the no-data path, got: {stderr}"
"exit 1 for GER40 must be the no-data path, got: {stderr}"
);
eprintln!("skip: no local GER40 data");
return;
@@ -299,7 +299,7 @@ fn run_real_nonvetted_symbol_resolves_pip_from_sidecar() {
// Skip on a host without USDJPY geometry/data — either refusal is a clean skip,
// sourced from recorded geometry, never an authored-table miss.
if out.status.code() == Some(2) {
if out.status.code() == Some(1) {
let stderr = String::from_utf8_lossy(&out.stderr);
if stderr.contains("no recorded geometry") || stderr.contains("no local data") {
eprintln!("skip: no local USDJPY geometry/data");
@@ -511,12 +511,12 @@ fn run_real_with_trace_persists_taps_and_keeps_stdout_unchanged() {
.expect("spawn aura run --real --trace");
// Skip on a data-less machine: GER40 with no local data takes the
// distinct no-data path (exit 2), never the pip-refusal, and writes no traces.
if out.status.code() == Some(2) {
// distinct no-data path (exit 1, runtime), never the pip-refusal, and writes no traces.
if out.status.code() == Some(1) {
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
stderr.contains("no local data for symbol 'GER40'"),
"exit 2 for GER40 --trace must be the no-data path, got: {stderr}"
"exit 1 for GER40 --trace must be the no-data path, got: {stderr}"
);
eprintln!("skip: no local GER40 data");
let _ = std::fs::remove_dir_all(&cwd);
@@ -567,9 +567,9 @@ fn chart_emits_self_contained_html_for_a_persisted_run() {
assert!(html.contains("\"equity\""), "equity series missing from the chart");
assert!(html.contains("uPlot=function"), "vendored uPlot not inlined");
// a missing run is a usage error (stderr + exit 2), not a panic.
// a missing run is a runtime failure (stderr + exit 1), not a panic.
let missing = Command::new(BIN).args(["chart", "nope"]).current_dir(&cwd).output().expect("spawn chart nope");
assert_eq!(missing.status.code(), Some(2), "missing run must exit 2");
assert_eq!(missing.status.code(), Some(1), "missing run must exit 1");
assert!(!missing.status.success());
let _ = std::fs::remove_dir_all(&cwd);
@@ -608,11 +608,11 @@ fn chart_panels_flag_selects_panels_mode() {
/// Property: `--tap <nonexistent>` on a single-run chart is a CLEAN refusal at the
/// CLI seam — refuse-don't-guess (C10). The builder's no-tap `Err` is unit-tested,
/// but this pins the user-facing wiring: `filter_to_tap` returning `Err` must reach
/// `eprintln!` + `exit(2)`, never a panic and never a chart of zero series. A
/// `eprintln!` + `exit(1)`, never a panic and never a chart of zero series. A
/// regression that swallowed the `Err` (e.g. charting an empty `ChartData`) would
/// exit 0 with an empty page; this fails it. Archive-local (persists its own run).
#[test]
fn chart_tap_nonexistent_on_run_refuses_with_exit_2() {
fn chart_tap_nonexistent_on_run_refuses_with_exit_1() {
let cwd = temp_cwd("chart-tap-missing");
// persist a single run; its taps are `equity` / `exposure`, never `nope`.
@@ -620,7 +620,7 @@ fn chart_tap_nonexistent_on_run_refuses_with_exit_2() {
assert!(traced.status.success(), "run --trace exit: {:?}", traced.status);
let out = Command::new(BIN).args(["chart", "demo", "--tap", "nope"]).current_dir(&cwd).output().expect("spawn chart --tap");
assert_eq!(out.status.code(), Some(2), "nonexistent tap must exit 2: {:?}", out.status);
assert_eq!(out.status.code(), Some(1), "nonexistent tap must exit 1: {:?}", out.status);
assert!(out.stdout.is_empty(), "the refusal must not leak a chart page to stdout, got: {:?}", String::from_utf8_lossy(&out.stdout));
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
@@ -637,19 +637,19 @@ fn chart_tap_nonexistent_on_run_refuses_with_exit_2() {
let _ = std::fs::remove_dir_all(&cwd);
}
/// Property: an unknown `chart <name>` is a usage error pinned at BOTH the exit code
/// and the user-facing message — exit 2 (never a panic) AND the NotFound branch's
/// Property: an unknown `chart <name>` is a runtime failure pinned at BOTH the exit code
/// and the user-facing message — exit 1 (never a panic) AND the NotFound branch's
/// "no recorded run or family '<name>'" wording, which now covers families too. A
/// regression that drifted the message (e.g. back to the run-only phrasing) or
/// downgraded the exit would slip past the bundled exit-only check; this pins the
/// message/exit contract. Archive-local: charts into an empty cwd so the name is
/// genuinely absent.
#[test]
fn chart_unknown_name_refuses_with_exit_2_and_message() {
fn chart_unknown_name_refuses_with_exit_1_and_message() {
let cwd = temp_cwd("chart-unknown");
let out = Command::new(BIN).args(["chart", "ghost"]).current_dir(&cwd).output().expect("spawn chart ghost");
assert_eq!(out.status.code(), Some(2), "unknown name must exit 2: {:?}", out.status);
assert_eq!(out.status.code(), Some(1), "unknown name must exit 1: {:?}", out.status);
assert!(out.stdout.is_empty(), "the refusal must not leak a chart page to stdout, got: {:?}", String::from_utf8_lossy(&out.stdout));
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
@@ -1345,16 +1345,16 @@ fn walkforward_real_persists_one_oos_member_per_window() {
/// Property: the per-instrument pip refusal fires for `aura sweep --real
/// <no-geometry symbol>` exactly as it does for `aura run --real` — a symbol with no
/// recorded geometry (`NONEXISTENT`, no sidecar on any host) is rejected with exit 2
/// recorded geometry (`NONEXISTENT`, no sidecar on any host) is rejected with exit 1
/// and the "no recorded geometry" message BEFORE any bar-data access. NOT gated: the
/// refusal precedes the archive entirely, so it is CI-safe and runs on every machine.
#[test]
fn sweep_real_no_geometry_symbol_refuses_with_exit_2() {
fn sweep_real_no_geometry_symbol_refuses_with_exit_1() {
// not gated: the pip refusal happens before any data access.
let dir = temp_cwd("sweep_real_refuse");
let out = std::process::Command::new(BIN)
.args(["sweep", "--real", "NONEXISTENT"]).current_dir(&dir).output().unwrap();
assert_eq!(out.status.code(), Some(2));
assert_eq!(out.status.code(), Some(1));
assert!(String::from_utf8_lossy(&out.stderr).contains("no recorded geometry"));
let _ = std::fs::remove_dir_all(&dir);
@@ -1511,13 +1511,13 @@ fn trace_name_collision_across_kinds_is_refused() {
let run = Command::new(BIN).args(["run", "--trace", "t"]).current_dir(&cwd).output().expect("spawn run --trace");
assert!(run.status.success(), "run --trace exit: {:?}", run.status);
let sweep = Command::new(BIN).args(["sweep", "--trace", "t"]).current_dir(&cwd).output().expect("spawn sweep --trace");
assert_eq!(sweep.status.code(), Some(2), "sweep onto a run name must exit 2");
assert_eq!(sweep.status.code(), Some(1), "sweep onto a run name must exit 1");
// reverse: sweep then run on a fresh name -> the run is refused.
let sweep2 = Command::new(BIN).args(["sweep", "--trace", "u"]).current_dir(&cwd).output().expect("spawn sweep --trace u");
assert!(sweep2.status.success(), "sweep --trace u exit: {:?}", sweep2.status);
let run2 = Command::new(BIN).args(["run", "--trace", "u"]).current_dir(&cwd).output().expect("spawn run --trace u");
assert_eq!(run2.status.code(), Some(2), "run onto a family name must exit 2");
assert_eq!(run2.status.code(), Some(1), "run onto a family name must exit 1");
let _ = std::fs::remove_dir_all(&cwd);
}
@@ -3115,11 +3115,11 @@ fn generalize_grades_a_candidate_across_two_instruments() {
.current_dir(&cwd)
.output()
.expect("spawn aura");
if out.status.code() == Some(2) {
if out.status.code() == Some(1) {
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
stderr.contains("no local data") || stderr.contains("no recorded geometry"),
"exit 2 must be a data refusal, got: {stderr}"
"exit 1 must be a data refusal, got: {stderr}"
);
eprintln!("skip: no local GER40/USDJPY data");
return;
@@ -3262,11 +3262,11 @@ fn generalize_persists_a_discoverable_cross_instrument_family() {
.current_dir(&cwd)
.output()
.expect("spawn aura");
if out.status.code() == Some(2) {
if out.status.code() == Some(1) {
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
stderr.contains("no local data") || stderr.contains("no recorded geometry"),
"exit 2 must be a data refusal, got: {stderr}"
"exit 1 must be a data refusal, got: {stderr}"
);
eprintln!("skip: no local GER40/USDJPY data");
let _ = std::fs::remove_dir_all(&cwd);
@@ -4019,3 +4019,21 @@ fn dual_grammar_discriminator_requires_an_existing_file_not_just_json_suffix() {
"the built-in run grammar (not the blueprint-read path) must handle it: {stderr:?}"
);
}
/// The exit-code partition (#175 iteration 2, deviation #8): a USAGE error (a
/// malformed command line) exits 2; a RUNTIME failure (a well-formed command
/// whose needed data/state is missing) exits 1. Pins the partition as a property.
#[test]
fn exit_codes_partition_usage_two_from_runtime_one() {
// usage: an unknown flag is a command-line error → exit 2
let usage = std::process::Command::new(env!("CARGO_BIN_EXE_aura"))
.args(["run", "--bogus"]).output().expect("spawn");
assert_eq!(usage.status.code(), Some(2),
"unknown flag is a usage error → 2; stderr: {}", String::from_utf8_lossy(&usage.stderr));
// runtime: a well-formed command whose symbol has no recorded data → exit 1
let runtime = std::process::Command::new(env!("CARGO_BIN_EXE_aura"))
.args(["run", "--real", "NONEXISTENT"]).output().expect("spawn");
assert_eq!(runtime.status.code(), Some(1),
"no data for a valid command is a runtime failure → 1; stderr: {}",
String::from_utf8_lossy(&runtime.stderr));
}