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
This commit is contained in:
+105
-31
@@ -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<String>) -> 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<bool, String> {
|
||||
/// `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<ExecFileShape, String> {
|
||||
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<TapPlan, String> {
|
||||
|
||||
/// 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 \
|
||||
|
||||
@@ -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).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
|
||||
@@ -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("<unknown>");
|
||||
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<String> = 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<String> = 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);
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user