fix(cli): sweep-entry ergonomics — honest refusals, no store litter (#210 c0110 fieldtest)
Three test-first fixes from the cycle-0110 fieldtest findings: - Axis-name preflight on the dissolved sweep arm: every --axis name is checked against the wrapped probe namespace (the --list-axes names) BEFORE the #203 strip; an unknown or raw-form name is refused house-style echoing exactly what the user typed, with a --list-axes pointer — no more stripped-then-mangled fragments from the deep referential refusal. Data-free, fires before the archive is touched. - Validate-before-register in the sweep sugar: intrinsic doc checks + executor preflight + the pure bind_axes coverage check (reused, not re-inlined; now pub(crate)) all run in-memory before register_generated — a refused invocation leaves no generated document in the content-addressed store and no campaign_runs.jsonl line (previously P2/P3 refusals littered runs/campaigns, one doc malformed). - blueprint_slot_prose: the sweep/walkforward/mc loaded-blueprint slots share one dispatch-boundary presenter — an op-script array gets a targeted 'run aura graph build first' hint, every load error goes through blueprint_load_prose + the missing-project hint, and the raw {e:?} Debug leak (#184's pattern, deliberately left on these three slots back then) is gone. Two stale Debug-leak pins flipped, two new refusal tests. Suite 1060/0, clippy -D warnings clean. refs #210
This commit is contained in:
@@ -151,7 +151,7 @@ pub(crate) fn raw_matches_wrapped(raw: &str, wrapped: &str) -> bool {
|
||||
/// node segment yields raw), then require every wrapped slot to be covered.
|
||||
/// Pure and independent of the env/data seam so the three fault arms are
|
||||
/// unit-testable without a project, a store, or a loaded blueprint.
|
||||
fn bind_axes(
|
||||
pub(crate) fn bind_axes(
|
||||
space: &[ParamSpec],
|
||||
strategy_id: &str,
|
||||
params: &[(String, Scalar)],
|
||||
|
||||
@@ -344,6 +344,41 @@ pub(crate) fn unresolved_namespace_hint(e: &LoadError, env: &crate::project::Env
|
||||
}
|
||||
}
|
||||
|
||||
/// Validate a blueprint-slot document — the `sweep`/`walkforward`/`mc`
|
||||
/// loaded-blueprint branches' dispatch-boundary check (#210 c0110 fieldtest
|
||||
/// finding: the sweep slot used to print the raw `{e:?}` Debug leak). Single-
|
||||
/// sourced here so the three dual-grammar subcommands cannot diverge (#184's
|
||||
/// convention, extended). Two refusals, checked in order:
|
||||
///
|
||||
/// 1. The document parses as JSON but is an op-script ARRAY (#157), not a
|
||||
/// built blueprint envelope — refused with a targeted hint pointing at
|
||||
/// `aura graph build`, since `blueprint_from_json` would otherwise surface
|
||||
/// a confusing internal serde shape mismatch.
|
||||
/// 2. `blueprint_from_json` fails to load the envelope — phrased house-style
|
||||
/// via [`blueprint_load_prose`] (+ [`unresolved_namespace_hint`] where it
|
||||
/// applies), never the raw `LoadError` Debug form.
|
||||
///
|
||||
/// `Ok(())` means the document loaded cleanly; callers that only need the
|
||||
/// validation (not the `Composite`) re-parse `doc` themselves afterward
|
||||
/// (`blueprint_from_json` is cheap and infallible at that point).
|
||||
pub(crate) fn blueprint_slot_prose(doc: &str, env: &crate::project::Env) -> Result<(), String> {
|
||||
if matches!(serde_json::from_str::<serde_json::Value>(doc), Ok(serde_json::Value::Array(_))) {
|
||||
return Err(
|
||||
"this is an op-script (an op array), not a built blueprint — run \
|
||||
'aura graph build' first"
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
blueprint_from_json(doc, &|t| env.resolve(t)).map(|_| ()).map_err(|e| {
|
||||
let mut msg = blueprint_load_prose(&e);
|
||||
if let Some(hint) = unresolved_namespace_hint(&e, env) {
|
||||
msg.push_str(" — ");
|
||||
msg.push_str(&hint);
|
||||
}
|
||||
msg
|
||||
})
|
||||
}
|
||||
|
||||
/// #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,
|
||||
|
||||
@@ -4427,9 +4427,10 @@ fn dispatch_sweep(a: SweepCmd, env: &project::Env) {
|
||||
eprintln!("aura: {path}: {e}");
|
||||
std::process::exit(2);
|
||||
});
|
||||
// Parse-validate the blueprint once at the boundary (with file-path context).
|
||||
if let Err(e) = blueprint_from_json(&doc, &|t| env.resolve(t)) {
|
||||
eprintln!("aura: {path}: {e:?}");
|
||||
// Parse-validate the blueprint once at the boundary (with file-path context),
|
||||
// house-style prose (#184's convention, single-sourced — #210 c0110 finding).
|
||||
if let Err(msg) = graph_construct::blueprint_slot_prose(&doc, env) {
|
||||
eprintln!("aura: {path}: {msg}");
|
||||
std::process::exit(2);
|
||||
}
|
||||
// A built-in-only flag with a blueprint file is not in this grammar.
|
||||
@@ -4490,6 +4491,27 @@ fn dispatch_sweep(a: SweepCmd, env: &project::Env) {
|
||||
// strategy ref resolves against this one write; a second
|
||||
// put under `content_id_of(&canonical)` would target the
|
||||
// identical key and is not needed.
|
||||
// FIX (#210 c0110 finding "wrapped-name refusal mangles the
|
||||
// axis"): validate every `--axis` name against the WRAPPED
|
||||
// probe namespace (`blueprint_axis_probe`/`--list-axes` —
|
||||
// the same names #203's strip later consumes) BEFORE
|
||||
// stripping/translating to the raw campaign namespace. A
|
||||
// raw-form or fat-fingered name is refused here, echoing
|
||||
// exactly what the user typed, instead of surfacing a
|
||||
// stripped-then-mangled fragment deep inside a member run.
|
||||
// Data-free (the probe only reloads the blueprint), so
|
||||
// this fires before the archive is touched.
|
||||
let space = blueprint_axis_probe(&doc, env).param_space();
|
||||
for (n, _) in &axes {
|
||||
if !space.iter().any(|p| &p.name == n) {
|
||||
eprintln!(
|
||||
"aura: axis \"{n}\" is not one of this blueprint's \
|
||||
sweepable axes — run 'aura sweep <bp> --list-axes' \
|
||||
to see them"
|
||||
);
|
||||
std::process::exit(2);
|
||||
}
|
||||
}
|
||||
let symbol = symbol.clone();
|
||||
let source = DataSource::from_choice(data, env);
|
||||
let (from_ts, to_ts) = source.full_window(env);
|
||||
@@ -4593,8 +4615,8 @@ fn dispatch_walkforward(a: WalkforwardCmd, env: &project::Env) {
|
||||
eprintln!("aura: {path}: {e}");
|
||||
std::process::exit(2);
|
||||
});
|
||||
if let Err(e) = blueprint_from_json(&doc, &|t| env.resolve(t)) {
|
||||
eprintln!("aura: {path}: {e:?}");
|
||||
if let Err(msg) = graph_construct::blueprint_slot_prose(&doc, env) {
|
||||
eprintln!("aura: {path}: {msg}");
|
||||
std::process::exit(2);
|
||||
}
|
||||
if a.strategy.is_some()
|
||||
@@ -4672,8 +4694,8 @@ fn dispatch_mc(a: McCmd, env: &project::Env) {
|
||||
eprintln!("aura: {path}: {e}");
|
||||
std::process::exit(2);
|
||||
});
|
||||
if let Err(e) = blueprint_from_json(&doc, &|t| env.resolve(t)) {
|
||||
eprintln!("aura: {path}: {e:?}");
|
||||
if let Err(msg) = graph_construct::blueprint_slot_prose(&doc, env) {
|
||||
eprintln!("aura: {path}: {msg}");
|
||||
std::process::exit(2);
|
||||
}
|
||||
// A built-in-only flag with a blueprint file is not in this grammar
|
||||
|
||||
@@ -124,6 +124,47 @@ pub(crate) fn run_sweep_sugar(
|
||||
env: &crate::project::Env,
|
||||
) -> Result<(), String> {
|
||||
let generated = translate_sweep(axes, name, symbol, from_ms, to_ms, blueprint_canonical)?;
|
||||
|
||||
// Validate BEFORE registering anything (#210 c0110 fieldtest, "store
|
||||
// litter" finding): a referential refusal must never leave a dead
|
||||
// generated document in the content-addressed store. Defensive doc-shape
|
||||
// checks first (generated docs should always pass this — `translate_sweep`
|
||||
// builds them by construction), then the executable-shape preflight, then
|
||||
// the axis-binding check against the blueprint's own (wrapped) open param
|
||||
// space — the referential shape the dispatch-boundary name preflight
|
||||
// (main.rs) does not catch: a subset of axes that leaves an open param
|
||||
// unbound (probe P3).
|
||||
let doc_faults = aura_research::validate_campaign(&generated.campaign);
|
||||
if !doc_faults.is_empty() {
|
||||
return Err(crate::research_docs::fault_block(
|
||||
"generated campaign document invalid:",
|
||||
doc_faults.iter().map(crate::research_docs::doc_fault_prose).collect(),
|
||||
));
|
||||
}
|
||||
let process_faults = aura_research::validate_process(&generated.process);
|
||||
if !process_faults.is_empty() {
|
||||
return Err(crate::research_docs::fault_block(
|
||||
"generated process document invalid:",
|
||||
process_faults.iter().map(crate::research_docs::doc_fault_prose).collect(),
|
||||
));
|
||||
}
|
||||
aura_campaign::preflight(&generated.process, &generated.campaign)
|
||||
.map_err(|f| crate::campaign_run::exec_fault_prose(&f))?;
|
||||
|
||||
// The pure `bind_axes` seam (campaign_run.rs), reused rather than
|
||||
// re-inlined (#210): checks every generated axis name resolves to exactly
|
||||
// one open slot of the WRAPPED param space and every open slot is
|
||||
// covered. Any one grid value per axis suffices — this checks NAME
|
||||
// coverage/uniqueness, never the bound value.
|
||||
let space = crate::blueprint_axis_probe(blueprint_canonical, env).param_space();
|
||||
let strategy_id = content_id_of(blueprint_canonical);
|
||||
let probe_params: Vec<(String, Scalar)> = axes
|
||||
.iter()
|
||||
.filter_map(|(n, vals)| vals.first().map(|v| (n.clone(), *v)))
|
||||
.collect();
|
||||
crate::campaign_run::bind_axes(&space, &strategy_id, &probe_params)
|
||||
.map_err(|f| crate::campaign_run::exec_fault_prose(&aura_campaign::ExecFault::Member(f)))?;
|
||||
|
||||
let reg = env.registry();
|
||||
let (_process_id, campaign_id) = register_generated(®, &generated)?;
|
||||
crate::campaign_run::run_campaign_by_id(&campaign_id, env, crate::campaign_run::RunPresentation::MemberLinesOnly)
|
||||
|
||||
Reference in New Issue
Block a user