diff --git a/crates/aura-cli/src/graph_construct.rs b/crates/aura-cli/src/graph_construct.rs index 2b3907c..64cbd68 100644 --- a/crates/aura-cli/src/graph_construct.rs +++ b/crates/aura-cli/src/graph_construct.rs @@ -9,16 +9,18 @@ use std::path::Path; use aura_engine::{ blueprint_from_json, blueprint_identity_json, blueprint_to_json, replay, BindOpError, - CompileError, Composite, GraphSession, LoadError, Op, OpError, Scalar, ScalarKind, + BlueprintDoc, CompileError, Composite, GraphSession, LoadError, Op, OpError, Scalar, ScalarKind, }; use serde::Deserialize; +use crate::research_docs::resolve_id_prefix; + // `std_vocabulary` is now only reached through `aura_runner::project::Env::resolve` in // production code; tests still exercise it directly. #[cfg(test)] use aura_vocabulary::std_vocabulary; -/// The op-list reference `aura graph build --help` appends (#323): the nine +/// The op-list reference `aura graph build --help` appends (#323): the ten /// op kinds with their fields and one worked element each. Lives beside /// [`OpDoc`] so a new op variant is one screen away from its help line. pub const OP_REFERENCE: &str = r#"Op-list reference (stdin: a JSON array of op objects, applied in order): @@ -40,6 +42,9 @@ pub const OP_REFERENCE: &str = r#"Op-list reference (stdin: a JSON array of op o fuse two or more sibling params into one public knob {"op":"doc","text":"..."} declare the composite's one-line meaning (C29) + {"op":"use","ref":{"name":"agree"},"name":"gate","bind":{"sma.length":{"I64":9}}} + splice a registered blueprint (by "content_id" or "name") under an + instance name ("bind" path-qualifies the spliced instance's params) Node types and their ports: aura graph introspect --vocabulary | --node "#; @@ -82,26 +87,66 @@ enum OpDoc { /// Declare the composite's one-line meaning (C29, #316) — the op-script /// twin of the builder's `.doc(...)`. Doc { text: String }, + /// Splice a registered blueprint into the building graph as a nested + /// composite (#317) — the DTO the engine's `Op::Use` never sees + /// directly: the CLI resolves `ref` (a store content id, a unique + /// content-id prefix, or a registry name label) to the full content id + /// at conversion time, before the engine ever runs. + Use { + #[serde(rename = "ref")] + r#ref: UseRef, + #[serde(default)] + name: Option, + #[serde(default)] + bind: BTreeMap, + }, +} + +/// A `use` op's reference (#317) — exactly one of a store content id (full +/// or a unique prefix, #302 semantics) or a registry name label +/// (`graph register --name`, latest-wins). +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +enum UseRef { + #[serde(rename = "content_id")] + ContentId(String), + #[serde(rename = "name")] + Name(String), } impl OpDoc { - /// The op-kind label for the `op N (kind): cause` message. - fn kind_label(&self) -> &'static str { + /// The op-kind label for the `op N (kind): cause` message. A `use` op + /// carries its instance name (when the author gave one) so a `use` + /// fault reads `use "gate"`, not a bare, undifferentiated `use` — the + /// only kind whose label is not a fixed string (#317). + fn kind_label(&self) -> String { match self { - OpDoc::Source { .. } => "source", - OpDoc::Input { .. } => "input", - OpDoc::Add { .. } => "add", - OpDoc::Feed { .. } => "feed", - OpDoc::Connect { .. } => "connect", - OpDoc::Expose { .. } => "expose", - OpDoc::Tap { .. } => "tap", - OpDoc::Gang { .. } => "gang", - OpDoc::Doc { .. } => "doc", + OpDoc::Source { .. } => "source".to_string(), + OpDoc::Input { .. } => "input".to_string(), + OpDoc::Add { .. } => "add".to_string(), + OpDoc::Feed { .. } => "feed".to_string(), + OpDoc::Connect { .. } => "connect".to_string(), + OpDoc::Expose { .. } => "expose".to_string(), + OpDoc::Tap { .. } => "tap".to_string(), + OpDoc::Gang { .. } => "gang".to_string(), + OpDoc::Doc { .. } => "doc".to_string(), + OpDoc::Use { name: Some(n), .. } => format!("use {n:?}"), + OpDoc::Use { name: None, .. } => "use".to_string(), } } } impl From for Op { + /// Infallible, context-free conversion — used directly only by + /// build-free introspection paths (`introspect --unwired`, #317's + /// spec: "Build-free introspection paths pass a `|_| None` closure"), + /// which never resolve a `use` ref through the registry. A bare + /// `OpDoc::Use` therefore maps its `UseRef` payload verbatim into + /// `ref_id` (unresolved) — that session's `subgraph` closure is always + /// `&|_| None`, so any `use` op there faults `UnknownSubgraph` + /// regardless of the exact `ref_id` text; `graph build`'s real path + /// (`composite_from_str`) never reaches this arm — it resolves and + /// replaces each `Op::Use` before conversion (see `resolve_use_op`). fn from(d: OpDoc) -> Op { match d { OpDoc::Source { role, kind } => Op::Source { role, kind }, @@ -115,6 +160,14 @@ impl From for Op { OpDoc::Tap { from, as_name } => Op::Tap { from, as_name }, OpDoc::Gang { as_name, into } => Op::Gang { as_name, into }, OpDoc::Doc { text } => Op::Doc { text }, + OpDoc::Use { r#ref, name, bind } => Op::Use { + ref_id: match r#ref { + UseRef::ContentId(id) => id, + UseRef::Name(name) => name, + }, + name, + bind: bind.into_iter().collect(), + }, } } } @@ -147,10 +200,12 @@ fn format_op_error(e: &OpError) -> String { BindOpError::KindMismatch { param, expected, got } => { format!("param {node}.{param} expects {expected:?} but got {got:?}") } + BindOpError::AlreadyGanged { param, gang } => { + format!("cannot bind {node}.{param} — member of gang {gang:?}") + } }, OpError::UnconnectedPort { node, slot } => format!("slot {node}.{slot} is unconnected"), OpError::RoleKindMismatch { role } => format!("input role {role} fans into slots of differing kinds"), - OpError::UnboundRootRole { role } => format!("root input role {role} is unbound"), OpError::GangKindMismatch { member, expected, got } => { format!("gang: member `{member}` is {got:?}, expected {expected:?}") } @@ -160,18 +215,174 @@ fn format_op_error(e: &OpError) -> String { OpError::GangArity { gang } => format!("gang `{gang}`: needs at least two members"), OpError::Incomplete(ce) => format!("{ce:?}"), OpError::DuplicateDoc => "a doc op may appear at most once".to_string(), + // #317: `graph build`'s real path (`composite_from_str`) resolves and + // fetches every `use` op before replay, so this never fires there — + // but `introspect --unwired` stays build-free/subgraph-free by spec + // (`&|_| None`, see `From for Op`), so a `use` op in a partial + // document reaches this arm through THAT path, by-identifier on the + // (unresolved) `ref_id` text. + OpError::UnknownSubgraph { ref_id } => { + format!("use: no subgraph for content id {ref_id:?}") + } } } +/// Resolve one `use` op's [`UseRef`] to the full store content id (#317): +/// verbatim if it already IS a 64-hex content id, else a unique content-id +/// prefix (#302 semantics, reusing [`resolve_id_prefix`]) or a registry name +/// label (latest-wins, [`aura_registry::Registry::resolve_blueprint_label`]). +/// Returns the full id plus the ` -> ` text the +/// resolution echo shows — the bare cause on a miss (unknown label / unknown +/// or ambiguous prefix), for the caller to attribute by op index. +fn resolve_use_ref_id(r: &UseRef, registry: &aura_registry::Registry) -> Result<(String, String), String> { + match r { + UseRef::Name(label) => { + let id = registry.resolve_blueprint_label(label).ok_or_else(|| { + let labels = registry.blueprint_labels(); + if labels.is_empty() { + format!("no registered blueprint labeled {label:?} — no labels registered") + } else { + let names = labels.iter().map(|(n, _)| n.as_str()).collect::>().join(", "); + format!("no registered blueprint labeled {label:?} — registered labels: {names}") + } + })?; + Ok((id.clone(), format!("{label} -> {id}"))) + } + UseRef::ContentId(raw) => { + if aura_runner::axes::is_content_id(raw) { + return Ok((raw.clone(), format!("{raw} -> {raw}"))); + } + let candidates = registry.list_blueprint_ids().map_err(|e| e.to_string())?; + match resolve_id_prefix(raw, &candidates)? { + Some(full) => { + let shown = format!("{raw} -> {full}"); + Ok((full, shown)) + } + None => Err(format!("no registered blueprint matches content id {raw:?}")), + } + } + } +} + +/// The 12-char content-id prefix convention `graph introspect --registered` +/// shows (#317), reused in the use-seam C29 refusal so both surfaces name a +/// blueprint the same shortened way. +fn id_prefix12(id: &str) -> &str { + &id[..id.len().min(12)] +} + +/// The use-seam C29 doc-gate refusal cause (#317): re-run +/// [`aura_registry::gate_composite_docs`] — the SAME walk `graph register` +/// gates the write path with — over a FETCHED composite, before it ever +/// reaches the session; names the fetched blueprint's id-prefix and the +/// failing (root or nested — from the consumer's view, everything fetched is +/// "nested") composite by identifier, mirroring `research_docs.rs`'s +/// `BadDescription` phrasing for the same two `DocGateFault` kinds. +fn use_doc_gate_cause(full_id: &str, e: &aura_registry::RegistryError) -> String { + let aura_registry::RegistryError::UndescribedComposite { name, fault } = e else { + // Defensive: `gate_composite_docs` only ever constructs + // `UndescribedComposite`; every other `RegistryError` variant is + // unreachable from this call, but the match stays total rather than + // panicking on a surprise variant. + return format!("registered blueprint {} fails the description gate", id_prefix12(full_id)); + }; + let rule = match fault { + aura_core::DocGateFault::Empty => format!( + "nested composite {name:?} has no description — re-register it with a \"doc\" op (C29)" + ), + aura_core::DocGateFault::RestatesName => { + format!("nested composite {name:?} has a doc that merely restates its name (C29)") + } + }; + format!("registered blueprint {} fails the description gate: {rule}", id_prefix12(full_id)) +} + +/// Resolve, fetch, C29-gate, and echo one `use` op (#317) — the CLI-side +/// half of the store/engine split (the engine never resolves a label, a +/// prefix, or the registry itself). On success, caches the fetched +/// CANONICAL JSON (not the parsed `Composite` — `Composite` is not `Clone`, +/// mirroring the engine's own `use_fixture`-reload test pattern) under the +/// full content id, so the `subgraph` closure handed to `replay` is a pure, +/// registry-free lookup that re-parses on every call. Returns the bare +/// cause on any refusal; the caller attributes it by op index. +fn resolve_use_op( + r#ref: UseRef, + name: Option, + bind: BTreeMap, + env: &aura_runner::project::Env, + cache: &mut std::collections::HashMap, +) -> Result { + let registry = env.registry(); + let (full_id, shown) = resolve_use_ref_id(&r#ref, ®istry)?; + let json = registry + .get_blueprint(&full_id) + .map_err(|e| e.to_string())? + .ok_or_else(|| format!("no registered blueprint {full_id}"))?; + let doc: BlueprintDoc = serde_json::from_str(&json) + .map_err(|e| format!("registered blueprint {full_id} is not a valid blueprint: {e}"))?; + // C29 at the use seam: gate the DATA form (no vocabulary needed) before + // the composite ever reaches the session. + if let Err(e) = aura_registry::gate_composite_docs(&doc.blueprint) { + return Err(use_doc_gate_cause(&full_id, &e)); + } + // Resolve the fetched envelope through the FULL vocabulary here, at DTO + // conversion (review finding, #317 follow-up): deferring this to the + // `subgraph` closure's lazy re-parse let a deserialize/vocab-resolution + // failure fold into `.ok() -> None`, which `replay` then misreports as + // `UnknownSubgraph` — a fetched-but-unloadable blueprint is a runtime + // content fault (exit 1), not a missing reference. `blueprint_load_prose` + // + `unresolved_namespace_hint` are the same house-style pair + // `blueprint_slot_prose`/`composite_from_any` already use over a + // `LoadError`. + if let Err(e) = blueprint_from_json(&json, &|t| env.resolve(t)) { + let mut msg = blueprint_load_prose(&e); + if let Some(hint) = unresolved_namespace_hint(&e, env) { + msg.push_str(" — "); + msg.push_str(&hint); + } + return Err(format!("registered blueprint {} is invalid: {msg}", id_prefix12(&full_id))); + } + let instance = name.clone().unwrap_or_else(|| doc.blueprint.name.clone()); + // The resolution echo (C14 benign class): stderr only, so stdout stays + // clean payload (#164's convention, extended to this new note). + crate::diag::note!("use {instance:?}: {shown}"); + cache.insert(full_id.clone(), json); + Ok(Op::Use { ref_id: full_id, name, bind: bind.into_iter().collect() }) +} + /// Parse a JSON op-list document and replay it through the env's vocabulary into /// a built `Composite` — or a `op N (kind): cause` message (a per-op fault, /// attributed by the retained op-kind list) / `finalize: cause` (a holistic fault -/// past the last op). +/// past the last op). Every `use` op resolves through the registry HERE, before +/// 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 { let docs: Vec = serde_json::from_str(doc).map_err(|e| format!("invalid op-list: {e}"))?; - let labels: Vec<&'static str> = docs.iter().map(OpDoc::kind_label).collect(); - let ops: Vec = docs.into_iter().map(Op::from).collect(); - replay("graph", ops, &|t| env.resolve(t)).map_err(|(idx, err)| { + let labels: Vec = docs.iter().map(OpDoc::kind_label).collect(); + let mut cache: std::collections::HashMap = std::collections::HashMap::new(); + let mut ops: Vec = Vec::with_capacity(docs.len()); + for (idx, d) in docs.into_iter().enumerate() { + let op = match d { + OpDoc::Use { r#ref, name, bind } => match resolve_use_op(r#ref, name, bind, env, &mut cache) { + Ok(op) => op, + Err(cause) => return Err(format!("op {idx} ({}): {cause}", labels[idx])), + }, + other => Op::from(other), + }; + ops.push(op); + } + // The injected `subgraph` lookup (#317): a pure, registry-free map read — + // every `use` op's blueprint was already fetched, gated, AND resolved + // (`resolve_use_op`'s eager `blueprint_from_json` check) above; this + // closure only re-parses the cached bytes (`Composite` is not `Clone`). + // The `.ok()` is defensive, not a fallible path in practice: re-parsing + // the SAME cached JSON through the SAME pure resolver cannot fail here + // once it has already succeeded once above (review finding, #317 + // follow-up — the failure case now surfaces eagerly, at DTO conversion). + let subgraph = |ref_id: &str| { + cache.get(ref_id).and_then(|json| blueprint_from_json(json, &|t| env.resolve(t)).ok()) + }; + replay("graph", ops, &|t| env.resolve(t), &subgraph).map_err(|(idx, err)| { let mut cause = format_op_error(&err); // #244: the op-script path reports an unresolvable namespaced type as // `OpError::UnknownNodeType`, a different error family than the @@ -245,7 +456,11 @@ pub fn introspect_node(type_id: &str, env: &aura_runner::project::Env) -> Result pub fn introspect_unwired(doc: &str, env: &aura_runner::project::Env) -> Result { let docs: Vec = serde_json::from_str(doc).map_err(|e| format!("invalid op-list: {e}"))?; let resolver = |t: &str| env.resolve(t); - let mut session = GraphSession::new("introspect", &resolver); + // #317: build-free introspection stays subgraph-free by design (spec: + // "Build-free introspection paths pass a `|_| None` closure") — a `use` + // op here always misses (`OpError::UnknownSubgraph`, `From`'s own + // doc comment), never a registry read. + let mut session = GraphSession::new("introspect", &resolver, &|_: &str| None); for (i, d) in docs.into_iter().enumerate() { session.apply(Op::from(d)).map_err(|e| format!("op {i}: {}", format_op_error(&e)))?; } @@ -256,6 +471,33 @@ pub fn introspect_unwired(doc: &str, env: &aura_runner::project::Env) -> Result< Ok(out) } +/// `aura graph introspect --registered` (#317): one row per registry label — +/// `