feat(cli): house-style loader-error prose + missing-project hint on the run path

aura run leaked the raw LoadError Debug form (UnknownNodeType("..."))
while graph build already phrased the same fault house-style (#162
convention: engine error types are Display-free, the CLI presents them).
Route the run path through the shared blueprint_load_prose presenter (now
pub(crate), env-agnostic) so both surfaces phrase a LoadError identically
and neither leaks the Debug token (#184). Add unresolved_namespace_hint:
when the unresolved id looks project-namespaced (::) and no project was
loaded (env.provenance() is None), append a hint pointing at the missing
Aura.toml — the newcomer running the host from the wrong directory now
gets an orienting diagnosis instead of a bare type error (#185). The hint
is applied on both the run and graph-build paths. RED-first: the two
existing run-path pins were flipped to assert the prose + absence of the
Debug token before implementing; suite green (1043/0).

closes #184
closes #185
This commit is contained in:
2026-07-04 15:19:58 +02:00
parent 5256b3d0ca
commit 61c8828fad
4 changed files with 61 additions and 9 deletions
+29 -5
View File
@@ -314,8 +314,11 @@ pub fn introspect_cmd(cmd: crate::GraphIntrospectCmd, env: &crate::project::Env)
/// Phrase a blueprint-document `LoadError` as prose (Debug-leak-free; the
/// engine error types are `Display`-free by convention — this is the CLI's
/// presentation layer, like `format_op_error`).
fn blueprint_load_prose(e: &LoadError) -> String {
/// presentation layer, like `format_op_error`). `pub(crate)`: shared by the
/// `graph build` path (below) and `aura run`'s loaded-blueprint branch
/// (main.rs) — env-agnostic on purpose, so both callers get identical wording
/// (#184).
pub(crate) fn blueprint_load_prose(e: &LoadError) -> String {
match e {
LoadError::Json(err) => format!("blueprint document is not valid JSON: {err}"),
LoadError::UnsupportedVersion { found, supported } => {
@@ -325,6 +328,22 @@ fn blueprint_load_prose(e: &LoadError) -> String {
}
}
/// (#185) When an unresolved type id LOOKS project-namespaced (`::`) but no
/// project was loaded for this invocation (`env.provenance()` is `None`),
/// append a hint pointing at the missing `Aura.toml` — the likeliest cause is
/// "ran outside the project directory", not "genuinely unknown std id". Bare
/// std-vocabulary ids (no `::`) and invocations that DO have a project loaded
/// get no hint.
pub(crate) fn unresolved_namespace_hint(e: &LoadError, env: &crate::project::Env) -> Option<String> {
match e {
LoadError::UnknownNodeType(t) if t.contains("::") && env.provenance().is_none() => Some(
"type id looks project-namespaced but no Aura.toml was found — run inside a project directory"
.to_string(),
),
_ => None,
}
}
/// #196: build a `Composite` from a document that is EITHER a #155 blueprint
/// envelope (a JSON object: `format_version` + `blueprint`) OR a construction
/// op-list (a JSON array) — shape-discriminated on the top-level JSON type,
@@ -334,9 +353,14 @@ fn composite_from_any(text: &str, env: &crate::project::Env) -> Result<Composite
serde_json::from_str(text).map_err(|e| format!("invalid document: {e}"))?;
match value {
serde_json::Value::Array(_) => composite_from_str(text, env),
serde_json::Value::Object(_) => {
blueprint_from_json(text, &|t| env.resolve(t)).map_err(|e| blueprint_load_prose(&e))
}
serde_json::Value::Object(_) => blueprint_from_json(text, &|t| env.resolve(t)).map_err(|e| {
let mut msg = blueprint_load_prose(&e);
if let Some(hint) = unresolved_namespace_hint(&e, env) {
msg.push_str("");
msg.push_str(&hint);
}
msg
}),
_ => Err("document is neither a blueprint envelope (object) nor an op-list (array)".into()),
}
}
+6 -1
View File
@@ -4292,7 +4292,12 @@ fn dispatch_run(a: RunCmd, env: &project::Env) {
std::process::exit(2);
});
let signal = blueprint_from_json(&doc, &|t| env.resolve(t)).unwrap_or_else(|e| {
eprintln!("aura: {path}: {e:?}");
let mut msg = graph_construct::blueprint_load_prose(&e);
if let Some(hint) = graph_construct::unresolved_namespace_hint(&e, env) {
msg.push_str("");
msg.push_str(&hint);
}
eprintln!("aura: {path}: {msg}");
std::process::exit(2);
});
// Refuse an open (free-knob) blueprint at the dispatch boundary, mirroring
+11 -2
View File
@@ -337,7 +337,9 @@ fn aura_run_loads_and_runs_a_blueprint_file() {
}
/// A blueprint referencing a node type outside the closed vocabulary fails clean
/// at load (the #160 closed-set guard at the data-plane face; invariant 9).
/// at load (the #160 closed-set guard at the data-plane face; invariant 9), and
/// (#184) the failure is phrased as house-style prose — matching the `graph
/// build` presenter's wording — never the raw `UnknownNodeType` Debug leak.
#[test]
fn aura_run_rejects_an_unknown_node_blueprint() {
let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura"))
@@ -346,7 +348,14 @@ fn aura_run_rejects_an_unknown_node_blueprint() {
.expect("spawn aura run unknown");
assert_ne!(out.status.code(), Some(0), "an unknown node type must fail the run");
let stderr = String::from_utf8(out.stderr).expect("utf-8");
assert!(stderr.contains("UnknownNodeType"), "names the cause: {stderr}");
assert!(
stderr.contains(r#"unknown node type "Nope""#),
"house-style prose names the type: {stderr}"
);
assert!(
!stderr.contains("UnknownNodeType"),
"does not leak the Debug variant name: {stderr}"
);
}
/// Property (#175, refuse-don't-guess): built-in-only flags on `aura run
+15 -1
View File
@@ -79,6 +79,12 @@ fn introspect_vocabulary_lists_project_types() {
assert!(text.contains("SMA"), "std ids still listed");
}
/// (#184/#185) Outside any project, `demo::Identity` resolves nowhere: the base
/// failure is house-style prose (never the raw `UnknownNodeType` Debug leak),
/// and — because the unresolved id LOOKS project-namespaced (`::`) while no
/// `Aura.toml` was found up from cwd — the message carries a hint pointing at
/// the missing project, distinguishing "no project" from "genuinely unknown
/// std id" (the bare-id case in `cli_run.rs` gets no such hint).
#[test]
fn outside_a_project_the_demo_blueprint_is_unknown() {
// cwd = the aura-cli crate dir: no Aura.toml anywhere up the tree.
@@ -87,7 +93,15 @@ fn outside_a_project_the_demo_blueprint_is_unknown() {
let out = aura(&["run", bp.to_str().unwrap()], cwd);
assert!(!out.status.success());
let err = String::from_utf8_lossy(&out.stderr);
assert!(err.contains("UnknownNodeType"), "stderr: {err}");
assert!(
err.contains(r#"unknown node type "demo::Identity""#),
"house-style prose names the type: {err}"
);
assert!(!err.contains("UnknownNodeType"), "does not leak the Debug variant name: {err}");
assert!(
err.contains("Aura.toml"),
"hints that no project was found for the namespaced id: {err}"
);
}
/// `Env::runs_root()` resolves `Aura.toml`'s `[paths] runs = "runs"` against