//! The CLI-side serde front-end for the construction op-script (#157, §C): a //! JSON op-list document deserializes into the `OpDoc` DTO (so the engine `Op` //! stays serde-free), which maps 1:1 into `aura_engine::Op`. The `aura graph //! build` / `aura graph introspect` subcommands here drive these ops through the //! injected `aura_vocabulary::std_vocabulary`. use std::collections::BTreeMap; use std::path::Path; use aura_engine::{ blueprint_from_json, blueprint_identity_json, blueprint_to_json, name_gate, replay, ArgOpError, BindOpError, BlueprintDoc, CompileError, Composite, GraphSession, LoadError, NameGateFault, Op, OpError, Scalar, ScalarKind, }; use aura_runner::runner::render_value; 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 eleven /// op kinds with their fields and one worked element each (#331: `name` joins /// the roster, ten -> eleven). 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): {"op":"source","role":"price","kind":"F64"} declare a bound root input role of a scalar kind {"op":"input","role":"price"} declare an open input role (wired by an enclosing graph) {"op":"add","type":"SMA","name":"fast","bind":{"length":{"I64":2}}} instantiate a node ("name" optional; "bind" maps param -> typed scalar) {"op":"add","type":"Session","name":"ny","args":{"tz":"America/New_York","open":"09:30"},"bind":{"period_minutes":{"I64":15}}} an arg-bearing type applies "args" (closed, per-type-declared string pairs) BEFORE "bind" — see graph introspect --node for its args {"op":"feed","role":"price","into":["fast.series","slow.series"]} wire a role into one or more input slots {"op":"connect","from":"fast.value","to":"sub.lhs"} wire a node output into an input slot {"op":"expose","from":"sub.value","as":"bias"} name a graph output field {"op":"tap","from":"sub.value","as":"spread"} declare a recordable tap on a wire (expose's output-side twin) {"op":"gang","as":"length","into":["fast.length","slow.length"]} 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) {"op":"name","name":"ny_momentum"} set the composite's render name, at most once per script (default "graph" if omitted) Node types and their ports: aura graph introspect --vocabulary | --node "#; /// The wire DTO for one construction op — the document's by-identifier shape, /// internally tagged by `"op"`, mapped into the serde-free engine `Op`. Bind /// values are the typed `Scalar` form (`{"I64":2}`); `kind` the capitalized /// `ScalarKind` form (`"F64"`) — both the #155 representations. #[derive(Debug, Deserialize)] #[serde(tag = "op", rename_all = "lowercase", deny_unknown_fields)] enum OpDoc { Source { role: String, kind: ScalarKind }, Input { role: String }, Add { #[serde(rename = "type")] type_id: String, // `name` mirrors the builder's `.named("fast")`; `add`'s node identifier is a // naming, not an aliasing (contrast `expose`'s `as`, which is a real alias). #[serde(rename = "name", default)] as_name: Option, // Construction args (#271) — the typed, closed channel applied BEFORE // `bind`. A `BTreeMap` (deterministic iteration order into `Op::Add`'s // `Vec`, mirroring `bind`'s own convention) — absent for every // args-free type, so an old-style `add` document is unaffected. #[serde(default)] args: BTreeMap, #[serde(default)] bind: BTreeMap, }, Feed { role: String, into: Vec }, Connect { from: String, to: String }, Expose { from: String, #[serde(rename = "as")] as_name: String, }, Tap { from: String, #[serde(rename = "as")] as_name: String, }, Gang { #[serde(rename = "as")] as_name: String, into: Vec, }, /// 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, }, /// Set the composite's render name (#331) — script-level, at most once; /// the op-script twin of `replay`'s seeded default name (`"graph"`). Name { name: String }, } /// 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. 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".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(), OpDoc::Name { .. } => "name".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 }, OpDoc::Input { role } => Op::Input { role }, OpDoc::Add { type_id, as_name, args, bind } => Op::Add { type_id, as_name, args: args.into_iter().collect(), bind: bind.into_iter().collect(), }, OpDoc::Feed { role, into } => Op::Feed { role, into }, OpDoc::Connect { from, to } => Op::Connect { from, to }, OpDoc::Expose { from, as_name } => Op::Expose { from, as_name }, 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(), }, OpDoc::Name { name } => Op::Name { name }, } } } /// Phrase one `ArgOpError` (#271) as a human cause, WITHOUT the node prefix — /// shared by `format_op_error`'s `BadArg` arm (the `add`-op path) and /// `blueprint_load_prose`'s `BadArg` arm (the blueprint LOAD path), so the two /// error families cannot phrase the same fault differently. Exhaustive over /// `ArgOpError`; `BadValue` names the arg, its declared `ArgKind`, AND the /// closed per-kind `hint()` line (the same hint `introspect --node` shows). fn arg_op_error_prose(err: &ArgOpError) -> String { match err { ArgOpError::NotArgBearing => "takes no construction args".to_string(), ArgOpError::UnknownArg(a) => format!("has no construction arg {a:?}"), ArgOpError::DuplicateArg(a) => format!("was given arg {a:?} more than once"), ArgOpError::MissingArg(a) => format!("is missing required arg {a:?}"), ArgOpError::BadValue { arg, kind, got } => { format!("arg {arg:?} expects {kind:?} ({}) — got {got:?}", kind.hint()) } } } /// Phrase one `OpError` as a human cause (the engine error types are /// `Display`-free by convention; this is the CLI's presentation layer). The /// exhaustive match means a new `OpError` variant forces an update here. fn format_op_error(e: &OpError) -> String { match e { OpError::UnknownNodeType(t) => format!("unknown node type {t:?}"), OpError::DuplicateIdentifier(s) => format!("duplicate identifier {s:?}"), OpError::DuplicateOutput(s) => format!("duplicate output name {s:?}"), OpError::DuplicateTap(s) => format!("duplicate tap name {s:?}"), OpError::DuplicateRole(s) => format!("duplicate role {s:?}"), OpError::UnknownIdentifier(s) => format!("unknown node identifier {s:?}"), OpError::UnknownRole(s) => format!("unknown role {s:?}"), OpError::MalformedPort(s) => format!("malformed port reference {s:?}"), OpError::UnknownInPort { node, name } => format!("node {node:?} has no input port {name:?}"), OpError::AmbiguousInPort { node, name } => format!("node {node:?} has ambiguous input port {name:?}"), OpError::UnknownOutPort { node, name } => format!("node {node:?} has no output field {name:?}"), OpError::AmbiguousOutPort { node, name } => format!("node {node:?} has ambiguous output field {name:?}"), OpError::KindMismatch { from, to, producer, consumer } => { format!("kind mismatch {from} -> {to} (producer {producer:?}, consumer {consumer:?})") } OpError::SlotAlreadyWired { node, slot } => format!("slot {node}.{slot} already wired"), OpError::WouldCycle { from, to } => format!("connecting {from} -> {to} would close a cycle"), OpError::BadParam { node, err } => match err { BindOpError::UnknownParam(p) => format!("node {node} has no param {p:?}"), BindOpError::AmbiguousParam(p) => format!("node {node} has ambiguous param {p:?}"), 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::BadArg { node, err } => format!("node {node} {}", arg_op_error_prose(err)), OpError::UnconnectedPort { node, slot } => format!("slot {node}.{slot} is unconnected"), OpError::RoleKindMismatch { role } => format!("input role {role} fans into slots of differing kinds"), OpError::GangKindMismatch { member, expected, got } => { format!("gang: member `{member}` is {got:?}, expected {expected:?}") } OpError::AlreadyGanged { node, param } => { format!("gang: `{node}.{param}` is already ganged") } 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:?}") } OpError::DuplicateName => "a script names its blueprint at most once".to_string(), OpError::BadName { name, fault } => name_gate_fault_prose(name, fault), } } /// Phrase a `name_gate` shape violation as prose (#331): shared by this /// module's op-intake `format_op_error` `BadName` arm and the /// blueprint-envelope intake's root-name gate (`composite_from_authored_text` /// below) — the shape rule has exactly one seam-independent cause per fault, /// so both data-borne birth routes for a name read identically. fn name_gate_fault_prose(name: &str, fault: &NameGateFault) -> String { let cause = match fault { NameGateFault::Empty => "must be non-empty", NameGateFault::ContainsSeparator => "must not contain '/' or '\\'", NameGateFault::DotSegment => "must not be \".\" or \"..\"", }; format!("blueprint name {name:?} is invalid: {cause} (a single path segment)") } /// 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). 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 { // #336: deserialize element-by-element off the untyped `Value` array, not in // one `Vec` 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::from_str(doc).map_err(|e| format!("invalid op-list: {e}"))?; let mut docs: Vec = 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 = 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 // blueprint LOAD path's `LoadError::UnknownNodeType` — so without this, // `unresolved_namespace_hint` is never consulted here. Same tier hint, // appended the same way. if let OpError::UnknownNodeType(t) = &err && let Some(hint) = tier_hint_for_type_id(t, env) { cause.push_str(" — "); cause.push_str(&hint); } match labels.get(idx) { Some(kind) => format!("op {idx} ({kind}): {cause}"), None => format!("finalize: {cause}"), } }) } /// Parse a JSON op-list document, replay it through the env's vocabulary, and /// return the emitted #155 blueprint JSON — fault shapes as in `composite_from_str`. pub fn build_from_str(doc: &str, env: &aura_runner::project::Env) -> Result { let composite = composite_from_str(doc, env)?; blueprint_to_json(&composite).map_err(|e| format!("serialize error: {e:?}")) } /// `aura graph build`: read the op-list document from stdin, build, and print the /// blueprint to stdout — or the cause to stderr and exit non-zero. pub fn build_cmd(env: &aura_runner::project::Env) { use std::io::Read; let mut doc = String::new(); if let Err(e) = std::io::stdin().read_to_string(&mut doc) { eprintln!("aura: reading stdin: {e}"); std::process::exit(1); } match build_from_str(&doc, env) { // `print!`, not `println!`: the canonical artifact is exactly the library // `blueprint_to_json` bytes (the form #158 content-addresses) — a JSON value // with no trailing newline. The CLI is a transport; it must not frame the // canonical bytes (#164). Ok(json) => print!("{json}"), Err(msg) => { eprintln!("aura: {msg}"); std::process::exit(1); } } } /// `aura graph introspect --node `: a type's ports + kinds + param paths, /// read off the pre-build schema (no graph built). `Err` if `T` is not in the /// closed vocabulary. pub fn introspect_node(type_id: &str, env: &aura_runner::project::Env) -> Result { let builder = env.resolve(type_id).ok_or_else(|| format!("unknown node type {type_id:?}"))?; let schema = builder.schema(); // C29 (#315): the head line carries the node's one-line meaning. let mut out = format!("{} — {}\n", builder.label(), schema.doc); // #271: an arg-bearing (pending) type declares its ArgSpecs but no real // ports/params yet — those form only once `try_args` runs (`make`). Show // the arg rows first (they must be supplied before anything else can be // discovered) plus the one-line note the spec's worked discovery example // pins. for spec in builder.arg_specs() { out.push_str(&format!(" arg {}: {:?} ({})\n", spec.name, spec.kind, spec.kind.hint())); } if builder.is_pending() { out.push_str(" note ports and params form at construction; args are required\n"); } for port in &schema.inputs { out.push_str(&format!(" in {}:{:?}\n", port.name, port.kind)); } for field in &schema.output { out.push_str(&format!(" out {}:{:?}\n", field.name, field.kind)); } for p in builder.params() { out.push_str(&format!(" param {}:{:?} (bind {{\"{:?}\": }})\n", p.name, p.kind, p.kind)); } Ok(out) } /// `aura graph introspect --unwired`: the still-open interior slots of a partial /// op-list document, by-identifier (applies the ops, does NOT finalize). 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); // #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)))?; } let mut out = String::new(); for (slot, kind) in session.unwired() { out.push_str(&format!("{slot}:{kind:?}\n")); } Ok(out) } /// `aura graph introspect --registered` (#317): one row per registry label — /// `