6b6086fdef
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
1243 lines
62 KiB
Rust
1243 lines
62 KiB
Rust
//! 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 <T> 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 <T>"#;
|
|
|
|
/// 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<String>,
|
|
// 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<String, String>,
|
|
#[serde(default)]
|
|
bind: BTreeMap<String, Scalar>,
|
|
},
|
|
Feed { role: String, into: Vec<String> },
|
|
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<String>,
|
|
},
|
|
/// 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<String>,
|
|
#[serde(default)]
|
|
bind: BTreeMap<String, Scalar>,
|
|
},
|
|
/// 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<OpDoc> 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<OpDoc> 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 `<label-or-prefix> -> <full id>` 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::<Vec<_>>().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<String>,
|
|
bind: BTreeMap<String, Scalar>,
|
|
env: &aura_runner::project::Env,
|
|
cache: &mut std::collections::HashMap<String, String>,
|
|
) -> Result<Op, String> {
|
|
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<Composite, String> {
|
|
// #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());
|
|
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<String, String> {
|
|
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 <T>`: 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<String, String> {
|
|
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 {{\"{:?}\": <v>}})\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<String, String> {
|
|
let docs: Vec<OpDoc> = 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<OpDoc>`'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 —
|
|
/// `<label> <12-char id prefix> <root doc line>`, latest-wins (the label
|
|
/// sidecar's own resolution rule) — the discovery surface the worked
|
|
/// acceptance program's last step reads. An empty store is `no labels
|
|
/// registered` on stdout, exit 0 (a listing, not a fault); dangling label
|
|
/// rows (content id no longer resolves) are already skipped by
|
|
/// `blueprint_labels()`.
|
|
fn introspect_registered(env: &aura_runner::project::Env) -> Result<String, String> {
|
|
let registry = env.registry();
|
|
let labels = registry.blueprint_labels();
|
|
if labels.is_empty() {
|
|
return Ok("no labels registered\n".to_string());
|
|
}
|
|
let mut out = String::new();
|
|
for (name, id) in labels {
|
|
let json = registry
|
|
.get_blueprint(&id)
|
|
.map_err(|e| e.to_string())?
|
|
.ok_or_else(|| format!("label {name:?} names {id}, which is not in the store"))?;
|
|
let doc: BlueprintDoc = serde_json::from_str(&json)
|
|
.map_err(|e| format!("registered blueprint {id} is not a valid blueprint: {e}"))?;
|
|
let root_doc = doc.blueprint.doc.as_deref().unwrap_or("");
|
|
out.push_str(&format!("{name} {} {root_doc}\n", id_prefix12(&id)));
|
|
}
|
|
Ok(out)
|
|
}
|
|
|
|
/// `aura graph introspect`: dispatch the read-only queries. Exactly one of
|
|
/// `--vocabulary` / `--node <T>` / `--unwired` / `--params <FILE|ID>` / the id
|
|
/// group must be set; zero or more than one is the usage error (exit 2). The id
|
|
/// group is `--content-id [FILE]` and/or `--identity-id` — the two id flags may
|
|
/// combine (one build, both ids, content id first).
|
|
pub fn introspect_cmd(cmd: crate::GraphIntrospectCmd, env: &aura_runner::project::Env) {
|
|
let count = cmd.vocabulary as usize
|
|
+ cmd.node.is_some() as usize
|
|
+ cmd.unwired as usize
|
|
+ cmd.folds as usize
|
|
+ cmd.registered as usize
|
|
+ cmd.params.is_some() as usize
|
|
+ (cmd.content_id.is_some() || cmd.identity_id) as usize;
|
|
if count != 1 {
|
|
eprintln!(
|
|
"aura: Usage: aura graph introspect --vocabulary | --node <T> | --unwired | --folds | --registered | --params <FILE|ID> | --content-id [FILE] | --identity-id (the two id flags may be combined)"
|
|
);
|
|
std::process::exit(2);
|
|
}
|
|
if cmd.vocabulary {
|
|
// C29 (#315): each type id carries its schema's one-line meaning —
|
|
// bare names teach nothing (What do Latch, When, Select, Bias do?).
|
|
// A rostered id that fails to resolve is an invariant breach; better
|
|
// loud than a silent C29-incomplete bare row.
|
|
for t in env.type_ids() {
|
|
let b = env.resolve(t).expect("every rostered type id resolves to a builder");
|
|
println!("{t:<18} {}", b.schema().doc);
|
|
}
|
|
} else if cmd.folds {
|
|
// The fold vocabulary binds at graph-declared taps (C27), so the
|
|
// graph introspect namespace is its discovery surface (#315). #332:
|
|
// this renders the fold-REGISTRY roster — the same roster `aura run
|
|
// --tap TAP=FOLD` resolves labels against (aura-runner's layered
|
|
// `FoldRegistry`) — not the aura-std `FoldKind` table: labels here
|
|
// must be exactly what `--tap` accepts (lowercase), including the
|
|
// `record` entry that has no `FoldKind` counterpart.
|
|
for (label, doc) in aura_runner::FoldRegistry::core().roster() {
|
|
println!("{label:<7} — {doc}");
|
|
}
|
|
} else if cmd.registered {
|
|
// The label sidecar's discovery surface (#317): one row per
|
|
// registered label — nothing to build, so a store-only read, exit 0
|
|
// even on an empty store (a listing, not a fault).
|
|
match introspect_registered(env) {
|
|
Ok(s) => print!("{s}"),
|
|
Err(m) => {
|
|
eprintln!("aura: {m}");
|
|
std::process::exit(1);
|
|
}
|
|
}
|
|
} else if let Some(type_id) = cmd.node.as_deref() {
|
|
match introspect_node(type_id, env) {
|
|
Ok(s) => print!("{s}"),
|
|
Err(m) => {
|
|
eprintln!("aura: {m}");
|
|
std::process::exit(2);
|
|
}
|
|
}
|
|
} else if cmd.unwired {
|
|
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 introspect_unwired(&doc, env) {
|
|
Ok(s) => print!("{s}"),
|
|
Err(m) => {
|
|
eprintln!("aura: {m}");
|
|
std::process::exit(1);
|
|
}
|
|
}
|
|
} else if let Some(target) = cmd.params.as_deref() {
|
|
// --params <FILE|ID> (#196): the RAW composite param space — the one
|
|
// namespace campaign axes are validated against
|
|
// (`validate_campaign_refs`) and `exec --override`'s own resolution
|
|
// (`override_paths`, member.rs) resolves against (#328: the wrapped
|
|
// `<blueprint>.<node>.<param>` form was retired — there is exactly
|
|
// one axis namespace now, so the two surfaces agree line-for-line,
|
|
// see `params_lines`'s own doc comment).
|
|
match params_lines(target, env) {
|
|
Ok(s) => print!("{s}"),
|
|
Err(m) => {
|
|
eprintln!("aura: {m}");
|
|
std::process::exit(1);
|
|
}
|
|
}
|
|
} else {
|
|
// --content-id [FILE] / --identity-id (combinable): one build, then each
|
|
// requested id on its own line, content id first. With a FILE value the
|
|
// document is read from it, shape-discriminated (#196): a JSON array is a
|
|
// construction op-list, a JSON object the #155 blueprint envelope — so a
|
|
// registered blueprint's printed id is exactly its store key. Without a
|
|
// FILE the op-list is read from stdin (the pre-#196 behaviour, unchanged).
|
|
// The content id (#158) is the SHA256 of the same `blueprint_to_json`
|
|
// bytes `graph build` emits; the identity id (#171) the SHA256 of the
|
|
// debug-name-blind `blueprint_identity_json` form — both via the one
|
|
// shared `crate::content_id` primitive, the same hash the run
|
|
// manifest's `topology_hash` uses (computed inline in
|
|
// `aura_runner::member`'s `run_signal_r`/`run_blueprint_member` — #319
|
|
// Task 9 retired the CLI-side `topology_hash` wrapper this comment
|
|
// used to name), so all surfaces agree by construction.
|
|
let composite = match cmd.content_id.as_ref() {
|
|
Some(Some(file)) => {
|
|
let text = match std::fs::read_to_string(file) {
|
|
Ok(t) => t,
|
|
Err(e) => {
|
|
eprintln!("aura: cannot read {}: {e}", file.display());
|
|
std::process::exit(1);
|
|
}
|
|
};
|
|
match composite_from_authored_text(&text, env) {
|
|
Ok(c) => c,
|
|
Err(m) => {
|
|
eprintln!("aura: {m}");
|
|
std::process::exit(1);
|
|
}
|
|
}
|
|
}
|
|
_ => {
|
|
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 composite_from_str(&doc, env) {
|
|
Ok(c) => c,
|
|
Err(m) => {
|
|
eprintln!("aura: {m}");
|
|
std::process::exit(1);
|
|
}
|
|
}
|
|
}
|
|
};
|
|
if cmd.content_id.is_some() {
|
|
match blueprint_to_json(&composite) {
|
|
Ok(json) => println!("{}", crate::content_id(&json)),
|
|
Err(e) => {
|
|
eprintln!("aura: serialize error: {e:?}");
|
|
std::process::exit(1);
|
|
}
|
|
}
|
|
}
|
|
if cmd.identity_id {
|
|
match blueprint_identity_json(&composite) {
|
|
Ok(json) => println!("{}", crate::content_id(&json)),
|
|
Err(e) => {
|
|
eprintln!("aura: serialize error: {e:?}");
|
|
std::process::exit(1);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// 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`). `pub(crate)`: shared by the
|
|
/// `graph build` path (below) and `aura exec`'s blueprint leg
|
|
/// (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 } => {
|
|
// #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)),
|
|
LoadError::BadArg(e) => format!("construction args invalid: {}", arg_op_error_prose(e)),
|
|
}
|
|
}
|
|
|
|
/// Phrase a gang-table `CompileError` as prose (Debug-leak-free, mirroring
|
|
/// `blueprint_load_prose`'s convention). `with_gangs` — the only producer of
|
|
/// `LoadError::Gang` — always yields `CompileError::BadGang`; a bare fallback
|
|
/// covers the type's other variants without ever printing a raw `{e:?}`.
|
|
fn gang_fault_prose(e: &CompileError) -> String {
|
|
use aura_engine::GangFault as F;
|
|
let CompileError::BadGang(fault) = e else {
|
|
return "malformed gang table".to_string();
|
|
};
|
|
match fault {
|
|
F::BadName { gang } => format!("gang {gang:?} has an empty or dotted name"),
|
|
F::TooFewMembers { gang } => format!("gang {gang:?} has fewer than two members"),
|
|
F::NodeOutOfRange { gang, node } => {
|
|
format!("gang {gang:?} references out-of-range node {node}")
|
|
}
|
|
F::NotAPrimitive { gang, node } => {
|
|
format!("gang {gang:?} member node {node} is not a primitive")
|
|
}
|
|
F::NoOpenParam { gang, node, name } => {
|
|
format!("gang {gang:?} member node {node} has no open param {name:?}")
|
|
}
|
|
F::PosMismatch { gang, node, expected, got } => format!(
|
|
"gang {gang:?} member node {node} declares position {got} but the param sits at {expected}"
|
|
),
|
|
F::KindMismatch { gang, node, name, expected, got } => format!(
|
|
"gang {gang:?} member node {node} param {name:?} is {got:?} but the gang expects {expected:?}"
|
|
),
|
|
F::MemberInTwoGangs { gang, node, pos } => format!(
|
|
"gang {gang:?} member (node {node}, pos {pos}) already belongs to another gang"
|
|
),
|
|
}
|
|
}
|
|
|
|
/// (#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> {
|
|
let namespaced = type_id.contains("::");
|
|
match env.provenance() {
|
|
None if namespaced => Some(
|
|
"type id looks project-namespaced but no Aura.toml was found — run inside a project directory"
|
|
.to_string(),
|
|
),
|
|
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,
|
|
}
|
|
}
|
|
|
|
/// (#185/#241) See [`tier_hint_for_type_id`] for the tier rules; this is the
|
|
/// blueprint LOAD path's entry point over `LoadError`.
|
|
pub(crate) fn unresolved_namespace_hint(e: &LoadError, env: &aura_runner::project::Env) -> Option<String> {
|
|
match e {
|
|
LoadError::UnknownNodeType(t) => tier_hint_for_type_id(t, env),
|
|
_ => 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,
|
|
/// each canonicalized by its own rules.
|
|
///
|
|
/// **Ungated by design (#331 review finding).** This is the STORE READ-BACK
|
|
/// shape: `params_lines`'s content-id fetch (`resolve_blueprint_text`'s
|
|
/// non-file branch) — `introspect --params <ID>` and, by the same
|
|
/// convention, `validate_campaign_refs`'s already-ungated `blueprint_from_json`
|
|
/// call — reads whatever is already sitting in the store, and C29 says a
|
|
/// registered artifact is never retroactively invalidated. Freshly authored
|
|
/// FILE text (a hand-edited document that has not yet passed through a gated
|
|
/// intake) goes through [`composite_from_authored_text`] instead, never here
|
|
/// — `params_lines`'s OWN file branch is such a case (#331 delta re-review:
|
|
/// it used to call straight through to this fn, missing the gate; fixed by
|
|
/// branching on `resolve_blueprint_text`'s file-vs-store flag).
|
|
pub(crate) fn composite_from_any(text: &str, env: &aura_runner::project::Env) -> Result<Composite, String> {
|
|
let value: serde_json::Value =
|
|
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| {
|
|
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()),
|
|
}
|
|
}
|
|
|
|
/// The FILE-intake counterpart of [`composite_from_any`] (#331 review
|
|
/// finding): identical parse, plus the blueprint-envelope root-name shape
|
|
/// gate. Every call site that builds a `Composite` from text freshly read
|
|
/// off disk (`graph register`, `graph introspect --content-id <FILE>`, the
|
|
/// bare `aura graph <FILE>` viewer, and `graph introspect --params <FILE>`'s
|
|
/// file branch) goes through this wrapper — never the ungated
|
|
/// `composite_from_any` — because a hand-edited envelope with
|
|
/// `"name":"../x"` would otherwise register/build cleanly and write
|
|
/// `traces/../x/` at run time (`trace_store.rs` joins the name unsanitized).
|
|
/// Gating the composite's name unconditionally (not just on the Object
|
|
/// branch) is harmless: an op-script-built composite's name already passed
|
|
/// the op-intake gate (`GraphSession::set_name`), so re-checking it here is
|
|
/// redundant, not restrictive — it keeps this wrapper a single shared choke
|
|
/// point rather than one that has to re-discriminate the JSON shape. Only
|
|
/// the ROOT name is checked: the filesystem seam consumes exclusively the
|
|
/// root name. Store read-back (reproduce, `use`-splice resolution from the
|
|
/// registry, and `params_lines`'s content-id branch) never reaches this
|
|
/// function — C29's "registered artifacts are never retroactively
|
|
/// invalidated" stays intact.
|
|
///
|
|
/// **Deliberate exception — the one direct `gate_authored_root_name` caller
|
|
/// (main.rs).** One fresh-FILE intake reaches the same unsanitized
|
|
/// `traces/<name>/` seam without going through this wrapper, because it
|
|
/// already parses the document its own way and only needs the shared root-
|
|
/// name gate bolted on, not the shape-discrimination `composite_from_any`
|
|
/// does:
|
|
/// - `exec <blueprint.json>` (`exec_blueprint_leg`, #319): envelope-only
|
|
/// grammar (no op-script fallback), feeds `signal.name()` into
|
|
/// `run_signal_r`/`run_measurement` -> `bind_tap_plan` ->
|
|
/// `TraceStore::begin_run`.
|
|
///
|
|
/// (The quintet's other direct callers — `dispatch_run`,
|
|
/// `validate_and_register_axes`, `run_blueprint_sweep`/`_walkforward`/`_mc`,
|
|
/// `list_blueprint_axes` — retired with the quintet, #319 Task 8.)
|
|
///
|
|
/// This shares the identical `name_gate` + `name_gate_fault_prose`
|
|
/// primitives this wrapper uses, so the refusal wording is byte-identical
|
|
/// across every authored-file-intake route even though the shape-
|
|
/// discrimination step is not shared by them.
|
|
pub(crate) fn composite_from_authored_text(
|
|
text: &str,
|
|
env: &aura_runner::project::Env,
|
|
) -> Result<Composite, String> {
|
|
let composite = composite_from_any(text, env)?;
|
|
gate_authored_root_name(composite.name())?;
|
|
Ok(composite)
|
|
}
|
|
|
|
/// The root-name shape gate itself (#331 delta re-review), factored out of
|
|
/// [`composite_from_authored_text`] so `exec_blueprint_leg`'s narrower-grammar
|
|
/// file intake (see that fn's doc comment) can share the exact `name_gate`
|
|
/// call and `name_gate_fault_prose` wording without going through the
|
|
/// shape-discriminating wrapper.
|
|
pub(crate) fn gate_authored_root_name(name: &str) -> Result<(), String> {
|
|
name_gate(name).map_err(|fault| name_gate_fault_prose(name, &fault))
|
|
}
|
|
|
|
/// Resolve a blueprint document's bytes from a file path or a 64-hex content
|
|
/// id in the project store (the campaign-run target-addressing convention).
|
|
/// The id shape is `aura_runner::axes::is_content_id` — the same predicate
|
|
/// `exec`'s campaign-target resolution uses, so the two FILE-or-id surfaces
|
|
/// cannot drift apart on what counts as a store address. The `bool` names
|
|
/// which branch fired: `true` for a FRESH FILE read, `false` for a STORE
|
|
/// (content-id) fetch — `params_lines` (#331 delta re-review) uses it to
|
|
/// decide whether the root-name gate applies (a fresh file must gate; a
|
|
/// store read-back must not, C29) without re-deriving the FILE-vs-id
|
|
/// distinction a second time.
|
|
fn resolve_blueprint_text(target: &str, env: &aura_runner::project::Env) -> Result<(String, bool), String> {
|
|
// A CLI arg tolerates the `content:` display prefix (#194); doc ref
|
|
// fields stay bare-only.
|
|
let target = target
|
|
.strip_prefix("content:")
|
|
.filter(|t| aura_runner::axes::is_content_id(t))
|
|
.unwrap_or(target);
|
|
let path = Path::new(target);
|
|
if path.is_file() {
|
|
return std::fs::read_to_string(path)
|
|
.map(|text| (text, true))
|
|
.map_err(|e| format!("cannot read {}: {e}", path.display()));
|
|
}
|
|
if aura_runner::axes::is_content_id(target) {
|
|
return match env.registry().get_blueprint(target) {
|
|
Ok(Some(json)) => Ok((json, false)),
|
|
Ok(None) => Err(format!("no blueprint {target} in the project store")),
|
|
Err(e) => Err(e.to_string()),
|
|
};
|
|
}
|
|
Err(format!("'{target}' is neither a readable .json file nor a 64-hex content id"))
|
|
}
|
|
|
|
/// `aura graph introspect --params <FILE|ID>` (#196): one `{name}:{kind:?}`
|
|
/// line per open param of the RAW composite (no harness wrap) — the
|
|
/// campaign-axis namespace `validate_campaign_refs` checks axes against. The
|
|
/// kind renders via `ScalarKind`'s `Debug` (`I64`/`F64`/`Bool`/`Timestamp`),
|
|
/// the same form `introspect --node` already uses for param kinds. Followed
|
|
/// by one `{name}:{kind:?} default={value}` line per BOUND param (#328):
|
|
/// `bound_param_space()`'s names are already RAW (#203), so no blueprint-name
|
|
/// concatenation is needed — line-identical to the retired `aura sweep
|
|
/// --list-axes`'s own bound pass (`list_blueprint_axes`, main.rs, gone with
|
|
/// the #319 sugar retirement), same `render_value` lexicon.
|
|
/// #331 delta re-review: the FILE target is a FOURTH freshly-authored-file
|
|
/// intake this fn's own `composite_from_any` call had missed gating — a FILE
|
|
/// routes through [`composite_from_authored_text`] instead; a content id
|
|
/// (STORE read-back) keeps the ungated `composite_from_any` (C29).
|
|
fn params_lines(target: &str, env: &aura_runner::project::Env) -> Result<String, String> {
|
|
use std::fmt::Write as _;
|
|
let (text, is_file) = resolve_blueprint_text(target, env)?;
|
|
// Shape-discriminated like file-mode --content-id: an op-script (array)
|
|
// builds through the one-build tail, an envelope (object) loads (#202).
|
|
let composite = if is_file { composite_from_authored_text(&text, env)? } else { composite_from_any(&text, env)? };
|
|
let mut out = String::new();
|
|
for p in composite.param_space() {
|
|
let _ = writeln!(out, "{}:{:?}", p.name, p.kind);
|
|
}
|
|
for b in composite.bound_param_space() {
|
|
let _ = writeln!(out, "{}:{:?} default={}", b.name, b.kind, render_value(&b.value));
|
|
}
|
|
Ok(out)
|
|
}
|
|
|
|
/// `aura graph register <blueprint.json> [--name <label>]` (#196, `--name`
|
|
/// #317): parse the blueprint through the project vocabulary, canonicalize,
|
|
/// content-address, and store — the `process register` pattern, printing
|
|
/// the store path so the trail is followable. With `--name`, additionally
|
|
/// labels the stored content id (`aura_registry::Registry::put_blueprint_label`),
|
|
/// echoing the repoint when the label already pointed elsewhere. Prose to
|
|
/// stderr + exit 1 on any refusal.
|
|
pub fn register_cmd(file: &Path, name: Option<&str>, env: &aura_runner::project::Env) {
|
|
match register_blueprint(file, name, env) {
|
|
Ok(lines) => {
|
|
for line in lines {
|
|
println!("{line}");
|
|
}
|
|
}
|
|
Err(m) => {
|
|
eprintln!("aura: {m}");
|
|
std::process::exit(1);
|
|
}
|
|
}
|
|
}
|
|
|
|
fn register_blueprint(
|
|
file: &Path,
|
|
name: Option<&str>,
|
|
env: &aura_runner::project::Env,
|
|
) -> Result<Vec<String>, String> {
|
|
let text = std::fs::read_to_string(file)
|
|
.map_err(|e| format!("cannot read {}: {e}", file.display()))?;
|
|
// Shape-discriminated like file-mode --content-id (#202): either shape
|
|
// canonicalizes to the envelope form, so the stored id is shape-invariant.
|
|
// Gated (#331 review finding): this text is freshly authored FILE bytes,
|
|
// not a store read-back, so `composite_from_authored_text` applies.
|
|
let composite = composite_from_authored_text(&text, env)?;
|
|
let canonical = blueprint_to_json(&composite).map_err(|e| format!("serialize error: {e:?}"))?;
|
|
let id = crate::content_id(&canonical);
|
|
let registry = env.registry();
|
|
registry.put_blueprint(&id, &canonical).map_err(|e| e.to_string())?;
|
|
let mut lines = vec![format!(
|
|
"registered blueprint {id} ({})",
|
|
registry.blueprint_path(&id).display()
|
|
)];
|
|
if let Some(label) = name {
|
|
// Resolve BEFORE writing, so the repoint echo compares against the
|
|
// pre-write target (#317).
|
|
let previous = registry.resolve_blueprint_label(label);
|
|
registry.put_blueprint_label(label, &id).map_err(|e| e.to_string())?;
|
|
lines.push(match previous {
|
|
Some(old) if old != id => format!("label {label:?} -> {id} (was {old})"),
|
|
_ => format!("label {label:?} -> {id}"),
|
|
});
|
|
}
|
|
Ok(lines)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
/// The spec's worked-example document deserializes into the op vocabulary and
|
|
/// maps into engine `Op`s that replay into a compilable signal blueprint —
|
|
/// the DTO is a faithful front-end over the engine surface.
|
|
#[test]
|
|
fn opdoc_document_deserializes_and_replays() {
|
|
let doc = r#"[
|
|
{"op":"source","role":"price","kind":"F64"},
|
|
{"op":"add","type":"SMA","name":"fast","bind":{"length":{"I64":2}}},
|
|
{"op":"add","type":"SMA","name":"slow","bind":{"length":{"I64":4}}},
|
|
{"op":"add","type":"Sub"},
|
|
{"op":"add","type":"Bias"},
|
|
{"op":"feed","role":"price","into":["fast.series","slow.series"]},
|
|
{"op":"connect","from":"fast.value","to":"sub.lhs"},
|
|
{"op":"connect","from":"slow.value","to":"sub.rhs"},
|
|
{"op":"connect","from":"sub.value","to":"bias.signal"},
|
|
{"op":"expose","from":"bias.bias","as":"bias"}
|
|
]"#;
|
|
let docs: Vec<OpDoc> = serde_json::from_str(doc).expect("document parses");
|
|
assert_eq!(docs.len(), 10);
|
|
assert_eq!(docs[1].kind_label(), "add");
|
|
let ops: Vec<Op> = docs.into_iter().map(Op::from).collect();
|
|
// both SMA lengths are bound, so the only open param is bias.scale (f64).
|
|
let composite =
|
|
replay("graph", ops, &std_vocabulary, &|_: &str| None).expect("replay resolves");
|
|
composite.compile_with_params(&[Scalar::f64(0.5)]).expect("compiles");
|
|
}
|
|
|
|
#[test]
|
|
fn build_from_str_emits_blueprint_json_for_valid_document() {
|
|
let doc = r#"[
|
|
{"op":"source","role":"price","kind":"F64"},
|
|
{"op":"add","type":"SMA","name":"fast","bind":{"length":{"I64":2}}},
|
|
{"op":"add","type":"SMA","name":"slow","bind":{"length":{"I64":4}}},
|
|
{"op":"add","type":"Sub"},
|
|
{"op":"add","type":"Bias"},
|
|
{"op":"feed","role":"price","into":["fast.series","slow.series"]},
|
|
{"op":"connect","from":"fast.value","to":"sub.lhs"},
|
|
{"op":"connect","from":"slow.value","to":"sub.rhs"},
|
|
{"op":"connect","from":"sub.value","to":"bias.signal"},
|
|
{"op":"expose","from":"bias.bias","as":"bias"}
|
|
]"#;
|
|
let json = super::build_from_str(doc, &aura_runner::project::Env::std()).expect("valid document builds");
|
|
assert!(json.contains("\"format_version\":1"), "emits the #155 envelope: {json}");
|
|
assert!(json.contains("\"SMA\""), "carries the SMA nodes: {json}");
|
|
}
|
|
|
|
/// `add` names a node via `name` (mirroring the builder's `.named("fast")`),
|
|
/// not the old SQL-ish `as`: the identifier resolves in later ops and the
|
|
/// emitted #155 blueprint carries it.
|
|
#[test]
|
|
fn add_names_node_via_name_key() {
|
|
let doc = r#"[
|
|
{"op":"source","role":"price","kind":"F64"},
|
|
{"op":"add","type":"SMA","name":"fast"},
|
|
{"op":"feed","role":"price","into":["fast.series"]},
|
|
{"op":"expose","from":"fast.value","as":"out"}
|
|
]"#;
|
|
let json = super::build_from_str(doc, &aura_runner::project::Env::std()).expect("name-keyed add builds");
|
|
assert!(json.contains("\"name\":\"fast\""), "the blueprint carries the node name: {json}");
|
|
}
|
|
|
|
#[test]
|
|
fn build_from_str_reports_unknown_node_at_its_op_index() {
|
|
let doc = r#"[{"op":"add","type":"SMA","name":"fast"},{"op":"add","type":"Nope"}]"#;
|
|
let err = super::build_from_str(doc, &aura_runner::project::Env::std()).unwrap_err();
|
|
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.
|
|
let doc = r#"[
|
|
{"op":"source","role":"price","kind":"F64"},
|
|
{"op":"add","type":"SMA","name":"fast"},
|
|
{"op":"add","type":"SMA","name":"slow"},
|
|
{"op":"add","type":"Sub"},
|
|
{"op":"feed","role":"price","into":["fast.series","slow.series"]},
|
|
{"op":"connect","from":"fast.value","to":"sub.lhs"}
|
|
]"#;
|
|
let err = super::build_from_str(doc, &aura_runner::project::Env::std()).unwrap_err();
|
|
assert!(err.starts_with("finalize: "), "finalize fault, got: {err}");
|
|
assert!(err.contains("sub.rhs") && err.contains("is unconnected"),
|
|
"names the unconnected slot by-identifier, got: {err}");
|
|
assert!(!err.contains("node:"), "no raw machine index in the message, got: {err}");
|
|
}
|
|
|
|
#[test]
|
|
fn introspect_node_lists_ports_kinds_and_params() {
|
|
let out = super::introspect_node("SMA", &aura_runner::project::Env::std()).expect("SMA is in the vocabulary");
|
|
assert!(out.contains("series"), "lists the input port: {out}");
|
|
assert!(out.contains("value"), "lists the output field: {out}");
|
|
assert!(out.contains("length"), "lists the param path: {out}");
|
|
assert!(super::introspect_node("Nope", &aura_runner::project::Env::std()).is_err(), "rejects an unknown type");
|
|
}
|
|
|
|
/// A bind whose value-kind mismatches the param reads as prose, like the connect
|
|
/// mismatch — not a Debug struct.
|
|
#[test]
|
|
fn build_from_str_bad_bind_kind_reads_as_prose() {
|
|
let doc = r#"[{"op":"add","type":"SMA","name":"fast","bind":{"length":{"F64":2.0}}}]"#;
|
|
let err = super::build_from_str(doc, &aura_runner::project::Env::std()).unwrap_err();
|
|
assert_eq!(err, "op 0 (add): param fast.length expects I64 but got F64");
|
|
}
|
|
|
|
/// A bind to a param the node does not declare reads as prose naming the node
|
|
/// and the unknown param — not a Debug struct.
|
|
#[test]
|
|
fn build_from_str_unknown_bind_param_reads_as_prose() {
|
|
let doc = r#"[{"op":"add","type":"SMA","name":"fast","bind":{"window":{"I64":2}}}]"#;
|
|
let err = super::build_from_str(doc, &aura_runner::project::Env::std()).unwrap_err();
|
|
assert_eq!(err, "op 0 (add): node fast has no param \"window\"");
|
|
}
|
|
|
|
/// The ambiguous-param branch of the bind-error presenter is phrased as prose
|
|
/// naming the node and the ambiguous param. No std primitive carries two
|
|
/// same-named params, so the branch is unreachable end-to-end and is pinned on
|
|
/// the presenter directly.
|
|
#[test]
|
|
fn ambiguous_bind_param_reads_as_prose() {
|
|
let msg = format_op_error(&OpError::BadParam {
|
|
node: "fast".to_string(),
|
|
err: BindOpError::AmbiguousParam("length".to_string()),
|
|
});
|
|
assert_eq!(msg, "node fast has ambiguous param \"length\"");
|
|
}
|
|
|
|
/// `introspect --node` shows the typed-Scalar bind-value form so a hand-author
|
|
/// does not have to read source to learn the `{"I64": <v>}` wrapping.
|
|
#[test]
|
|
fn introspect_node_shows_the_bind_form() {
|
|
let out = super::introspect_node("SMA", &aura_runner::project::Env::std()).expect("SMA is in the vocabulary");
|
|
assert!(out.contains("(bind {\"I64\": <v>})"), "shows the bind-value form: {out}");
|
|
}
|
|
|
|
#[test]
|
|
fn introspect_unwired_reports_open_slots_of_a_partial_document() {
|
|
let doc = r#"[
|
|
{"op":"add","type":"SMA","name":"fast"},
|
|
{"op":"add","type":"Sub"},
|
|
{"op":"connect","from":"fast.value","to":"sub.lhs"}
|
|
]"#;
|
|
let out = super::introspect_unwired(doc, &aura_runner::project::Env::std()).expect("partial document introspects");
|
|
assert!(out.contains("sub.rhs"), "sub.rhs is still open: {out}");
|
|
assert!(out.contains("fast.series"), "fast.series is still open: {out}");
|
|
assert!(!out.contains("sub.lhs"), "sub.lhs is covered: {out}");
|
|
}
|
|
|
|
/// The `tap` op-doc parses (externally tagged, `"as"`-renamed like `expose`)
|
|
/// and `aura graph build` emits a blueprint whose serialized `taps` names the
|
|
/// wire — the name-addressed authoring path the fieldtest wanted, no raw index.
|
|
#[test]
|
|
fn tap_opdoc_builds_a_blueprint_carrying_the_tap() {
|
|
let doc = r#"[
|
|
{"op":"source","role":"price","kind":"F64"},
|
|
{"op":"add","type":"SMA","name":"fast","bind":{"length":{"I64":2}}},
|
|
{"op":"add","type":"SMA","name":"slow","bind":{"length":{"I64":4}}},
|
|
{"op":"add","type":"Sub"},
|
|
{"op":"feed","role":"price","into":["fast.series","slow.series"]},
|
|
{"op":"connect","from":"fast.value","to":"sub.lhs"},
|
|
{"op":"connect","from":"slow.value","to":"sub.rhs"},
|
|
{"op":"tap","from":"fast.value","as":"fast_ma"},
|
|
{"op":"expose","from":"sub.value","as":"bias"}
|
|
]"#;
|
|
// the tap-doc kind_label registers as "tap"
|
|
let docs: Vec<OpDoc> = serde_json::from_str(doc).expect("document parses");
|
|
assert_eq!(docs[7].kind_label(), "tap");
|
|
// and the built blueprint carries the tap (an un-tapped composite emits no
|
|
// `taps` key, so its presence is the proof the op threaded through)
|
|
let json = super::build_from_str(doc, &aura_runner::project::Env::std()).expect("tap op-doc builds");
|
|
assert!(json.contains("\"taps\""), "blueprint carries the taps section: {json}");
|
|
assert!(json.contains("fast_ma"), "the tap names the wire: {json}");
|
|
}
|
|
}
|