feat(aura-cli, aura-engine): use-aware unwired introspection; gang-rule prose; LinComb discovery

Harvest sweep, batch 5 of 6.

- introspect --unwired resolves use refs through the registry store:
  the parse+resolve phase is extracted from graph build's path into the
  shared parse_and_resolve_ops, so a use-bearing document introspects
  identically to how it builds (fetch, C29 gate, label resolution) —
  retiring both the hard miss and the 'content id' mislabeling of
  by-name refs. The engine's subgraph closure contract is unchanged
  (store-freedom binds the engine, not the CLI caller).
- ganging a spliced instance's member path refuses with the rule
  (OpError::GangOfSplicedInstance) instead of the typo-shaped
  no-such-param prose; the leaf-typo case keeps its UnknownParam shape
  under a new sibling pin.
- the --node pending note names the follow-up moves on the current
  surface (build, then introspect --params / --unwired), and the
  authoring guide gains a worked LinComb op-script example
  (args-then-bind order, term[i]/weights[i], verified by building it).

refs #339
refs #341
This commit is contained in:
2026-07-26 12:47:33 +02:00
parent d2b0cdf64c
commit 74281842b8
4 changed files with 184 additions and 51 deletions
+77 -33
View File
@@ -156,16 +156,17 @@ impl OpDoc {
}
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`).
/// Infallible, context-free conversion. Neither production caller
/// (`composite_from_str`'s / `introspect_unwired`'s shared
/// [`parse_and_resolve_ops`] phase, #339 item 4 harvest) reaches the
/// `OpDoc::Use` arm below with an unresolved ref: both intercept every
/// `Op::Use` op and route it through `resolve_use_op` (registry fetch +
/// C29 gate + full-vocabulary resolve) BEFORE ever calling `Op::from` —
/// only the remaining, `use`-free op kinds reach this conversion as-is.
/// The `OpDoc::Use` arm stays for match exhaustiveness (a bare,
/// registry-free conversion, `ref_id` verbatim off the `UseRef`); a
/// hypothetical direct caller would still get one, but every op-script
/// entry point in this file resolves first.
fn from(d: OpDoc) -> Op {
match d {
OpDoc::Source { role, kind } => Op::Source { role, kind },
@@ -255,14 +256,20 @@ fn format_op_error(e: &OpError) -> String {
format!("gang: `{node}.{param}` is already ganged")
}
OpError::GangArity { gang } => format!("gang `{gang}`: needs at least two members"),
OpError::GangOfSplicedInstance { node, member } => format!(
"gang: {node}.{member} is a spliced instance's member — ganging a used instance's member params is not yet supported"
),
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.
// #317, extended #339 item 4 harvest: BOTH CLI entry points
// (`composite_from_str`'s and `introspect_unwired`'s shared
// `parse_and_resolve_ops` phase) resolve and fetch every `use` op
// before replay/apply, so neither ever constructs an `Op::Use` whose
// `ref_id` is anything but an already-resolved content id — this
// arm is unreachable from the CLI surface. It stays reachable only
// against a bare `GraphSession`/`replay` caller that hands `Op::Use`
// a `ref_id` its own `subgraph` closure doesn't resolve (an engine-
// level API misuse, not a document-authoring fault).
OpError::UnknownSubgraph { ref_id } => {
format!("use: no subgraph for content id {ref_id:?}")
}
@@ -408,13 +415,21 @@ fn resolve_use_op(
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> {
/// [`parse_and_resolve_ops`]'s success payload: the parsed `Op`s, their
/// by-index kind labels (`op N (kind): cause` attribution), and the id -> json
/// cache the caller's own `subgraph` closure reads.
type ResolvedOps = (Vec<Op>, Vec<String>, std::collections::HashMap<String, String>);
/// Parse a JSON op-list document into `Op`s, resolving every `use` op through
/// the registry (fetch, C29-gate, full-vocabulary resolve) HERE, before either
/// caller ever builds a session (#317, extended #339 item 4 harvest): the
/// shared first phase of `composite_from_str` (`graph build`'s real path) AND
/// `introspect_unwired`'s build-free path, so a `use`-bearing document
/// resolves identically under both — introspection no longer treats a
/// resolvable ref as a hard miss. A resolution/doc-gate fault is attributed
/// exactly like any other per-op construction fault (same `op N (kind):
/// cause` shape).
fn parse_and_resolve_ops(doc: &str, env: &aura_runner::project::Env) -> Result<ResolvedOps, 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
@@ -450,6 +465,15 @@ fn composite_from_str(doc: &str, env: &aura_runner::project::Env) -> Result<Comp
};
ops.push(op);
}
Ok((ops, labels, cache))
}
/// 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).
fn composite_from_str(doc: &str, env: &aura_runner::project::Env) -> Result<Composite, String> {
let (ops, labels, cache) = parse_and_resolve_ops(doc, env)?;
// 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
@@ -527,7 +551,15 @@ pub fn introspect_node(type_id: &str, env: &aura_runner::project::Env) -> Result
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");
// #341 item 3 harvest: name the follow-up moves on the CURRENT
// surface, not just the fact that ports/params are absent — a
// consumer's first port guess is otherwise a coin flip, and only
// trial refusals surface the real names.
out.push_str(
" note ports and params form at construction; args are required — build \
the op-script, then `aura graph introspect --params <bp.json>` shows the open knobs and \
`--unwired` on a partial document shows the unfilled slots\n",
);
}
for port in &schema.inputs {
out.push_str(&format!(" in {}:{:?}\n", port.name, port.kind));
@@ -542,17 +574,29 @@ pub fn introspect_node(type_id: &str, env: &aura_runner::project::Env) -> Result
}
/// `aura graph introspect --unwired`: the still-open interior slots of a partial
/// op-list document, by-identifier (applies the ops, does NOT finalize).
/// op-list document, by-identifier (applies the ops, does NOT finalize). A
/// `use` op resolves through the registry via the SAME [`parse_and_resolve_ops`]
/// phase `composite_from_str` uses (#339 item 4 harvest: this path used to pass
/// `&|_| None` as the subgraph resolver, so a use-bearing document always
/// missed with `OpError::UnknownSubgraph` — mislabeling a by-name ref as a
/// "content id" in the bargain); the real store resolver threads in cleanly
/// because `unwired()` is read straight off the (unfinished) session,
/// unaffected by the `use` resolution happening one phase earlier.
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 (ops, labels, cache) = parse_and_resolve_ops(doc, env)?;
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 subgraph = |ref_id: &str| {
cache.get(ref_id).and_then(|json| blueprint_from_json(json, &|t| env.resolve(t)).ok())
};
let mut session = GraphSession::new("introspect", &resolver, &subgraph);
for (i, op) in ops.into_iter().enumerate() {
session.apply(op).map_err(|e| {
let cause = format_op_error(&e);
match labels.get(i) {
Some(kind) => format!("op {i} ({kind}): {cause}"),
None => format!("op {i}: {cause}"),
}
})?;
}
let mut out = String::new();
for (slot, kind) in session.unwired() {