fix(aura-cli): --folds renders the registry roster; op-lists refuse unknown keys

Two fold-selector/op-script surface-honesty bugs from the 2026-07-24
fieldtests, both RED-first.

--folds (#332): graph introspect --folds rendered the aura-std FoldKind
table — capitalized ids (Mean) and no record entry — while aura run
--tap resolves labels against the fold-registry roster (lowercase,
record included). The discovery surface the --tap help points at thus
misdescribed the accepted input twice. It now renders
FoldRegistry::core().roster() (label + doc per entry); the pinned
FoldKind-rendering test was rewritten to pin the roster form (8 rows,
no capitalized id leaks, record present).

Unknown op-list keys (#326): a typo'd key in an op-list element
("params" for "bind") was silently dropped and the wrong graph built
with zero signal, contradicting the closed-vocabulary posture (C25).
OpDoc now carries serde deny_unknown_fields; the refusal names the
offending key. Exit class is 1, not the issue's suggested 2: op-lists
arrive on stdin, and the pinned #175 attribution principle classes
stdin-content faults as runtime — the new refusal fires from that same
deserialize. Blast radius verified: all 21 committed *.ops.json under
fieldtests/ still build cleanly.

closes #332
closes #326
This commit is contained in:
2026-07-24 16:12:12 +02:00
parent 73ad87b08a
commit 8dbca82756
3 changed files with 42 additions and 12 deletions
+9 -4
View File
@@ -48,7 +48,7 @@ Node types and their ports: aura graph introspect --vocabulary | --node <T>"#;
/// values are the typed `Scalar` form (`{"I64":2}`); `kind` the capitalized /// values are the typed `Scalar` form (`{"I64":2}`); `kind` the capitalized
/// `ScalarKind` form (`"F64"`) — both the #155 representations. /// `ScalarKind` form (`"F64"`) — both the #155 representations.
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
#[serde(tag = "op", rename_all = "lowercase")] #[serde(tag = "op", rename_all = "lowercase", deny_unknown_fields)]
enum OpDoc { enum OpDoc {
Source { role: String, kind: ScalarKind }, Source { role: String, kind: ScalarKind },
Input { role: String }, Input { role: String },
@@ -285,9 +285,14 @@ pub fn introspect_cmd(cmd: crate::GraphIntrospectCmd, env: &aura_runner::project
} }
} else if cmd.folds { } else if cmd.folds {
// The fold vocabulary binds at graph-declared taps (C27), so the // The fold vocabulary binds at graph-declared taps (C27), so the
// graph introspect namespace is its discovery surface (#315). // graph introspect namespace is its discovery surface (#315). #332:
for f in aura_std::fold_vocabulary() { // this renders the fold-REGISTRY roster — the same roster `aura run
println!("{:<7} binds {:<9} out {:<9}{}", f.id, f.binds, f.out, f.doc); // --tap TAP=FOLD` resolves labels against (aura-runner's layered
// `FoldRegistry`) — not the aura-std `FoldKind` table: labels here
// must be exactly what `--tap` accepts (lowercase), including the
// `record` entry that has no `FoldKind` counterpart.
for (label, doc) in aura_runner::FoldRegistry::core().roster() {
println!("{label:<7}{doc}");
} }
} else if let Some(type_id) = cmd.node.as_deref() { } else if let Some(type_id) = cmd.node.as_deref() {
match introspect_node(type_id, env) { match introspect_node(type_id, env) {
+15
View File
@@ -354,6 +354,21 @@ fn graph_build_bad_stdin_content_is_runtime_exit_1() {
assert!(stderr.contains("Nope"), "still names the cause: {stderr}"); assert!(stderr.contains("Nope"), "still names the cause: {stderr}");
} }
/// Property (#326): an op-list element carrying an unknown/typo'd key (e.g.
/// `params` where the schema expects `bind`) is refused at parse — not
/// silently dropped. Previously the unrecognized key vanished, the field it
/// meant to set kept its default, and the wrong graph built with zero
/// signal. Same content-fault family as `graph_build_bad_stdin_content_is_
/// runtime_exit_1` (both fire from the same top-level deserialize), so the
/// exit class matches: exit 1, stderr names the offending key.
#[test]
fn graph_build_rejects_an_unknown_op_field() {
let doc = r#"[{"op":"add","type":"Const","params":{"value":{"F64":1.0}}}]"#;
let (stderr, code) = run_code(&["graph", "build"], doc);
assert_eq!(code, Some(1), "unknown op field is a content fault -> exit 1; stderr: {stderr}");
assert!(stderr.contains("params"), "names the unknown key: {stderr}");
}
/// Property (exit-code partition, #175 iteration 2): within the SAME `graph /// Property (exit-code partition, #175 iteration 2): within the SAME `graph
/// introspect` subcommand, an invalid `--node <T>` flag VALUE is a USAGE error (a /// introspect` subcommand, an invalid `--node <T>` flag VALUE is a USAGE error (a
/// command-line fault the user must fix in argv) — exit 2 — distinct from bad stdin /// command-line fault the user must fix in argv) — exit 2 — distinct from bad stdin
+18 -8
View File
@@ -103,18 +103,28 @@ fn graph_introspect_node_head_line_carries_the_meaning() {
assert!(head.contains("moving average"), "head line carries the meaning: {out}"); assert!(head.contains("moving average"), "head line carries the meaning: {out}");
} }
/// #315: the fold vocabulary is introspectable from the binary — name, bind /// #332: `--folds` renders the fold-REGISTRY roster (the same roster
/// rule, output kind, and meaning per fold (it lived only in source comments). /// `aura run --tap TAP=FOLD` resolves labels against), not the aura-std
/// `FoldKind` table — lowercase, parseable labels, including the `record`
/// entry that has no `FoldKind` counterpart. Previously this surface printed
/// capitalized `FoldKind` ids (`Mean`) and omitted `record`, so the help
/// text's own discovery surface described input `--tap` refused.
#[test] #[test]
fn graph_introspect_folds_lists_the_fold_vocabulary() { fn graph_introspect_folds_lists_the_fold_registry_roster() {
let (out, err, ok) = run(&["graph", "introspect", "--folds"]); let (out, err, ok) = run(&["graph", "introspect", "--folds"]);
assert!(ok, "folds listing failed: {err}"); assert!(ok, "folds listing failed: {err}");
let lines: Vec<&str> = out.lines().filter(|l| !l.trim().is_empty()).collect(); let lines: Vec<&str> = out.lines().filter(|l| !l.trim().is_empty()).collect();
assert_eq!(lines.len(), 7, "one line per fold kind: {out}"); assert_eq!(lines.len(), 8, "one line per registry entry (record + 7 folds): {out}");
let mean = lines.iter().find(|l| l.starts_with("Mean")).expect("Mean row"); assert!(
assert!(mean.contains("f64") && mean.contains("mean"), "Mean row rules + meaning: {mean}"); !out.split_whitespace().any(|w| w == "Mean"),
let count = lines.iter().find(|l| l.starts_with("Count")).expect("Count row"); "no capitalized FoldKind id leaks through: {out}"
assert!(count.contains("i64") && count.contains("any"), "Count row rules: {count}"); );
let record = lines.iter().find(|l| l.starts_with("record ")).expect("record row");
assert!(record.contains("series"), "record row meaning: {record}");
let mean = lines.iter().find(|l| l.starts_with("mean ")).expect("mean row");
assert!(mean.contains("f64") && mean.contains("mean"), "mean row rules + meaning: {mean}");
let count = lines.iter().find(|l| l.starts_with("count ")).expect("count row");
assert!(count.contains("i64") && count.contains("any"), "count row rules: {count}");
} }
/// #315: the metric roster carries each metric's one-line meaning beside its /// #315: the metric roster carries each metric's one-line meaning beside its