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:
@@ -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}"
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user