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:
@@ -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));
|
||||
}
|
||||
|
||||
@@ -25,6 +25,22 @@ fn run(args: &[&str], stdin_doc: &str) -> (String, String, bool) {
|
||||
)
|
||||
}
|
||||
|
||||
/// Like `run`, but returns the exact process exit CODE (not just success/failure).
|
||||
/// The exit-code partition (#175 iteration 2) is a property of the integer, so its
|
||||
/// tests must assert `Some(1)` vs `Some(2)`, which `run`'s bool cannot express.
|
||||
fn run_code(args: &[&str], stdin_doc: &str) -> (String, Option<i32>) {
|
||||
let mut child = Command::new(BIN)
|
||||
.args(args)
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.expect("spawn aura");
|
||||
child.stdin.take().unwrap().write_all(stdin_doc.as_bytes()).unwrap();
|
||||
let out = child.wait_with_output().expect("wait aura");
|
||||
(String::from_utf8_lossy(&out.stderr).into_owned(), out.status.code())
|
||||
}
|
||||
|
||||
const SIGNAL_DOC: &str = r#"[
|
||||
{"op":"source","role":"price","kind":"F64"},
|
||||
{"op":"add","type":"SMA","name":"fast","bind":{"length":{"I64":2}}},
|
||||
@@ -251,3 +267,32 @@ fn graph_introspect_content_id_rejects_a_bad_document() {
|
||||
assert!(stdout.is_empty(), "no content id emitted on error: {stdout}");
|
||||
assert!(stderr.contains("Nope"), "names the cause: {stderr}");
|
||||
}
|
||||
|
||||
/// Property (exit-code partition, #175 iteration 2, deviation #8): a malformed
|
||||
/// op-script piped to `aura graph build` is a RUNTIME failure — bad stdin *content*,
|
||||
/// exit 1 — NOT the usage code 2. The attribution principle classifies bad
|
||||
/// piped-stdin data as environment/runtime (the command line was well-formed),
|
||||
/// distinct from a command-line fault. The sibling `graph_build_fails_fast_at_the_
|
||||
/// offending_op` pins only `!ok` (non-zero); this pins the exact integer, so a
|
||||
/// regression that reverted the graph_construct stdin site to the pre-split exit 2
|
||||
/// fails here.
|
||||
#[test]
|
||||
fn graph_build_bad_stdin_content_is_runtime_exit_1() {
|
||||
let (stderr, code) = run_code(&["graph", "build"], r#"[{"op":"add","type":"Nope"}]"#);
|
||||
assert_eq!(code, Some(1), "bad stdin content is a runtime failure -> exit 1; stderr: {stderr}");
|
||||
assert!(stderr.contains("Nope"), "still names the cause: {stderr}");
|
||||
}
|
||||
|
||||
/// Property (exit-code partition, #175 iteration 2): within the SAME `graph
|
||||
/// introspect` subcommand, an invalid `--node <T>` flag VALUE is a USAGE error (a
|
||||
/// command-line fault the user must fix in argv) — exit 2 — distinct from bad stdin
|
||||
/// content on the `--unwired`/`--content-id` branches, which is runtime exit 1. This
|
||||
/// pins the argv-fault-vs-stdin-fault boundary the attribution principle draws at
|
||||
/// the exact integer; the sibling `graph_introspect_node_rejects_an_unknown_type`
|
||||
/// checks only non-zero, and would pass whether the code were 1 or 2.
|
||||
#[test]
|
||||
fn graph_introspect_unknown_node_flag_is_usage_exit_2() {
|
||||
let (stderr, code) = run_code(&["graph", "introspect", "--node", "Bogus"], "");
|
||||
assert_eq!(code, Some(2), "an invalid --node flag value is a usage error -> exit 2; stderr: {stderr}");
|
||||
assert!(stderr.contains("Bogus"), "names the bad type: {stderr}");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user