From 521459dd50aa6f545a9601b4e758968d1dc06a34 Mon Sep 17 00:00:00 2001 From: claude Date: Sun, 26 Jul 2026 11:47:21 +0200 Subject: [PATCH] feat(aura-cli, aura-runner): exec routing-seam refusals + by-name unbound-role prose Harvest sweep, batch 2 of 6. - exec's file-target classification is now a four-way shape peek (campaign / blueprint / op-script / wrong-kind): a valid-JSON op-script array refuses at the routing seam with a build-first pointer ('aura graph build') instead of the blueprint leg's false 'not valid JSON' claim, and a kind-bearing non-campaign document names the found kind plus exec's two executable classes. Exit-code classes unchanged (op-script usage-class 2, wrong-kind runtime-class 1). - the bare-tap measurement leg renders CompileError::UnboundRootRole by the signal's own root-role NAME (captured pre-consumption), retiring the raw Debug-form leak; every other variant keeps the total fallback. refs #342 refs #339 --- crates/aura-cli/src/main.rs | 136 +++++++++++++++++------ crates/aura-cli/tests/exec.rs | 45 ++++++++ crates/aura-cli/tests/graph_construct.rs | 12 +- crates/aura-runner/src/measure.rs | 32 +++++- 4 files changed, 186 insertions(+), 39 deletions(-) diff --git a/crates/aura-cli/src/main.rs b/crates/aura-cli/src/main.rs index 1f1b074..b73c91b 100644 --- a/crates/aura-cli/src/main.rs +++ b/crates/aura-cli/src/main.rs @@ -663,13 +663,17 @@ fn parse_override_tokens(tokens: &[String]) -> Vec<(String, Scalar)> { // The declarative argument grammar. clap owns argv tokenizing, scoped `--help`, // `--version`, `--flag=value`, `--`, and long-option abbreviation; the `dispatch_*` // handlers below convert each `*Cmd` into the argument shapes the existing execution -// fns accept. `exec` (#319) is the one document-executing verb: it dispatches on -// `is_blueprint_file` layered with `classify_exec_document_file` (the "kind"-field -// peek) — a first-positional naming an existing `.json` file that parses as JSON -// and carries no top-level "kind" selects the loaded-blueprint leg; a file -// carrying "kind", or a target that is not a readable `.json` file at all (a +// fns accept. `exec` (#319, extended #342) is the one document-executing verb: it +// dispatches on `is_blueprint_file` layered with `classify_exec_document_file`'s +// shape peek (top-level array vs. object, then the "kind" field) — a +// first-positional naming an existing `.json` file that parses as JSON and carries +// no top-level "kind" selects the loaded-blueprint leg; a file carrying +// `"kind":"campaign"`, or a target that is not a readable `.json` file at all (a // registered content id), selects the campaign leg; a file that does not parse as -// JSON at all refuses neutrally before either leg's own validation runs. +// JSON at all refuses neutrally before either leg's own validation runs; a +// top-level JSON array (an op-script) or a `"kind"` naming anything other than +// "campaign" (e.g. a process document) is likewise refused right at this seam, +// with exec's own vocabulary, before either leg ever sees it. // `--override`/`--tap` tokens are lexed by `parse_override_tokens`/ // `tap_plan_from_args` above, shared across both legs where applicable. Usage // errors exit 2; runtime refusals exit 1; a campaign that completes with ≥1 @@ -967,27 +971,63 @@ fn is_blueprint_file(arg: &Option) -> Option<&str> { .filter(|a| a.ends_with(".json") && std::path::Path::new(a).is_file()) } +/// `exec`'s FILE-target shape, discriminated once at the routing seam +/// (#342 items 3-4, extending #319's review Minor-3): a document exec +/// cannot execute at all is refused HERE, with exec's own vocabulary, +/// before either leg's own document-shape validation ever runs — never by +/// falling through to a leg whose own refusal prose assumes a shape this +/// document never had. +enum ExecFileShape { + /// A research-document envelope carrying top-level `"kind":"campaign"` + /// — the campaign leg. + Campaign, + /// No top-level `"kind"` at all (a `{"format_version":N,"blueprint":{…}}` + /// envelope) — the blueprint leg. + Blueprint, + /// A top-level JSON array: a construction op-script (#157) — not an + /// envelope of either executable class, however valid its JSON is. + OpScript, + /// A top-level `"kind"` naming something other than `"campaign"` (e.g. + /// a process document) — a real, kind-bearing document, just not one + /// of exec's two executable classes. Carries the found kind value. + WrongKind(String), +} + /// `exec`'s FILE-target leg discriminator, layered on top of /// `is_blueprint_file`: a research-document envelope (process/campaign) /// carries a required top-level `"kind"` field; a blueprint envelope -/// (`{"format_version":N,"blueprint":{…}}`) carries none. `is_blueprint_file` -/// alone only tells "an existing `.json` file" from a bare harness-kind/id -/// token — it cannot tell a campaign document ending in `.json` from a -/// blueprint file, both being ordinary readable `.json` files — so `exec`'s -/// routing peeks this one cheap key before choosing a leg. +/// (`{"format_version":N,"blueprint":{…}}`) carries none; a construction +/// op-script (#157) is a top-level JSON array, not an object at all. +/// `is_blueprint_file` alone only tells "an existing `.json` file" from a +/// bare harness-kind/id token — it cannot tell these shapes apart, all +/// being ordinary readable `.json` files — so `exec`'s routing peeks the +/// top-level shape and the `"kind"` key before choosing a leg. /// -/// `Ok(true)`/`Ok(false)` classifies a file that parses as JSON at all -/// (campaign vs. not); `Err` means the file does NOT parse as JSON at all — -/// review Minor-3: a file this malformed has no leg to misattribute the -/// fault to (neither leg's own "document is not valid JSON" prose is the -/// right cause when the shape was never determined), so the caller refuses -/// neutrally instead of silently falling through to the blueprint leg. -fn classify_exec_document_file(path: &str) -> Result { +/// `Ok(_)` classifies a file that parses as JSON at all; `Err` means the +/// file does NOT parse as JSON at all — review Minor-3: a file this +/// malformed has no leg to misattribute the fault to (neither leg's own +/// "document is not valid JSON" prose is the right cause when the shape was +/// never determined), so the caller refuses neutrally instead of silently +/// falling through to the blueprint leg. +fn classify_exec_document_file(path: &str) -> Result { let text = std::fs::read_to_string(path).map_err(|e| format!("target file is not readable: {e}"))?; let v: serde_json::Value = serde_json::from_str(&text) .map_err(|e| format!("target file is not valid JSON: {e}"))?; - Ok(v.get("kind").is_some()) + if v.is_array() { + return Ok(ExecFileShape::OpScript); + } + match v.get("kind") { + None => Ok(ExecFileShape::Blueprint), + Some(k) => { + let found = k.as_str().map(str::to_string).unwrap_or_else(|| k.to_string()); + if found == "campaign" { + Ok(ExecFileShape::Campaign) + } else { + Ok(ExecFileShape::WrongKind(found)) + } + } + } } /// Terminate per a campaign-path result (#272): a String error is a refusal @@ -1032,26 +1072,60 @@ fn tap_plan_from_args(args: &[String]) -> Result { /// The one executor verb (#319): a campaign document (file or content id) /// or a signal blueprint (single run). Flag discipline is per-leg. A -/// `.json` file target is CLASSIFIED once, up front (review Minor-3): a file -/// that does not parse as JSON at all refuses neutrally here, before either -/// leg's own document-shape validation ever runs — so a malformed campaign -/// document is never misattributed to the blueprint leg's "blueprint -/// document is not valid JSON" prose. +/// `.json` file target is CLASSIFIED once, up front (review Minor-3, #342 +/// items 3-4): a file that does not parse as JSON at all refuses neutrally +/// here, before either leg's own document-shape validation ever runs — so a +/// malformed campaign document is never misattributed to the blueprint +/// leg's "blueprint document is not valid JSON" prose; an op-script or a +/// kind-bearing non-campaign document is likewise refused right here, with +/// exec's own vocabulary, rather than falling through to a leg whose own +/// validation would misattribute or under-explain the cause. fn dispatch_exec(a: ExecCmd, env: &aura_runner::project::Env) { let target = Some(a.target.clone()); let file_path = is_blueprint_file(&target); - let is_campaign_file = match file_path.map(classify_exec_document_file) { - Some(Ok(is_campaign)) => is_campaign, + let shape = match file_path.map(classify_exec_document_file) { + Some(Ok(shape)) => Some(shape), Some(Err(msg)) => { eprintln!("aura: exec: {}: {msg}", file_path.expect("Some by the match arm above")); std::process::exit(2); } - None => false, + None => None, }; - let blueprint_path = file_path.filter(|_| !is_campaign_file); - match blueprint_path { - Some(path) => exec_blueprint_leg(path, &a.r#override, &a.tap, env), - None => { + match (file_path, shape) { + // #342 item 3: a valid-JSON op-script array is not a document exec + // can execute at all — point at the build step that turns it into + // one, instead of letting it fall through to the blueprint leg's + // "not valid JSON" refusal (false: it IS valid JSON, just the wrong + // shape). Same exit-code class as that refusal (usage-class, 2). + (Some(path), Some(ExecFileShape::OpScript)) => { + eprintln!( + "aura: exec: {path}: this document is an op-script (a JSON array of \ +construction ops), not something exec can run directly — build it into a blueprint first \ +with `aura graph build < {path}`, then exec the result" + ); + std::process::exit(2); + } + // #342 item 4: a kind-bearing document that is not a campaign (e.g. + // a process document) names the found kind and exec's two + // executable classes, instead of the campaign leg's generic + // "kind" key must be "campaign" (which never says WHAT was found or + // what exec accepts at all). Same exit-code class as today's + // fall-through refusal (runtime-class, 1 — mirrors + // `exit_on_campaign_result`'s `Err` arm). + (Some(path), Some(ExecFileShape::WrongKind(found))) => { + eprintln!( + "aura: exec: {path}: this document's kind is \"{found}\", but exec executes \ +only a campaign document or a fully-bound blueprint" + ); + std::process::exit(1); + } + (Some(path), Some(ExecFileShape::Blueprint)) => { + exec_blueprint_leg(path, &a.r#override, &a.tap, env) + } + // A campaign-kind file, or a non-file target (a bare 64-hex content + // id): both run the campaign leg, which re-resolves the target + // itself (file-vs-id, then its own full parse+validate). + _ => { if !a.tap.is_empty() { eprintln!( "aura: exec: --tap applies only to a blueprint target; a campaign \ diff --git a/crates/aura-cli/tests/exec.rs b/crates/aura-cli/tests/exec.rs index e8733dc..d6347d9 100644 --- a/crates/aura-cli/tests/exec.rs +++ b/crates/aura-cli/tests/exec.rs @@ -258,6 +258,51 @@ fn exec_malformed_json_file_refuses_neutrally_not_misrouted() { ); } +/// #342 item 3: a valid-JSON op-script (a top-level JSON array of +/// construction ops) handed to `exec` must not fall through to the +/// blueprint leg's "blueprint document is not valid JSON" refusal — that +/// claim is false, the document IS valid JSON, just the wrong shape. It +/// refuses right at the routing seam with prose that names the op-script +/// diagnosis and points at the build step that turns it into a runnable +/// blueprint (`aura graph build`). +#[test] +fn exec_op_script_file_refuses_with_build_hint_not_invalid_json() { + let dir = temp_cwd("exec-op-script"); + let op_script = r#"[ + {"op":"source","role":"price","kind":"F64"}, + {"op":"add","type":"SMA","name":"fast","bind":{"length":{"I64":2}}}, + {"op":"feed","role":"price","into":["fast.series"]}, + {"op":"expose","from":"fast.value","as":"bias"} + ]"#; + write_doc(&dir, "script.json", op_script); + let (out, code) = run_code_in(&dir, &["exec", "script.json"]); + assert_eq!(code, Some(2), "stdout/stderr: {out}"); + assert!(out.contains("op-script"), "must name the op-script diagnosis: {out}"); + assert!(out.contains("graph build"), "must point at the build step: {out}"); + assert!( + !out.contains("not valid JSON"), + "the document IS valid JSON — must not claim otherwise: {out}" + ); +} + +/// #342 item 4: a kind-bearing document that is not a campaign (here, a +/// process document) refuses naming the found kind and exec's two +/// executable classes, instead of the campaign leg's generic "the \"kind\" +/// key must be \"campaign\"" — which never says WHAT was found, nor what +/// exec accepts at all. +#[test] +fn exec_process_document_names_found_kind_and_executable_classes() { + let dir = temp_cwd("exec-wrong-kind-process"); + write_doc(&dir, "p.process.json", SWEEP_ONLY_PROCESS_DOC); + let (out, code) = run_code_in(&dir, &["exec", "p.process.json"]); + assert_eq!(code, Some(1), "stdout/stderr: {out}"); + assert!(out.contains("\"process\""), "must name the found kind: {out}"); + assert!( + out.contains("campaign document") && out.contains("fully-bound blueprint"), + "must name both of exec's executable classes: {out}" + ); +} + // --------------------------------------------------------------------------- // Task 2: the exec blueprint leg (synthetic single run). // --------------------------------------------------------------------------- diff --git a/crates/aura-cli/tests/graph_construct.rs b/crates/aura-cli/tests/graph_construct.rs index b1fdebf..73c9cd2 100644 --- a/crates/aura-cli/tests/graph_construct.rs +++ b/crates/aura-cli/tests/graph_construct.rs @@ -2312,10 +2312,9 @@ fn graph_build_accepts_an_open_input_pattern() { /// through `wrap_r` (an existing, unrelated nesting mechanism unaffected by /// this cycle), so a bare-tap (no-`bias`) pattern is used here: its /// `run_measurement` path compiles the signal directly, hitting -/// `CompileError::UnboundRootRole` at bootstrap — via the EXISTING `{e:?}` -/// Debug rendering (a raw index, not a role name); a name mapping there is -/// left for a follow-up (out of this cycle's scope, per the plan's own -/// escape hatch), so this pins the existing form. +/// `CompileError::UnboundRootRole` at bootstrap — rendered by role NAME +/// (#342 item 3), not the raw flat index the earlier form left as a +/// follow-up. #[test] fn running_an_open_blueprint_refuses_at_bootstrap() { let doc = r#"[ @@ -2333,7 +2332,8 @@ fn running_an_open_blueprint_refuses_at_bootstrap() { assert_eq!(code, Some(1), "an open blueprint refuses standalone at bootstrap, not finish: {stdout} {stderr}"); assert!(stdout.is_empty(), "no report emitted on a bootstrap refusal: {stdout}"); assert!( - stderr.contains("UnboundRootRole"), - "the runnability gate (compile), unchanged, names the fault: {stderr}" + stderr.contains("root role \"price\" is unbound"), + "the runnability gate (compile), unchanged, now names the role, not a raw index: {stderr}" ); + assert!(!stderr.contains("UnboundRootRole"), "Debug leak: {stderr}"); } diff --git a/crates/aura-runner/src/measure.rs b/crates/aura-runner/src/measure.rs index 8ed476b..db9e00f 100644 --- a/crates/aura-runner/src/measure.rs +++ b/crates/aura-runner/src/measure.rs @@ -10,7 +10,7 @@ use std::collections::BTreeMap; use aura_core::{Scalar, Timestamp}; -use aura_engine::{Composite, Harness, MeasurementReport, RunManifest}; +use aura_engine::{CompileError, Composite, Harness, MeasurementReport, RunManifest}; use crate::member::{key_supply, raw_bound_defaults, resolve_run_data, RunData}; use crate::project::Env; @@ -51,6 +51,27 @@ pub fn measurement_manifest( } } +/// #339 item 3 (a #317 follow-up): `CompileError::UnboundRootRole { role }` +/// carries a flat root-role index — meaningless to a caller who authored a +/// NAMED open role (`{"op":"input","role":"price"}`). `role_names` is +/// `signal.input_roles()`'s own names, read before `compile_with_params` +/// consumes `signal` (mirrors `member::compile_error_prose`'s pre-consumption +/// `names` capture for its `ParamKindMismatch` prose). Every other +/// `CompileError` variant keeps the existing Debug fallback — unreachable on +/// this direct-compile path in practice (arity/kind/wiring faults are caught +/// earlier at `finish()`/`introspect`), but kept total rather than partial. +fn compile_error_prose(e: &CompileError, role_names: &[String]) -> String { + let CompileError::UnboundRootRole { role } = e else { + return format!("this blueprint does not compile to a runnable harness: {e:?}"); + }; + let name = role_names.get(*role).map(String::as_str).unwrap_or(""); + format!( + "this blueprint does not compile to a runnable harness: root role \"{name}\" is \ + unbound — it is declared open (an `input` role) but there is no enclosing graph \ + to wire it when run standalone" + ) +} + /// The bare measurement run (C28 phase 3): `run_signal_r` MINUS `wrap_r` and the /// eq/ex/r R-evaluation, KEEPING the declared-tap bind → drain → persist (C27). /// No broker, no risk executor, no per-cycle equity/exposure/r recorders — this @@ -79,12 +100,19 @@ pub fn run_measurement( std::process::exit(1); } let names: Vec = signal.param_space().iter().map(|p| p.name.clone()).collect(); + // #339 item 3: `signal`'s own root-role names, captured before + // `compile_with_params` consumes it below — the bare-tap path compiles + // the signal DIRECTLY (unlike the bias/strategy arm's `wrap_r` nesting), + // so an unbound open root role surfaces here as `CompileError:: + // UnboundRootRole { role }`, a flat index with no name attached at the + // engine boundary. `role_names` lets `compile_error_prose` resolve it. + let role_names: Vec = signal.input_roles().iter().map(|r| r.name.clone()).collect(); let defaults = raw_bound_defaults(&signal); let (sources, window, _pip_size) = resolve_run_data(&data, env, &binding); // Compile the signal DIRECTLY — no wrap_r, no broker/executor/eq-ex-r sinks. let mut flat = signal.compile_with_params(params).unwrap_or_else(|e| { - eprintln!("aura: this blueprint does not compile to a runnable harness: {e:?}"); + eprintln!("aura: {}", compile_error_prose(&e, &role_names)); std::process::exit(1); });