fix(aura-cli): unknown-key refusal cites the op index; two diagnostics hints

Harvest sweep, batch 1 of 6.

- graph build's unknown-key refusal now parses the op-list element-by-
  element and attributes any per-element parse fault as 'op N (kind):',
  immune to serde's deny-unknown-fields token position landing on the
  following element (RED-first: the misattribution was pinned failing
  before the fix).
- the UnsupportedVersion refusal names the full accepted range
  ('versions 1..=2'), not just the ceiling.
- a bare unresolved type id inside a data-only project now carries the
  same Rust-escalation pointer as the namespaced form ('aura nodes new');
  outside any project a bare id stays hint-free (a std-name typo is the
  likelier cause there).

closes #336
refs #341
This commit is contained in:
2026-07-26 11:31:51 +02:00
parent 77ad0465cb
commit 6b6086fdef
2 changed files with 102 additions and 16 deletions
+63 -16
View File
@@ -415,7 +415,28 @@ fn resolve_use_op(
/// replay (#317): a resolution/doc-gate fault is attributed exactly like any
/// other per-op construction fault (same `op N (kind): cause` shape, exit 1).
fn composite_from_str(doc: &str, env: &aura_runner::project::Env) -> Result<Composite, String> {
let docs: Vec<OpDoc> = serde_json::from_str(doc).map_err(|e| format!("invalid op-list: {e}"))?;
// #336: deserialize element-by-element off the untyped `Value` array, not in
// one `Vec<OpDoc>` shot. For an internally-tagged enum, serde_json's
// `deny_unknown_fields` fault fires only once the parser has consumed the
// WHOLE offending object — its reported line/column lands on the start of
// the FOLLOWING element, misattributing the fault in longer op-lists. The
// op-list's own index is immune to that token-position quirk, so every
// per-element parse fault is wrapped with it directly.
let raw: Vec<serde_json::Value> =
serde_json::from_str(doc).map_err(|e| format!("invalid op-list: {e}"))?;
let mut docs: Vec<OpDoc> = Vec::with_capacity(raw.len());
for (idx, v) in raw.into_iter().enumerate() {
// Read the op-kind tag straight off the still-untyped value: a parse
// fault (e.g. an unknown key) means `OpDoc` never materializes, so
// `OpDoc::kind_label` isn't available yet, but the raw "op" string is
// the same lowercase label it would produce for every kind but `use`.
let kind = v.get("op").and_then(serde_json::Value::as_str).map(str::to_string);
let parsed: OpDoc = serde_json::from_value(v).map_err(|e| match &kind {
Some(k) => format!("op {idx} ({k}): {e}"),
None => format!("op {idx}: {e}"),
})?;
docs.push(parsed);
}
let labels: Vec<String> = docs.iter().map(OpDoc::kind_label).collect();
let mut cache: std::collections::HashMap<String, String> = std::collections::HashMap::new();
let mut ops: Vec<Op> = Vec::with_capacity(docs.len());
@@ -733,7 +754,11 @@ 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 } => {
format!("blueprint format_version {found} is unsupported (this build reads {supported})")
// #341 item 2: name the full accepted range, not just the ceiling — the
// loader's own floor is the fixed `1` of its `1..=BLUEPRINT_FORMAT_VERSION`
// check (`blueprint_serde::blueprint_from_json`), never carried in the
// error itself.
format!("blueprint format_version {found} is unsupported (this build reads versions 1..={supported})")
}
LoadError::UnknownNodeType(t) => format!("unknown node type {t:?}"),
LoadError::Gang(e) => format!("gang section invalid: {}", gang_fault_prose(e)),
@@ -774,28 +799,38 @@ fn gang_fault_prose(e: &CompileError) -> String {
}
}
/// (#185/#241/#244) When an unresolved type id LOOKS project-namespaced
/// (`::`), the likeliest causes are environmental, tiered: outside any
/// project the hint points at the missing `Aura.toml`; inside a data-only
/// project it points at the missing node crate. Bare std ids (no `::`) and
/// invocations with a loaded node crate get no hint (the vocabulary error
/// carries the message). Shared core: both the blueprint LOAD path
/// (`unresolved_namespace_hint`, over `LoadError`) and the op-script `graph
/// build` path (`composite_from_str`, over `OpError`) call this same helper,
/// so the tier texts cannot drift between the two error families.
/// (#185/#241/#244/#341 item 5) When an unresolved type id LOOKS project-
/// namespaced (`::`), the likeliest causes are environmental, tiered: outside
/// any project the hint points at the missing `Aura.toml`; inside a data-only
/// project it points at the missing node crate. Outside any project, a BARE id
/// still gets no hint — at least as likely a plain typo of a std name as an
/// escalation miss, and "no Aura.toml" would mislead a std-typo author. Inside
/// a data-only project, though, a bare id gets the SAME escalation pointer as
/// a namespaced one (#341 item 5): the consumer who wrote a bare name is the
/// one least likely to know yet that new logic needs a namespace at all, so
/// withholding the hint from exactly them was backwards. A project with a
/// loaded node crate gets no hint either way (the vocabulary error carries the
/// message; an unresolved type there is a genuine miss, not an escalation
/// gap). Shared core: both the blueprint LOAD path (`unresolved_namespace_
/// hint`, over `LoadError`) and the op-script `graph build` path
/// (`composite_from_str`, over `OpError`) call this same helper, so the tier
/// texts cannot drift between the two error families.
fn tier_hint_for_type_id(type_id: &str, env: &aura_runner::project::Env) -> Option<String> {
if !type_id.contains("::") {
return None;
}
let namespaced = type_id.contains("::");
match env.provenance() {
None => Some(
None if namespaced => Some(
"type id looks project-namespaced but no Aura.toml was found — run inside a project directory"
.to_string(),
),
Some(p) if p.namespace.is_none() => Some(
None => None,
Some(p) if p.namespace.is_none() && namespaced => Some(
"type id looks project-namespaced but this project binds no node crate — attach one with `aura nodes new <name>`"
.to_string(),
),
Some(p) if p.namespace.is_none() => Some(
"new logic is authored in Rust — this project binds no node crate yet; attach one with `aura nodes new <name>`"
.to_string(),
),
Some(_) => None,
}
}
@@ -1088,6 +1123,18 @@ mod tests {
assert_eq!(err, "op 1 (add): unknown node type \"Nope\"");
}
/// Property (#341 item 2): a v3 document's `UnsupportedVersion` refusal names
/// the FULL supported range, not just the ceiling — the pre-fix prose named
/// only `supported` ("this build reads 2"), leaving a reader to guess whether
/// 0/1 are also refused.
#[test]
fn blueprint_load_prose_unsupported_version_names_the_full_range() {
let e = LoadError::UnsupportedVersion { found: 3, supported: 2 };
let msg = super::blueprint_load_prose(&e);
assert!(msg.contains("1..=2"), "names the full supported range 1..=2: {msg}");
assert!(msg.contains('3'), "still names the found version: {msg}");
}
#[test]
fn build_from_str_reports_unconnected_slot_at_finalize() {
// sub.rhs left unwired -> the holistic 0-arm fires at the finalize step.
+39
View File
@@ -482,6 +482,26 @@ fn graph_build_rejects_an_unknown_op_field() {
assert!(stderr.contains("params"), "names the unknown key: {stderr}");
}
/// Property (#336): the unknown-key refusal cites the OFFENDING element's own op
/// index, not serde's `deny_unknown_fields` token position, which (for an
/// internally-tagged enum) surfaces only once the parser has consumed the whole
/// object and lands on the START OF THE FOLLOWING element. With the typo'd key at
/// index 1 (of 3), a line/column-based message would mislead toward index 2;
/// this pins the op-list's own coordinate instead.
#[test]
fn graph_build_unknown_op_field_names_the_offending_op_index() {
let doc = r#"[
{"op":"source","role":"price","kind":"F64"},
{"op":"add","type":"Const","params":{"value":{"F64":1.0}}},
{"op":"expose","from":"const.value","as":"bias"}
]"#;
let (stderr, code) = run_code(&["graph", "build"], doc);
assert_eq!(code, Some(1), "unknown op field is a content fault -> exit 1; stderr: {stderr}");
assert!(stderr.contains("op 1"), "names the offending element's own op index (1): {stderr}");
assert!(!stderr.contains("op 2"), "must not attribute the fault to the following element: {stderr}");
assert!(stderr.contains("params"), "still names the unknown key: {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
@@ -1898,6 +1918,25 @@ fn graph_build_hints_attach_for_namespaced_ids_in_a_data_only_project() {
);
}
/// Property (#341 item 5): the BARE counterpart of the test above — the
/// consumer least likely to know yet that new logic is Rust (and therefore
/// least likely to namespace their type) is the one who most needs the
/// escalation pointer, not the one least likely to get it. Same data-only
/// project, same unresolved-type refusal, no `::` in the type id this time.
#[test]
fn graph_build_hints_attach_for_a_bare_unresolved_type_in_a_data_only_project() {
let dir = temp_cwd("graph-build-dataonly-bare-hint");
std::fs::write(dir.join("Aura.toml"), "").expect("write bare Aura.toml");
let ops = r#"[{"op":"add","type":"ThirdCandle","name":"n"}]"#;
let (stdout, stderr, code) = run_in_stdin(&dir, &["graph", "build"], ops);
assert_ne!(code, Some(0), "an unresolvable bare type refuses: {stderr}");
assert!(stdout.is_empty(), "no blueprint emitted on a fault: {stdout}");
assert!(
stderr.contains("aura nodes new"),
"the bare-form refusal now carries the same escalation pointer as the namespaced one: {stderr}"
);
}
/// Property (#284): the `tap` op-script op, driven through the real `aura
/// graph build` process (not an in-crate call to `build_from_str`), resolves
/// a name-addressed interior wire (`"fast.value"`, session node 0 / output