diff --git a/crates/aura-cli/src/campaign_run.rs b/crates/aura-cli/src/campaign_run.rs index f103304..15fe8de 100644 --- a/crates/aura-cli/src/campaign_run.rs +++ b/crates/aura-cli/src/campaign_run.rs @@ -264,7 +264,10 @@ pub(crate) fn run_campaign(target: &str, env: &Env) -> Result<(), String> { // Target resolution: a readable file is register-then-run sugar; a bare // 64-hex token addresses the store directly; anything else refuses naming - // both readings. + // both readings. A CLI arg is ephemeral input, so a leading `content:` + // display prefix is tolerated here (#194) — doc ref FIELDS stay bare-only + // (canonical-form byte stability). + let target = target.strip_prefix("content:").filter(|t| is_content_id(t)).unwrap_or(target); let campaign_id = if Path::new(target).is_file() { let doc = parse_valid_campaign(&PathBuf::from(target))?; registry diff --git a/crates/aura-cli/src/graph_construct.rs b/crates/aura-cli/src/graph_construct.rs index c9e585d..186c2e1 100644 --- a/crates/aura-cli/src/graph_construct.rs +++ b/crates/aura-cli/src/graph_construct.rs @@ -347,6 +347,12 @@ fn composite_from_any(text: &str, env: &crate::project::Env) -> Result Result { + // A CLI arg tolerates the `content:` display prefix (#194); doc ref + // fields stay bare-only. + let target = target + .strip_prefix("content:") + .filter(|t| crate::campaign_run::is_content_id(t)) + .unwrap_or(target); let path = Path::new(target); if path.is_file() { return std::fs::read_to_string(path) @@ -405,7 +411,7 @@ fn register_blueprint(file: &Path, env: &crate::project::Env) -> Result Result<(), String> { let registry = env.registry(); let id = registry.put_process(&canonical).map_err(|e| e.to_string())?; println!( - "registered process content:{id} ({})", + "registered process {id} ({})", registry.process_path(&id).display() ); Ok(()) @@ -205,7 +205,7 @@ fn introspect_process(cmd: &DocIntrospectCmd) -> Result<(), String> { if let Some(file) = &cmd.content_id { let doc = parse_valid_process(file) .map_err(|m| format!("an invalid document has no canonical form — {m}"))?; - println!("content:{}", aura_research::content_id_of(&process_to_json(&doc))); + println!("{}", aura_research::content_id_of(&process_to_json(&doc))); return Ok(()); } unreachable!("guard_one_mode enforces one mode") @@ -249,10 +249,25 @@ pub enum CampaignSub { Run { target: String }, } +/// The one authoring trap the referential tier can name outright: a ref that +/// carries the `content:` DISPLAY prefix (doc ref fields are bare-only — +/// canonical-form byte stability, #194). +fn prefix_hint(id: &str) -> &'static str { + if id.starts_with("content:") { + " (refs use the bare 64-hex id — drop the 'content:' prefix)" + } else { + "" + } +} + pub(crate) fn ref_fault_prose(f: &RefFault) -> String { match f { - RefFault::ProcessNotFound(id) => format!("process {id} not found in the project store"), - RefFault::StrategyNotFound(id) => format!("strategy {id} not found in the blueprint store"), + RefFault::ProcessNotFound(id) => { + format!("process {id} not found in the project store{}", prefix_hint(id)) + } + RefFault::StrategyNotFound(id) => { + format!("strategy {id} not found in the blueprint store{}", prefix_hint(id)) + } RefFault::IdentityUnmatched(id) => format!("identity id {id} matches no stored blueprint"), RefFault::StrategyUnloadable { id, error } => { format!("strategy {id} cannot be loaded: {error}") @@ -343,7 +358,7 @@ fn register_campaign(file: &PathBuf, env: &Env) -> Result<(), String> { let registry = env.registry(); let id = registry.put_campaign(&canonical).map_err(|e| e.to_string())?; println!( - "registered campaign content:{id} ({})", + "registered campaign {id} ({})", registry.campaign_path(&id).display() ); Ok(()) @@ -369,7 +384,7 @@ fn introspect_campaign(cmd: &DocIntrospectCmd) -> Result<(), String> { if let Some(file) = &cmd.content_id { let doc = parse_valid_campaign(file) .map_err(|m| format!("an invalid document has no canonical form — {m}"))?; - println!("content:{}", aura_research::content_id_of(&campaign_to_json(&doc))); + println!("{}", aura_research::content_id_of(&campaign_to_json(&doc))); return Ok(()); } unreachable!("guard_one_mode enforces one mode") @@ -412,6 +427,22 @@ mod tests { } } + #[test] + /// #194 (the prefix trap): a doc ref that carries the display `content:` + /// prefix (a copy-paste from register/introspect output) is bare-only in + /// canonical documents — the referential refusal names the prefix as the + /// cause instead of failing mute. A bare id keeps the plain refusal, so the + /// hint is conditional, not always-on. + fn ref_fault_prose_hints_when_a_ref_carries_the_content_prefix() { + let hash = "a".repeat(64); + let proc = ref_fault_prose(&RefFault::ProcessNotFound(format!("content:{hash}"))); + let strat = ref_fault_prose(&RefFault::StrategyNotFound(format!("content:{hash}"))); + assert!(proc.contains("drop the 'content:' prefix"), "process ref hint missing: {proc}"); + assert!(strat.contains("drop the 'content:' prefix"), "strategy ref hint missing: {strat}"); + let bare = ref_fault_prose(&RefFault::ProcessNotFound(hash)); + assert!(!bare.contains("drop the 'content:' prefix"), "bare id must not carry the hint: {bare}"); + } + #[test] fn zero_walk_forward_length_prose_is_path_addressed_and_debug_free() { let prose = doc_fault_prose(&DocFault::ZeroWalkForwardLength { stage: 2, field: "step_ms" }); diff --git a/crates/aura-cli/tests/graph_construct.rs b/crates/aura-cli/tests/graph_construct.rs index 001217a..69b2211 100644 --- a/crates/aura-cli/tests/graph_construct.rs +++ b/crates/aura-cli/tests/graph_construct.rs @@ -407,16 +407,19 @@ fn fixture(name: &str) -> String { format!("{}/tests/fixtures/{name}", env!("CARGO_MANIFEST_DIR")) } -/// The id extracted from a `registered blueprint content:{id} ({path})` line. +/// The id extracted from a `registered blueprint {id} ({path})` line. +/// Prefix-tolerant across the #194 convention change (the display form dropped +/// the glued `content:` prefix): the id is bare either way. fn registered_id(stdout: &str) -> String { stdout .lines() - .find(|l| l.starts_with("registered blueprint content:")) + .find(|l| l.starts_with("registered blueprint ")) .expect("register line") - .trim_start_matches("registered blueprint content:") + .trim_start_matches("registered blueprint ") .split(' ') .next() .expect("id") + .trim_start_matches("content:") .to_string() } @@ -428,6 +431,8 @@ 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}"); + // #194: the register verb prints the bare id — `registered blueprint ()`. + assert!(!stdout.contains("content:"), "register prints the bare id, no content: prefix: {stdout}"); 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:?}"); @@ -514,6 +519,23 @@ fn graph_params_by_content_id_matches_params_by_file() { assert_eq!(by_id_out, "fast.length:I64\nslow.length:I64\n", "the raw open params, in order"); } +/// #194 (the prefix trap): a `--params ` target may carry the display +/// `content:` prefix copied verbatim from register/introspect output — the CLI +/// strips it and resolves the bare store address, printing exactly the same raw +/// axis namespace the bare id (and the file) print. +#[test] +fn graph_params_tolerates_content_prefix_on_target() { + let dir = temp_cwd("params-content-prefix"); + 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 (pfx_out, pfx_err, pfx_code) = + run_in(&dir, &["graph", "introspect", "--params", &format!("content:{id}")]); + assert_eq!(pfx_code, Some(0), "content:-prefixed --params must resolve: stdout {pfx_out} stderr {pfx_err}"); + assert_eq!(pfx_out, "fast.length:I64\nslow.length:I64\n", "same raw axis namespace as the bare id"); +} + /// 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 diff --git a/crates/aura-cli/tests/research_docs.rs b/crates/aura-cli/tests/research_docs.rs index d6be012..9890c87 100644 --- a/crates/aura-cli/tests/research_docs.rs +++ b/crates/aura-cli/tests/research_docs.rs @@ -152,8 +152,12 @@ fn process_introspect_vocabulary_block_and_content_id() { write_doc(&dir, "p.process.json", PROCESS_DOC); let (out, code) = run_code_in(&dir, &["process", "introspect", "--content-id", "p.process.json"]); assert_eq!(code, Some(0), "stdout/stderr: {out}"); - let line = out.lines().find(|l| l.starts_with("content:")).expect("id line"); - assert_eq!(line.len(), "content:".len() + 64); + // #194: --content-id prints the bare 64-hex id (matching `graph introspect + // --content-id` and the store filename), no glued `content:` display prefix. + let line = out.lines().last().expect("id line").trim(); + assert_eq!(line.len(), 64, "bare 64-hex id, no content: prefix: {out}"); + assert!(line.chars().all(|c| c.is_ascii_hexdigit()), "bare hex id: {out}"); + assert!(!out.contains("content:"), "--content-id must not glue the content: prefix: {out}"); } #[test] @@ -187,9 +191,11 @@ fn process_register_stores_content_addressed_under_runs_root() { write_doc(&dir, "p.process.json", PROCESS_DOC); let (out, code) = run_code_in(&dir, &["process", "register", "p.process.json"]); assert_eq!(code, Some(0), "stdout/stderr: {out}"); - let line = out.lines().find(|l| l.starts_with("registered process content:")).expect("line"); + // #194: the register verb prints the bare id — `registered process ()`. + let line = out.lines().find(|l| l.starts_with("registered process ")).expect("line"); + assert!(!line.contains("content:"), "register line prints the bare id, no content: prefix: {line}"); let id = line - .trim_start_matches("registered process content:") + .trim_start_matches("registered process ") .split(' ') .next() .expect("id"); @@ -308,12 +314,13 @@ fn campaign_validate_in_project_reports_referential_tier_end_to_end() { assert_eq!(proc_code, Some(0), "seed process register failed: {proc_out}"); let proc_id = proc_out .lines() - .find(|l| l.starts_with("registered process content:")) + .find(|l| l.starts_with("registered process ")) .expect("register line") - .trim_start_matches("registered process content:") + .trim_start_matches("registered process ") .split(' ') .next() .expect("id") + .trim_start_matches("content:") .to_string(); // "fast.length": the axis name is the RAW composite's `param_space` name. @@ -424,8 +431,11 @@ fn campaign_introspect_content_id_prints_hash() { write_doc(&dir, "c.campaign.json", CAMPAIGN_DOC); let (out, code) = run_code_in(&dir, &["campaign", "introspect", "--content-id", "c.campaign.json"]); assert_eq!(code, Some(0), "stdout/stderr: {out}"); - let line = out.lines().find(|l| l.starts_with("content:")).expect("id line"); - assert_eq!(line.len(), "content:".len() + 64); + // #194: --content-id prints the bare 64-hex id, no glued `content:` prefix. + let line = out.lines().last().expect("id line").trim(); + assert_eq!(line.len(), 64, "bare 64-hex id, no content: prefix: {out}"); + assert!(line.chars().all(|c| c.is_ascii_hexdigit()), "bare hex id: {out}"); + assert!(!out.contains("content:"), "--content-id must not glue the content: prefix: {out}"); } #[test] @@ -434,9 +444,11 @@ fn campaign_register_stores_content_addressed_under_runs_root() { write_doc(&dir, "c.campaign.json", CAMPAIGN_DOC); let (out, code) = run_code_in(&dir, &["campaign", "register", "c.campaign.json"]); assert_eq!(code, Some(0), "stdout/stderr: {out}"); - let line = out.lines().find(|l| l.starts_with("registered campaign content:")).expect("line"); + // #194: the register verb prints the bare id — `registered campaign ()`. + let line = out.lines().find(|l| l.starts_with("registered campaign ")).expect("line"); + assert!(!line.contains("content:"), "register line prints the bare id, no content: prefix: {line}"); let id = line - .trim_start_matches("registered campaign content:") + .trim_start_matches("registered campaign ") .split(' ') .next() .expect("id"); @@ -527,12 +539,13 @@ fn register_process_doc(dir: &Path, file: &str, doc: &str) -> String { let (out, code) = run_code_in(dir, &["process", "register", file]); assert_eq!(code, Some(0), "process register failed: {out}"); out.lines() - .find(|l| l.starts_with("registered process content:")) + .find(|l| l.starts_with("registered process ")) .expect("register line") - .trim_start_matches("registered process content:") + .trim_start_matches("registered process ") .split(' ') .next() .expect("id") + .trim_start_matches("content:") .to_string() } @@ -821,12 +834,13 @@ fn campaign_run_by_content_id_matches_file_sugar_refusal() { assert_eq!(reg_code, Some(0), "register failed: {reg_out}"); let id = reg_out .lines() - .find(|l| l.starts_with("registered campaign content:")) + .find(|l| l.starts_with("registered campaign ")) .expect("register line") - .trim_start_matches("registered campaign content:") + .trim_start_matches("registered campaign ") .split(' ') .next() .expect("id") + .trim_start_matches("content:") .to_string(); let (id_out, id_code) = run_code_in(dir, &["campaign", "run", &id]); assert_eq!(id_code, Some(1), "stdout/stderr: {id_out}"); @@ -836,6 +850,53 @@ fn campaign_run_by_content_id_matches_file_sugar_refusal() { assert_eq!(file_line, id_line, "file- and id-addressed runs must refuse identically"); } +/// #194 (the prefix trap): a `campaign run` target may carry the display +/// `content:` prefix copied verbatim from the tool's own register/introspect +/// output — the CLI strips it and resolves the bare store address. Pinned +/// observably: running the SAME registered campaign by bare id and by its +/// `content:`-prefixed form must refuse identically (proof the prefix is +/// stripped, not read as a second, unresolvable address). Both refuse at the +/// data-free v1 preflight (MC process), so no real data is needed. +#[test] +fn campaign_run_tolerates_content_prefix_on_target() { + 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("prefix.process.json")), + ScratchPath::File(dir.join("prefix.campaign.json")), + ]); + let bp_id = seed_blueprint(dir, "campaign-run-prefix-seed"); + let proc_id = register_process_doc(dir, "prefix.process.json", MC_PROCESS_DOC); + write_doc(dir, "prefix.campaign.json", &campaign_doc_json(&bp_id, &proc_id, (1, 2), "", "")); + + let (reg_out, reg_code) = run_code_in(dir, &["campaign", "register", "prefix.campaign.json"]); + assert_eq!(reg_code, Some(0), "register failed: {reg_out}"); + let id = reg_out + .lines() + .find(|l| l.starts_with("registered campaign ")) + .expect("register line") + .trim_start_matches("registered campaign ") + .split(' ') + .next() + .expect("id") + .trim_start_matches("content:") + .to_string(); + + let (bare_out, _) = run_code_in(dir, &["campaign", "run", &id]); + let (pfx_out, _) = run_code_in(dir, &["campaign", "run", &format!("content:{id}")]); + + let pfx_line = pfx_out.lines().find(|l| l.starts_with("aura: ")).expect("prefixed refusal line"); + assert!( + !pfx_line.contains("neither a readable .json file nor a 64-hex content id"), + "a content:-prefixed target must resolve as the bare id, not refuse as an unknown target: {pfx_line}" + ); + let bare_line = bare_out.lines().find(|l| l.starts_with("aura: ")).expect("bare refusal line"); + assert_eq!(bare_line, pfx_line, "bare-id and content:-prefixed runs must resolve 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 @@ -863,12 +924,13 @@ fn campaign_run_accepts_a_graph_register_seeded_strategy() { 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:")) + .find(|l| l.starts_with("registered blueprint ")) .expect("register line") - .trim_start_matches("registered blueprint content:") + .trim_start_matches("registered blueprint ") .split(' ') .next() .expect("id") + .trim_start_matches("content:") .to_string(); let proc_id = register_process_doc(dir, "onramp.process.json", SWEEP_ONLY_PROCESS_DOC);