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
+6 -6
View File
@@ -129,7 +129,7 @@ pub fn build_cmd() {
let mut doc = String::new();
if let Err(e) = std::io::stdin().read_to_string(&mut doc) {
eprintln!("aura: reading stdin: {e}");
std::process::exit(2);
std::process::exit(1);
}
match build_from_str(&doc) {
// `print!`, not `println!`: the canonical artifact is exactly the library
@@ -139,7 +139,7 @@ pub fn build_cmd() {
Ok(json) => print!("{json}"),
Err(msg) => {
eprintln!("aura: {msg}");
std::process::exit(2);
std::process::exit(1);
}
}
}
@@ -209,13 +209,13 @@ pub fn introspect_cmd(cmd: crate::GraphIntrospectCmd) {
let mut doc = String::new();
if let Err(e) = std::io::stdin().read_to_string(&mut doc) {
eprintln!("aura: reading stdin: {e}");
std::process::exit(2);
std::process::exit(1);
}
match introspect_unwired(&doc) {
Ok(s) => print!("{s}"),
Err(m) => {
eprintln!("aura: {m}");
std::process::exit(2);
std::process::exit(1);
}
}
} else {
@@ -227,13 +227,13 @@ pub fn introspect_cmd(cmd: crate::GraphIntrospectCmd) {
let mut doc = String::new();
if let Err(e) = std::io::stdin().read_to_string(&mut doc) {
eprintln!("aura: reading stdin: {e}");
std::process::exit(2);
std::process::exit(1);
}
match build_from_str(&doc) {
Ok(json) => println!("{}", crate::content_id(&json)),
Err(m) => {
eprintln!("aura: {m}");
std::process::exit(2);
std::process::exit(1);
}
}
}
+45 -45
View File
@@ -187,7 +187,7 @@ fn sim_optimal_manifest(
/// (beside the run registry's `runs/`). Shared by every run form — all drain the same
/// two f64 taps (equity off the broker, exposure off the strategy) and carry a
/// `RunManifest`. Pure wiring over `ColumnarTrace` + `TraceStore`; the engine is
/// untouched. An I/O failure is a usage-level error (stderr + exit 2), per the spec.
/// untouched. An I/O failure is a runtime error (stderr + exit 1), per the spec.
fn persist_traces(
name: &str,
manifest: &RunManifest,
@@ -200,7 +200,7 @@ fn persist_traces(
];
if let Err(e) = TraceStore::open("runs").write(name, manifest, &taps) {
eprintln!("aura: trace persist failed: {e}");
std::process::exit(2);
std::process::exit(1);
}
}
@@ -497,8 +497,8 @@ fn filter_to_tap(data: ChartData, tap: &str) -> Result<ChartData, String> {
/// `aura chart <name> [--tap <t>] [--panels]`: classify the name and render. A
/// single run charts all its taps (or the one `--tap` selects); a family overlays
/// one tap (default `equity`) across its members; an unknown name is a usage error
/// (stderr + exit 2), never a panic.
/// one tap (default `equity`) across its members; an unknown name is a runtime error
/// (stderr + exit 1), never a panic.
fn emit_chart(name: &str, tap: Option<&str>, mode: ChartMode) {
let store = TraceStore::open("runs");
match store.name_kind(name) {
@@ -507,7 +507,7 @@ fn emit_chart(name: &str, tap: Option<&str>, mode: ChartMode) {
Ok(t) => t,
Err(e) => {
eprintln!("aura: {e}");
std::process::exit(2);
std::process::exit(1);
}
};
let mut data = build_chart_data(name, traces);
@@ -516,7 +516,7 @@ fn emit_chart(name: &str, tap: Option<&str>, mode: ChartMode) {
Ok(d) => d,
Err(e) => {
eprintln!("aura: {e}");
std::process::exit(2);
std::process::exit(1);
}
};
}
@@ -528,14 +528,14 @@ fn emit_chart(name: &str, tap: Option<&str>, mode: ChartMode) {
Ok(m) => m,
Err(e) => {
eprintln!("aura: {e}");
std::process::exit(2);
std::process::exit(1);
}
};
let data = match build_comparison_chart_data(name, &members, tap.unwrap_or("equity")) {
Ok(d) => d,
Err(e) => {
eprintln!("aura: {e}");
std::process::exit(2);
std::process::exit(1);
}
};
let data = decimate(data, CHART_DECIMATE_BUCKETS);
@@ -546,7 +546,7 @@ fn emit_chart(name: &str, tap: Option<&str>, mode: ChartMode) {
"aura: no recorded run or family '{name}' under runs/traces \
(run `aura run --trace {name}` or `aura sweep --trace {name}` first)"
);
std::process::exit(2);
std::process::exit(1);
}
}
}
@@ -559,7 +559,7 @@ fn run_sample(trace: Option<&str>) -> RunReport {
&& let Err(e) = TraceStore::open("runs").ensure_name_free(n, WriteKind::Run)
{
eprintln!("aura: {e}");
std::process::exit(2);
std::process::exit(1);
}
let (mut h, rx_eq, rx_ex) = sample_harness(SYNTHETIC_PIP_SIZE);
let sources: Vec<Box<dyn aura_engine::Source>> =
@@ -592,13 +592,13 @@ fn run_sample(trace: Option<&str>) -> RunReport {
/// fold as `run_sample`. The manifest window is read from a *separate* probe source
/// (a Source is single-pass), so the run source streams the window untouched.
/// A no-local-data condition (unknown symbol, or a window overlapping no file / no
/// bars) is a usage error: stderr + exit(2), not a panic.
/// bars) is a runtime error: stderr + exit(1), not a panic.
fn run_sample_real(symbol: &str, from_ms: Option<i64>, to_ms: Option<i64>, trace: Option<&str>) -> RunReport {
if let Some(n) = trace
&& let Err(e) = TraceStore::open("runs").ensure_name_free(n, WriteKind::Run)
{
eprintln!("aura: {e}");
std::process::exit(2);
std::process::exit(1);
}
let (source, window, pip_size) = open_real_source(symbol, from_ms, to_ms);
let (mut h, rx_eq, rx_ex) = sample_harness(pip_size);
@@ -655,14 +655,14 @@ enum DataSource {
},
}
/// No-local-data refusal — stderr + exit(2), mirroring `run_sample_real`.
/// No-local-data refusal — stderr + exit(1), mirroring `run_sample_real`.
fn no_real_data(symbol: &str) -> ! {
eprintln!("aura: no local data for symbol '{symbol}' at {}", data_server::DEFAULT_DATA_PATH);
std::process::exit(2)
std::process::exit(1)
}
/// Resolve the per-instrument pip from the recorded geometry sidecar, or refuse
/// (stderr + exit 2) when the symbol has no recorded geometry — the single home of
/// (stderr + exit 1) when the symbol has no recorded geometry — the single home of
/// the guessed-pip refusal, shared by `open_real_source` and `from_choice`. Reads
/// the symbol's geometry metadata (not bar data) before the run source opens, so an
/// instrument with no recorded geometry refuses without a guessed pip; the pip is
@@ -676,7 +676,7 @@ fn pip_or_refuse(server: &std::sync::Arc<data_server::DataServer>, symbol: &str)
refusing to run a real instrument with a guessed pip",
data_server::DEFAULT_DATA_PATH
);
std::process::exit(2);
std::process::exit(1);
}
}
}
@@ -706,7 +706,7 @@ fn probe_window(
/// the run source paired with its manifest `window` and per-instrument `pip_size`.
/// Single home of the real-source construction the single-run handlers share — the
/// sidecar-pip lookup, the `DataServer` `has_symbol` refusal, the probe-window pass, and
/// the run-source `open` (each refusal an stderr + exit 2). Pre-data refusals keep the
/// the run-source `open` (each refusal an stderr + exit 1). Pre-data refusals keep the
/// pip honest by construction. Shared by `run_sample_real` and `run_stage1_r`.
fn open_real_source(
symbol: &str,
@@ -731,7 +731,7 @@ fn open_real_source(
}
impl DataSource {
/// Build a provider from a parsed choice, or refuse (stderr + exit 2) on a symbol
/// Build a provider from a parsed choice, or refuse (stderr + exit 1) on a symbol
/// with no recorded geometry / absent data — both BEFORE any member runs (Fork C/G),
/// via the same `pip_or_refuse` / `no_real_data` helpers as
/// `run_sample_real`.
@@ -1613,7 +1613,7 @@ fn run_sweep(strategy: Strategy, name: &str, persist: bool, data: DataSource, gr
&& let Err(e) = TraceStore::open("runs").ensure_name_free(name, WriteKind::Family)
{
eprintln!("aura: {e}");
std::process::exit(2);
std::process::exit(1);
}
let reg = default_registry();
let family = match strategy {
@@ -1627,7 +1627,7 @@ fn run_sweep(strategy: Strategy, name: &str, persist: bool, data: DataSource, gr
Ok(id) => id,
Err(e) => {
eprintln!("aura: {e}");
std::process::exit(2);
std::process::exit(1);
}
};
for pt in &family.points {
@@ -1690,7 +1690,7 @@ fn run_walkforward(strategy: Strategy, name: &str, persist: bool, data: DataSour
&& let Err(e) = TraceStore::open("runs").ensure_name_free(name, WriteKind::Family)
{
eprintln!("aura: {e}");
std::process::exit(2);
std::process::exit(1);
}
let reg = default_registry();
let result = walkforward_family(strategy, persist.then_some(name), &data, grid, select);
@@ -1700,7 +1700,7 @@ fn run_walkforward(strategy: Strategy, name: &str, persist: bool, data: DataSour
Ok(id) => id,
Err(e) => {
eprintln!("aura: {e}");
std::process::exit(2);
std::process::exit(1);
}
};
for w in &result.windows {
@@ -2009,7 +2009,7 @@ fn run_mc(name: &str, persist: bool) {
&& let Err(e) = TraceStore::open("runs").ensure_name_free(name, WriteKind::Family)
{
eprintln!("aura: {e}");
std::process::exit(2);
std::process::exit(1);
}
let reg = default_registry();
let family = mc_family(persist.then_some(name));
@@ -2017,7 +2017,7 @@ fn run_mc(name: &str, persist: bool) {
Ok(id) => id,
Err(e) => {
eprintln!("aura: {e}");
std::process::exit(2);
std::process::exit(1);
}
};
for draw in &family.draws {
@@ -2102,7 +2102,7 @@ fn runs_families() {
Ok(m) => m,
Err(e) => {
eprintln!("aura: {e}");
std::process::exit(2);
std::process::exit(1);
}
};
for fam in group_families(members) {
@@ -2122,7 +2122,7 @@ fn runs_family(id: &str, rank: Option<&str>) {
Ok(m) => m,
Err(e) => {
eprintln!("aura: {e}");
std::process::exit(2);
std::process::exit(1);
}
};
let Some(family) = group_families(members).into_iter().find(|f| f.id == id) else {
@@ -2184,11 +2184,11 @@ fn point_from_params(space: &[ParamSpec], params: &[(String, Scalar)]) -> Vec<Ce
.map(|(_, s)| s.cell())
.unwrap_or_else(|| {
// A manifest missing a param the reloaded space expects is
// corrupted-on-disk data — exit cleanly (`aura:` + exit 2) like
// corrupted-on-disk data — exit cleanly (`aura:` + exit 1) like
// every other persisted-data failure on the reproduce path, not
// a panic.
eprintln!("aura: manifest is missing param {}", ps.name);
std::process::exit(2);
std::process::exit(1);
})
})
.collect()
@@ -2199,13 +2199,13 @@ fn point_from_params(space: &[ParamSpec], params: &[(String, Scalar)]) -> Vec<Ce
fn reproduce_family_in(reg: &Registry, id: &str, data: &DataSource) -> ReproduceReport {
let members = reg.load_family_members().unwrap_or_else(|e| {
eprintln!("aura: {e}");
std::process::exit(2);
std::process::exit(1);
});
let Some(family) = group_families(members).into_iter().find(|f| f.id == id) else {
// reproduce is an action, not a lookup: an unknown id is a hard error (distinct
// from `runs family <id>`'s treat-as-empty exit 0).
eprintln!("aura: no such family '{id}'");
std::process::exit(2);
std::process::exit(1);
};
let pip = data.pip_size();
let window = data.full_window();
@@ -2214,17 +2214,17 @@ fn reproduce_family_in(reg: &Registry, id: &str, data: &DataSource) -> Reproduce
let stored = &member.report;
let hash = stored.manifest.topology_hash.clone().unwrap_or_else(|| {
eprintln!("aura: family member has no topology_hash; not a generated run");
std::process::exit(2);
std::process::exit(1);
});
let doc = reg
.get_blueprint(&hash)
.unwrap_or_else(|e| {
eprintln!("aura: {e}");
std::process::exit(2);
std::process::exit(1);
})
.unwrap_or_else(|| {
eprintln!("aura: blueprint {hash} missing from store");
std::process::exit(2);
std::process::exit(1);
});
// Reload the stored blueprint per use: a Composite is !Clone, and both the
// param-space probe (below) and the re-run each consume one. The doc was
@@ -2232,7 +2232,7 @@ fn reproduce_family_in(reg: &Registry, id: &str, data: &DataSource) -> Reproduce
let reload = || {
blueprint_from_json(&doc, &|t| std_vocabulary(t)).unwrap_or_else(|e| {
eprintln!("aura: stored blueprint {hash} does not parse: {e:?}");
std::process::exit(2);
std::process::exit(1);
})
};
// The param_space of the WRAPPED signal — its knobs carry the `stage1_r`
@@ -2401,7 +2401,7 @@ fn run_macd(trace: Option<&str>) -> RunReport {
&& let Err(e) = TraceStore::open("runs").ensure_name_free(n, WriteKind::Run)
{
eprintln!("aura: {e}");
std::process::exit(2);
std::process::exit(1);
}
let (tx_eq, rx_eq) = mpsc::channel();
let (tx_ex, rx_ex) = mpsc::channel();
@@ -3107,14 +3107,14 @@ fn run_blueprint_sweep(doc: &str, axes: &[(String, Vec<Scalar>)], name: &str, pe
.expect("a loaded blueprint re-serializes");
reg.put_blueprint(&topo, &canonical).unwrap_or_else(|e| {
eprintln!("aura: {e}");
std::process::exit(2);
std::process::exit(1);
});
// Record the family unconditionally (C18/C21 lineage), exactly like `run_sweep`.
let id = match reg.append_family(name, FamilyKind::Sweep, &sweep_member_reports(&family)) {
Ok(id) => id,
Err(e) => {
eprintln!("aura: {e}");
std::process::exit(2);
std::process::exit(1);
}
};
for pt in &family.points {
@@ -3141,10 +3141,10 @@ fn run_blueprint_walkforward(doc: &str, axes: &[(String, Vec<Scalar>)], name: &s
&blueprint_from_json(doc, &|t| std_vocabulary(t)).expect("doc parse-validated at the dispatch boundary"),
)
.expect("a loaded blueprint re-serializes");
reg.put_blueprint(&topo, &canonical).unwrap_or_else(|e| { eprintln!("aura: {e}"); std::process::exit(2); });
reg.put_blueprint(&topo, &canonical).unwrap_or_else(|e| { eprintln!("aura: {e}"); std::process::exit(1); });
let id = match reg.append_family(name, FamilyKind::WalkForward, &walkforward_member_reports(&result)) {
Ok(id) => id,
Err(e) => { eprintln!("aura: {e}"); std::process::exit(2); }
Err(e) => { eprintln!("aura: {e}"); std::process::exit(1); }
};
for w in &result.windows {
println!("{}", family_member_line(&id, &w.run.oos_report));
@@ -3177,13 +3177,13 @@ fn run_blueprint_mc(doc: &str, n_seeds: u64, name: &str, data: DataSource) {
.expect("a loaded blueprint re-serializes");
reg.put_blueprint(&topo, &canonical).unwrap_or_else(|e| {
eprintln!("aura: {e}");
std::process::exit(2);
std::process::exit(1);
});
let id = match reg.append_family(name, FamilyKind::MonteCarlo, &mc_member_reports(&family)) {
Ok(id) => id,
Err(e) => {
eprintln!("aura: {e}");
std::process::exit(2);
std::process::exit(1);
}
};
for draw in &family.draws {
@@ -3422,7 +3422,7 @@ fn run_stage1_r(
&& let Err(e) = TraceStore::open("runs").ensure_name_free(n, WriteKind::Run)
{
eprintln!("aura: {e}");
std::process::exit(2);
std::process::exit(1);
}
let (tx_eq, rx_eq) = mpsc::channel();
let (tx_ex, rx_ex) = mpsc::channel();
@@ -3521,7 +3521,7 @@ fn persist_traces_r(
}
if let Err(e) = TraceStore::open("runs").write(name, manifest, &taps) {
eprintln!("aura: trace persist failed: {e}");
std::process::exit(2);
std::process::exit(1);
}
}
@@ -3596,7 +3596,7 @@ fn run_dispatch(args: RunArgs) -> Result<RunReport, String> {
// `parse_param_cells`). The four dual-grammar subcommands carry an optional
// `[blueprint]` positional; a first-positional that names an existing `.json` file
// (`is_blueprint_file`) selects the loaded-blueprint branch, otherwise the built-in
// grammar. Exit codes are behaviour-preserving (usage errors stay exit 2).
// grammar. Usage errors (clap parse + argv-applicability guards) exit 2; runtime failures exit 1.
/// The `aura` root parser. `#[command(version)]` reads `CARGO_PKG_VERSION`
/// (the workspace `0.1.0`), so `aura --version` prints `aura 0.1.0`.
@@ -5095,7 +5095,7 @@ mod tests {
/// local Pepperstone archive is absent, so the test never fails on a machine
/// without the data. Uses `GER40` — a *vetted* symbol (pip 1.0): the
/// per-instrument-pip refusal makes `run_sample_real` reject an un-specced
/// symbol before any data access (`std::process::exit(2)`), so this CLI-level
/// symbol before any data access (`std::process::exit(1)`), so this CLI-level
/// test must drive a symbol in the instrument table. The bounded-window AAPL.US
/// streaming property still lives in the ingest `streaming_seam` test, which
/// builds its source literally without the spec lookup.
+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));
}
+45
View File
@@ -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}");
}