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.