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),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -378,3 +378,230 @@ fn graph_introspect_no_flag_is_usage_exit_2() {
|
||||
assert_eq!(code, Some(2), "no selection flag is a usage error -> exit 2; stderr: {stderr}");
|
||||
assert!(stderr.contains("Usage"), "prints the usage line: {stderr}");
|
||||
}
|
||||
|
||||
/// A fresh, unique working directory for a test that persists content-addressed
|
||||
/// blueprints under `./runs/` (mirrors `research_docs.rs`'s `temp_cwd`).
|
||||
fn temp_cwd(name: &str) -> std::path::PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!("aura-graph-{}-{}", std::process::id(), name));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).expect("create temp cwd");
|
||||
dir
|
||||
}
|
||||
|
||||
/// Run `aura <args>` in `dir` (no stdin); return (stdout, stderr, exit code).
|
||||
fn run_in(dir: &std::path::Path, args: &[&str]) -> (String, String, Option<i32>) {
|
||||
let out = Command::new(BIN)
|
||||
.args(args)
|
||||
.current_dir(dir)
|
||||
.output()
|
||||
.expect("binary runs");
|
||||
(
|
||||
String::from_utf8_lossy(&out.stdout).into_owned(),
|
||||
String::from_utf8_lossy(&out.stderr).into_owned(),
|
||||
out.status.code(),
|
||||
)
|
||||
}
|
||||
|
||||
/// The absolute path of a blueprint fixture shipped with this test crate.
|
||||
fn fixture(name: &str) -> String {
|
||||
format!("{}/tests/fixtures/{name}", env!("CARGO_MANIFEST_DIR"))
|
||||
}
|
||||
|
||||
/// The id extracted from a `registered blueprint content:{id} ({path})` line.
|
||||
fn registered_id(stdout: &str) -> String {
|
||||
stdout
|
||||
.lines()
|
||||
.find(|l| l.starts_with("registered blueprint content:"))
|
||||
.expect("register line")
|
||||
.trim_start_matches("registered blueprint content:")
|
||||
.split(' ')
|
||||
.next()
|
||||
.expect("id")
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// Property (#196, the on-ramp): `aura graph register <blueprint.json>` parses
|
||||
/// the document through the vocabulary, canonicalizes, and stores it content-
|
||||
/// addressed under `runs/blueprints/<id>.json`, printing the id + store path.
|
||||
#[test]
|
||||
fn graph_register_stores_and_prints_content_id() {
|
||||
let dir = temp_cwd("register");
|
||||
let (stdout, stderr, code) = run_in(&dir, &["graph", "register", &fixture("sma_signal.json")]);
|
||||
assert_eq!(code, Some(0), "stdout: {stdout} stderr: {stderr}");
|
||||
let id = registered_id(&stdout);
|
||||
assert_eq!(id.len(), 64, "a 64-hex content id: {id:?}");
|
||||
assert!(id.chars().all(|c| c.is_ascii_hexdigit()), "hex only: {id:?}");
|
||||
assert!(
|
||||
dir.join("runs").join("blueprints").join(format!("{id}.json")).is_file(),
|
||||
"stored under runs/blueprints/<id>.json: {stdout}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Register is write-once content-addressed: the same document registers to the
|
||||
/// same id, exit 0 both times (the idempotent `put_blueprint` contract).
|
||||
#[test]
|
||||
fn graph_register_is_idempotent() {
|
||||
let dir = temp_cwd("register-idempotent");
|
||||
let bp = fixture("sma_signal.json");
|
||||
let (out1, err1, code1) = run_in(&dir, &["graph", "register", &bp]);
|
||||
let (out2, err2, code2) = run_in(&dir, &["graph", "register", &bp]);
|
||||
assert_eq!(code1, Some(0), "first register: {out1} {err1}");
|
||||
assert_eq!(code2, Some(0), "second register: {out2} {err2}");
|
||||
assert_eq!(registered_id(&out1), registered_id(&out2), "same id twice");
|
||||
}
|
||||
|
||||
/// Property (#196, the campaign-axis namespace): `--params <FILE>` prints the
|
||||
/// RAW composite param space — one `{name}:{kind:?}` line per open param, in
|
||||
/// lowering order, WITHOUT the harness-wrap prefix `aura sweep --list-axes`
|
||||
/// shows. The open fixture leaves exactly the two SMA lengths unbound
|
||||
/// (`bias.scale` is bound in the document).
|
||||
#[test]
|
||||
fn graph_params_lists_raw_axis_namespace() {
|
||||
let dir = temp_cwd("params");
|
||||
let (stdout, stderr, code) =
|
||||
run_in(&dir, &["graph", "introspect", "--params", &fixture("sma_signal_open.json")]);
|
||||
assert_eq!(code, Some(0), "stdout: {stdout} stderr: {stderr}");
|
||||
assert_eq!(stdout, "fast.length:I64\nslow.length:I64\n", "the raw open params, in order");
|
||||
}
|
||||
|
||||
/// Property (#196, file-mode --content-id): a blueprint FILE's printed content
|
||||
/// id is exactly the store key `graph register` prints — the file is shape-
|
||||
/// discriminated from the op-list form and canonicalized by the blueprint rules.
|
||||
#[test]
|
||||
fn graph_content_id_accepts_a_blueprint_file() {
|
||||
let dir = temp_cwd("content-id-file");
|
||||
let bp = fixture("sma_signal.json");
|
||||
let (reg_out, reg_err, reg_code) = run_in(&dir, &["graph", "register", &bp]);
|
||||
assert_eq!(reg_code, Some(0), "register: {reg_out} {reg_err}");
|
||||
let (id_out, id_err, id_code) = run_in(&dir, &["graph", "introspect", "--content-id", &bp]);
|
||||
assert_eq!(id_code, Some(0), "content-id: {id_out} {id_err}");
|
||||
assert_eq!(id_out.trim(), registered_id(®_out), "file content id == store key");
|
||||
}
|
||||
|
||||
/// The one-mode guard extends over the new `--params` mode: zero modes and two
|
||||
/// modes both refuse usage-exit-2, and the usage line names the new mode.
|
||||
#[test]
|
||||
fn graph_introspect_mode_guard_still_exits_two_on_zero_or_two_modes() {
|
||||
let dir = temp_cwd("mode-guard");
|
||||
let (_out0, err0, code0) = run_in(&dir, &["graph", "introspect"]);
|
||||
assert_eq!(code0, Some(2), "zero modes is usage exit 2: {err0}");
|
||||
assert!(err0.contains("--params <FILE|ID>"), "usage line names --params: {err0}");
|
||||
let (_out2, err2, code2) =
|
||||
run_in(&dir, &["graph", "introspect", "--vocabulary", "--params", "x.json"]);
|
||||
assert_eq!(code2, Some(2), "two modes is usage exit 2: {err2}");
|
||||
assert!(err2.contains("Usage: aura graph introspect"), "prints the usage line: {err2}");
|
||||
}
|
||||
|
||||
/// Property (#196, the register->params-by-id round trip): `--params <ID>`
|
||||
/// with a registered blueprint's content id resolves through the project
|
||||
/// store and prints exactly the same raw axis namespace `--params <FILE>`
|
||||
/// prints for the same document — the advertised `<FILE|ID>` addressing is
|
||||
/// two paths to the identical answer, not merely two accepted shapes.
|
||||
#[test]
|
||||
fn graph_params_by_content_id_matches_params_by_file() {
|
||||
let dir = temp_cwd("params-by-id");
|
||||
let bp = fixture("sma_signal_open.json");
|
||||
let (reg_out, reg_err, reg_code) = run_in(&dir, &["graph", "register", &bp]);
|
||||
assert_eq!(reg_code, Some(0), "register: {reg_out} {reg_err}");
|
||||
let id = registered_id(®_out);
|
||||
let (by_id_out, by_id_err, by_id_code) =
|
||||
run_in(&dir, &["graph", "introspect", "--params", &id]);
|
||||
assert_eq!(by_id_code, Some(0), "stdout: {by_id_out} stderr: {by_id_err}");
|
||||
let (by_file_out, by_file_err, by_file_code) =
|
||||
run_in(&dir, &["graph", "introspect", "--params", &bp]);
|
||||
assert_eq!(by_file_code, Some(0), "stdout: {by_file_out} stderr: {by_file_err}");
|
||||
assert_eq!(by_id_out, by_file_out, "by-id and by-file agree on the raw axis namespace");
|
||||
assert_eq!(by_id_out, "fast.length:I64\nslow.length:I64\n", "the raw open params, in order");
|
||||
}
|
||||
|
||||
/// Property (#196, negative): `--params <ID>` with a well-shaped 64-hex id
|
||||
/// that was never registered refuses with a store-miss message, not a panic
|
||||
/// or a silent empty namespace — the registry lookup branch of
|
||||
/// `resolve_blueprint_text` is exercised.
|
||||
#[test]
|
||||
fn graph_params_by_unregistered_id_reports_a_store_miss() {
|
||||
let dir = temp_cwd("params-missing-id");
|
||||
let bogus_id = "a".repeat(64);
|
||||
let (stdout, stderr, code) = run_in(&dir, &["graph", "introspect", "--params", &bogus_id]);
|
||||
assert_ne!(code, Some(0), "non-zero exit on a store miss: {stdout} {stderr}");
|
||||
assert!(stdout.is_empty(), "no partial namespace on a miss: {stdout}");
|
||||
assert!(
|
||||
stderr.contains(&format!("no blueprint {bogus_id} in the project store")),
|
||||
"names the miss: {stderr}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Property (#196, negative): `--params <TARGET>` where TARGET is neither an
|
||||
/// existing file nor a 64-hex content id refuses naming both readings — the
|
||||
/// `resolve_blueprint_text` fall-through branch, shared with `campaign run`'s
|
||||
/// target resolution via `is_content_id`.
|
||||
#[test]
|
||||
fn graph_params_target_neither_file_nor_id_is_refused() {
|
||||
let dir = temp_cwd("params-neither");
|
||||
let (stdout, stderr, code) = run_in(&dir, &["graph", "introspect", "--params", "not-a-file-or-id"]);
|
||||
assert_ne!(code, Some(0), "non-zero exit: {stdout} {stderr}");
|
||||
assert!(stdout.is_empty(), "no partial namespace on refusal: {stdout}");
|
||||
assert!(
|
||||
stderr.contains("'not-a-file-or-id' is neither a readable .json file nor a 64-hex content id"),
|
||||
"names both readings: {stderr}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Property (#196, the op-list branch of file-mode --content-id): a FILE that
|
||||
/// is a JSON *array* (a construction op-list, not a blueprint envelope) is
|
||||
/// shape-discriminated correctly and produces the SAME content id as piping
|
||||
/// the identical document to stdin — `composite_from_any`'s `Array` arm is
|
||||
/// exercised end-to-end, not just its `Object` sibling.
|
||||
#[test]
|
||||
fn graph_content_id_accepts_an_op_list_file() {
|
||||
let dir = temp_cwd("content-id-op-list-file");
|
||||
let op_list_path = dir.join("op-list.json");
|
||||
std::fs::write(&op_list_path, SIGNAL_DOC).expect("write op-list fixture");
|
||||
let (file_out, file_err, file_code) = run_in(
|
||||
&dir,
|
||||
&["graph", "introspect", "--content-id", op_list_path.to_str().unwrap()],
|
||||
);
|
||||
assert_eq!(file_code, Some(0), "stdout: {file_out} stderr: {file_err}");
|
||||
let (stdin_out, _e, stdin_ok) = run(&["graph", "introspect", "--content-id"], SIGNAL_DOC);
|
||||
assert!(stdin_ok, "stdin variant succeeds");
|
||||
assert_eq!(file_out.trim(), stdin_out.trim(), "op-list file and stdin agree on content id");
|
||||
}
|
||||
|
||||
/// Property (#196, negative): a FILE whose top-level JSON value is neither an
|
||||
/// object (blueprint envelope) nor an array (op-list) — e.g. a bare string —
|
||||
/// is refused, exercising `composite_from_any`'s fall-through arm.
|
||||
#[test]
|
||||
fn graph_content_id_rejects_a_file_that_is_neither_object_nor_array() {
|
||||
let dir = temp_cwd("content-id-neither-shape");
|
||||
let bad_path = dir.join("bad.json");
|
||||
std::fs::write(&bad_path, "\"just a string\"").expect("write bad fixture");
|
||||
let (stdout, stderr, code) = run_in(
|
||||
&dir,
|
||||
&["graph", "introspect", "--content-id", bad_path.to_str().unwrap()],
|
||||
);
|
||||
assert_ne!(code, Some(0), "non-zero exit: {stdout} {stderr}");
|
||||
assert!(stdout.is_empty(), "no content id emitted: {stdout}");
|
||||
assert!(
|
||||
stderr.contains("document is neither a blueprint envelope (object) nor an op-list (array)"),
|
||||
"names the shape refusal: {stderr}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Property (#196, negative): `register` on a syntactically-valid blueprint
|
||||
/// envelope that names an unknown node type refuses with the `blueprint_load_prose`
|
||||
/// `UnknownNodeType` prose (not a panic, not a bare Debug dump) — this refusal
|
||||
/// path is shared by `register`, `--params`, and file-mode `--content-id` (all
|
||||
/// route through `blueprint_from_json` -> `blueprint_load_prose`), so exercising
|
||||
/// it once through `register` closes the whole family.
|
||||
#[test]
|
||||
fn graph_register_rejects_an_unknown_node_type_with_prose() {
|
||||
let dir = temp_cwd("register-unknown-node");
|
||||
let (stdout, stderr, code) =
|
||||
run_in(&dir, &["graph", "register", &fixture("unknown_node.json")]);
|
||||
assert_ne!(code, Some(0), "non-zero exit: {stdout} {stderr}");
|
||||
assert!(stdout.is_empty(), "no registration line on refusal: {stdout}");
|
||||
assert!(
|
||||
stderr.contains("unknown node type \"Nope\""),
|
||||
"names the unknown node type: {stderr}"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -835,6 +835,52 @@ fn campaign_run_by_content_id_matches_file_sugar_refusal() {
|
||||
assert_eq!(file_line, id_line, "file- and id-addressed runs must refuse identically");
|
||||
}
|
||||
|
||||
/// Property (#196 on-ramp x #198 executor): a blueprint registered through
|
||||
/// `aura graph register` — NOT the pre-#196 `aura sweep` side-effect
|
||||
/// `seed_blueprint` relies on elsewhere in this file — is a fully valid
|
||||
/// campaign strategy ref: its content id resolves through the referential
|
||||
/// gate, and its raw param space (`fast.length`/`slow.length`, the same
|
||||
/// names `graph introspect --params` reports for this fixture) binds against
|
||||
/// the campaign document's axes in `bind_axes`. This closes fieldtest F5
|
||||
/// ("strategy refs unresolvable from the public surface — no blueprint
|
||||
/// register verb") end to end: the run reaches the member-data seam (the
|
||||
/// same [1, 2] 1970 window used elsewhere in this file for a deterministic,
|
||||
/// data-free refusal) rather than failing earlier at referencing or binding.
|
||||
#[test]
|
||||
fn campaign_run_accepts_a_graph_register_seeded_strategy() {
|
||||
let _fixture = project_lock();
|
||||
let dir = built_project();
|
||||
let runs_dir = dir.join("runs");
|
||||
std::fs::remove_dir_all(&runs_dir).ok();
|
||||
let _cleanup = ScratchGuard(vec![
|
||||
ScratchPath::Dir(runs_dir.clone()),
|
||||
ScratchPath::File(dir.join("onramp.process.json")),
|
||||
ScratchPath::File(dir.join("onramp.campaign.json")),
|
||||
]);
|
||||
let open_bp = format!("{}/tests/fixtures/sma_signal_open.json", env!("CARGO_MANIFEST_DIR"));
|
||||
let (reg_out, reg_code) = run_code_in(dir, &["graph", "register", &open_bp]);
|
||||
assert_eq!(reg_code, Some(0), "graph register failed: {reg_out}");
|
||||
let bp_id = reg_out
|
||||
.lines()
|
||||
.find(|l| l.starts_with("registered blueprint content:"))
|
||||
.expect("register line")
|
||||
.trim_start_matches("registered blueprint content:")
|
||||
.split(' ')
|
||||
.next()
|
||||
.expect("id")
|
||||
.to_string();
|
||||
|
||||
let proc_id = register_process_doc(dir, "onramp.process.json", SWEEP_ONLY_PROCESS_DOC);
|
||||
write_doc(dir, "onramp.campaign.json", &campaign_doc_json(&bp_id, &proc_id, (1, 2), "", ""));
|
||||
let (out, code) = run_code_in(dir, &["campaign", "run", "onramp.campaign.json"]);
|
||||
assert_eq!(code, Some(1), "stdout/stderr: {out}");
|
||||
assert!(
|
||||
out.contains("no recorded geometry") || out.contains("no data for instrument"),
|
||||
"referencing + axis-binding must succeed, reaching the member-data \
|
||||
seam rather than a referential or bind refusal: {out}"
|
||||
);
|
||||
}
|
||||
|
||||
/// `campaign run`'s file-sugar branch calls `parse_valid_campaign` directly
|
||||
/// (unwrapped), the SAME intrinsic validator `campaign register` calls but
|
||||
/// wraps in its own "refusing to register:" prefix. This pins that `run`
|
||||
|
||||
Reference in New Issue
Block a user