feat(cli): #196 blueprint on-ramp — graph register, introspect --params, blueprint-file --content-id (0107 task 10)

Closes the F5 authoring gap: a campaign's strategy ref + axes are now
authorable from the public surface without running a sweep first.
'aura graph register <blueprint.json>' puts the canonical form into the
content-addressed store (id == topology hash) and prints the stored
path via the pub blueprint_path accessor; 'aura graph introspect
--params <FILE|64-hex-ID>' lists the RAW composite param_space — the
exact namespace campaign axes are validated against (the wrapped
--list-axes namespace stays the sweep-verb view); --content-id gains an
optional FILE that shape-discriminates a blueprint envelope from an
op-list document (stdin behaviour byte-identical without FILE).
is_content_id promoted pub(crate) and shared with graph_construct so
the two FILE-or-id surfaces cannot drift on the id shape.

RED evidence was produced retroactively by the loop (stash-probe: the
5 new tests fail for the expected reasons without the src changes) —
ordering deviation noted, outcome unaffected.

Gates: graph_ 46/0, workspace 987/0, clippy -D warnings clean.

closes #196
refs #198
This commit is contained in:
2026-07-03 22:00:14 +02:00
parent aeb0366aed
commit 367f8678f1
5 changed files with 452 additions and 30 deletions
+159 -25
View File
@@ -5,10 +5,11 @@
//! injected `aura_std::std_vocabulary`.
use std::collections::BTreeMap;
use std::path::Path;
use aura_engine::{
blueprint_identity_json, blueprint_to_json, replay, BindOpError, Composite, GraphSession, Op,
OpError, Scalar, ScalarKind,
blueprint_from_json, blueprint_identity_json, blueprint_to_json, replay, BindOpError,
Composite, GraphSession, LoadError, Op, OpError, Scalar, ScalarKind,
};
use serde::Deserialize;
@@ -191,18 +192,19 @@ pub fn introspect_unwired(doc: &str, env: &crate::project::Env) -> Result<String
}
/// `aura graph introspect`: dispatch the read-only queries. Exactly one of
/// `--vocabulary` / `--node <T>` / `--unwired` / the id group must be set; zero
/// or more than one is the usage error (exit 2). The id group is `--content-id`
/// and/or `--identity-id` — the two id flags may combine (one build, both ids,
/// content id first).
/// `--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: &crate::project::Env) {
let count = cmd.vocabulary as usize
+ cmd.node.is_some() as usize
+ cmd.unwired as usize
+ (cmd.content_id || cmd.identity_id) 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 | --content-id | --identity-id (the two id flags may be combined)"
"aura: Usage: aura graph introspect --vocabulary | --node <T> | --unwired | --params <FILE|ID> | --content-id [FILE] | --identity-id (the two id flags may be combined)"
);
std::process::exit(2);
}
@@ -232,28 +234,64 @@ pub fn introspect_cmd(cmd: crate::GraphIntrospectCmd, env: &crate::project::Env)
std::process::exit(1);
}
}
} else {
// --content-id / --identity-id (combinable): one build of the op-list, then
// each requested id on its own line, content id first. 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 `topology_hash` also uses, so all surfaces
// agree by construction.
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);
}
let composite = match composite_from_str(&doc, env) {
Ok(c) => c,
} else if let Some(target) = cmd.params.as_deref() {
// --params <FILE|ID> (#196): the RAW composite param space — exactly the
// namespace campaign axes are validated against (`validate_campaign_refs`
// checks the raw space; the wrapped `--list-axes` namespace on `aura sweep`
// is the sweep-verb view, not the campaign view).
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 `topology_hash` also uses, 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_any(&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 {
if cmd.content_id.is_some() {
match blueprint_to_json(&composite) {
Ok(json) => println!("{}", crate::content_id(&json)),
Err(e) => {
@@ -274,6 +312,102 @@ pub fn introspect_cmd(cmd: crate::GraphIntrospectCmd, env: &crate::project::Env)
}
}
/// 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`).
fn blueprint_load_prose(e: &LoadError) -> String {
match e {
LoadError::Json(err) => format!("blueprint document is not valid JSON: {err}"),
LoadError::UnsupportedVersion { found, supported } => {
format!("blueprint format_version {found} is unsupported (this build reads {supported})")
}
LoadError::UnknownNodeType(t) => format!("unknown node type {t:?}"),
}
}
/// #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.
fn composite_from_any(text: &str, env: &crate::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| blueprint_load_prose(&e))
}
_ => Err("document is neither a blueprint envelope (object) nor an op-list (array)".into()),
}
}
/// 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 `crate::campaign_run::is_content_id` — the same predicate
/// `campaign run`'s target resolution uses, so the two FILE-or-id surfaces
/// cannot drift apart on what counts as a store address.
fn resolve_blueprint_text(target: &str, env: &crate::project::Env) -> Result<String, String> {
let path = Path::new(target);
if path.is_file() {
return std::fs::read_to_string(path)
.map_err(|e| format!("cannot read {}: {e}", path.display()));
}
if crate::campaign_run::is_content_id(target) {
return match env.registry().get_blueprint(target) {
Ok(Some(json)) => Ok(json),
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.
fn params_lines(target: &str, env: &crate::project::Env) -> Result<String, String> {
use std::fmt::Write as _;
let text = resolve_blueprint_text(target, env)?;
let composite =
blueprint_from_json(&text, &|t| env.resolve(t)).map_err(|e| blueprint_load_prose(&e))?;
let mut out = String::new();
for p in composite.param_space() {
let _ = writeln!(out, "{}:{:?}", p.name, p.kind);
}
Ok(out)
}
/// `aura graph register <blueprint.json>` (#196): 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. Prose to stderr + exit 1 on any refusal.
pub fn register_cmd(file: &Path, env: &crate::project::Env) {
match register_blueprint(file, env) {
Ok(line) => println!("{line}"),
Err(m) => {
eprintln!("aura: {m}");
std::process::exit(1);
}
}
}
fn register_blueprint(file: &Path, env: &crate::project::Env) -> Result<String, String> {
let text = std::fs::read_to_string(file)
.map_err(|e| format!("cannot read {}: {e}", file.display()))?;
let composite =
blueprint_from_json(&text, &|t| env.resolve(t)).map_err(|e| blueprint_load_prose(&e))?;
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())?;
Ok(format!(
"registered blueprint content:{id} ({})",
registry.blueprint_path(&id).display()
))
}
#[cfg(test)]
mod tests {
use super::*;