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
+227
View File
@@ -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(&reg_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(&reg_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}"
);
}