diff --git a/crates/aura-cli/src/campaign_run.rs b/crates/aura-cli/src/campaign_run.rs index 45c55f3..e1fb2af 100644 --- a/crates/aura-cli/src/campaign_run.rs +++ b/crates/aura-cli/src/campaign_run.rs @@ -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 ` 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')) } diff --git a/crates/aura-cli/src/graph_construct.rs b/crates/aura-cli/src/graph_construct.rs index cfa6481..09e35d9 100644 --- a/crates/aura-cli/src/graph_construct.rs +++ b/crates/aura-cli/src/graph_construct.rs @@ -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` / `--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 ` / `--unwired` / `--params ` / 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 | --unwired | --content-id | --identity-id (the two id flags may be combined)" + "aura: Usage: aura graph introspect --vocabulary | --node | --unwired | --params | --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 (#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 { + 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 { + 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 ` (#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 { + 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 ` (#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 { + 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::*; diff --git a/crates/aura-cli/src/main.rs b/crates/aura-cli/src/main.rs index c3f073f..2ba4fc7 100644 --- a/crates/aura-cli/src/main.rs +++ b/crates/aura-cli/src/main.rs @@ -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>, /// 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, } #[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), } } diff --git a/crates/aura-cli/tests/graph_construct.rs b/crates/aura-cli/tests/graph_construct.rs index 3cd343e..4ecf189 100644 --- a/crates/aura-cli/tests/graph_construct.rs +++ b/crates/aura-cli/tests/graph_construct.rs @@ -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 ` in `dir` (no stdin); return (stdout, stderr, exit code). +fn run_in(dir: &std::path::Path, args: &[&str]) -> (String, String, Option) { + 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 ` parses +/// the document through the vocabulary, canonicalizes, and stores it content- +/// addressed under `runs/blueprints/.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/.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 ` 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 "), "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 ` +/// with a registered blueprint's content id resolves through the project +/// store and prints exactly the same raw axis namespace `--params ` +/// prints for the same document — the advertised `` 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 ` 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 ` 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}" + ); +} diff --git a/crates/aura-cli/tests/research_docs.rs b/crates/aura-cli/tests/research_docs.rs index b4df9c6..618f0a1 100644 --- a/crates/aura-cli/tests/research_docs.rs +++ b/crates/aura-cli/tests/research_docs.rs @@ -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`