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:
@@ -32,8 +32,11 @@ use crate::research_docs::{
|
||||
doc_error_prose, doc_fault_prose, fault_block, parse_valid_campaign, ref_fault_prose,
|
||||
};
|
||||
|
||||
/// A bare store address: exactly 64 lowercase hex chars (the content-id key shape).
|
||||
fn is_content_id(s: &str) -> bool {
|
||||
/// A bare store address: exactly 64 lowercase hex chars (the content-id key
|
||||
/// shape). `pub(crate)`: `graph_construct`'s `--params <FILE|ID>` resolution
|
||||
/// (#196) shares this exact predicate rather than re-inlining it, so the two
|
||||
/// FILE-or-id surfaces cannot drift apart on the id shape a second time.
|
||||
pub(crate) fn is_content_id(s: &str) -> bool {
|
||||
s.len() == 64 && s.bytes().all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f'))
|
||||
}
|
||||
|
||||
|
||||
@@ -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::*;
|
||||
|
||||
@@ -3805,6 +3805,11 @@ enum GraphSub {
|
||||
Build,
|
||||
/// Introspect a graph.
|
||||
Introspect(GraphIntrospectCmd),
|
||||
/// Register a blueprint document into the content-addressed store (#196).
|
||||
Register {
|
||||
/// The blueprint .json file to register.
|
||||
file: std::path::PathBuf,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Args)]
|
||||
@@ -3818,12 +3823,18 @@ struct GraphIntrospectCmd {
|
||||
/// List the graph's unwired (unbound) ports.
|
||||
#[arg(long)]
|
||||
unwired: bool,
|
||||
/// Print the graph's content id (topology hash).
|
||||
#[arg(long)]
|
||||
content_id: bool,
|
||||
/// Print the graph's content id (topology hash). With FILE, read the
|
||||
/// document from it (a blueprint envelope or an op-list, shape-
|
||||
/// discriminated, #196); without FILE, read a stdin op-list as before.
|
||||
#[arg(long, value_name = "FILE")]
|
||||
content_id: Option<Option<std::path::PathBuf>>,
|
||||
/// Print the graph's topology-identity id (debug names stripped).
|
||||
#[arg(long)]
|
||||
identity_id: bool,
|
||||
/// Print a blueprint's raw param space (the campaign-axis namespace), one
|
||||
/// name:kind line per open param. FILE path or 64-hex store content id (#196).
|
||||
#[arg(long, value_name = "FILE|ID")]
|
||||
params: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Args)]
|
||||
@@ -4341,6 +4352,7 @@ fn dispatch_graph(a: GraphCmd, env: &project::Env) {
|
||||
None => print!("{}", render::render_html(&sample_blueprint())),
|
||||
Some(GraphSub::Build) => graph_construct::build_cmd(env),
|
||||
Some(GraphSub::Introspect(i)) => graph_construct::introspect_cmd(i, env),
|
||||
Some(GraphSub::Register { file }) => graph_construct::register_cmd(&file, env),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user