feat(aura-cli, aura-engine): graph introspect --taps — positive declared-tap discovery

Harvest sweep, batch 3 of 6 (first half).

Composite::declared_taps() walks the blueprint depth-first collecting
(tap name, source wire, column kind), names bare at every depth —
mirroring inline_composite's hoist, where a tap's name IS the flat
graph's load-bearing identifier — and bounds-total over invalid wires
(compile's validate_wiring stays the real gate). The CLI view loads
like --params (FILE gates the authored root name, a content id does
not), prints one 'name  node.field  Kind' row per tap, and renders the
tap-less case as a stderr note with empty stdout, exit 0.

Closes the recovery-only discovery loop: since #333 the only way to
learn a blueprint's declared tap names was provoking the undeclared-tap
refusal roster.

closes #337
This commit is contained in:
2026-07-26 12:10:43 +02:00
parent 521459dd50
commit c39f5e4762
4 changed files with 197 additions and 5 deletions
+44 -5
View File
@@ -589,10 +589,11 @@ fn introspect_registered(env: &aura_runner::project::Env) -> Result<String, Stri
}
/// `aura graph introspect`: dispatch the read-only queries. Exactly one of
/// `--vocabulary` / `--node <T>` / `--unwired` / `--params <FILE|ID>` / 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).
/// `--vocabulary` / `--node <T>` / `--unwired` / `--params <FILE|ID>` /
/// `--taps <FILE|ID>` / 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: &aura_runner::project::Env) {
let count = cmd.vocabulary as usize
+ cmd.node.is_some() as usize
@@ -600,10 +601,11 @@ pub fn introspect_cmd(cmd: crate::GraphIntrospectCmd, env: &aura_runner::project
+ cmd.folds as usize
+ cmd.registered as usize
+ cmd.params.is_some() as usize
+ cmd.taps.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 <T> | --unwired | --folds | --registered | --params <FILE|ID> | --content-id [FILE] | --identity-id (the two id flags may be combined)"
"aura: Usage: aura graph introspect --vocabulary | --node <T> | --unwired | --folds | --registered | --params <FILE|ID> | --taps <FILE|ID> | --content-id [FILE] | --identity-id (the two id flags may be combined)"
);
std::process::exit(2);
}
@@ -675,6 +677,18 @@ pub fn introspect_cmd(cmd: crate::GraphIntrospectCmd, env: &aura_runner::project
std::process::exit(1);
}
}
} else if let Some(target) = cmd.taps.as_deref() {
// --taps <FILE|ID> (#337): the positive declared-tap discovery view —
// #333 shipped only the refusal roster (`bind_tap_plan`'s
// `UnknownTap`), so provoking that refusal was the only way to learn
// a blueprint's declared tap names.
match taps_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
@@ -997,6 +1011,31 @@ fn params_lines(target: &str, env: &aura_runner::project::Env) -> Result<String,
Ok(out)
}
/// `aura graph introspect --taps <FILE|ID>` (#337): one row per declared tap
/// — `<name> <node>.<field> <kind:?>` — the positive discovery view #333's
/// refusal roster (`bind_tap_plan`'s `UnknownTap`) deliberately deferred:
/// before this, the only way to learn a blueprint's declared tap names was
/// provoking that refusal. Loads exactly like `--params` (FILE gates the
/// authored root name, a store content id does not, C29). A tap-less
/// blueprint prints nothing to stdout — nothing was declared to list — plus a
/// stderr note (the `bind_tap_plan` unbound-this-run note's own "aura: note:"
/// convention), exit 0: a listing, not a fault.
fn taps_lines(target: &str, env: &aura_runner::project::Env) -> Result<String, String> {
use std::fmt::Write as _;
let (text, is_file) = resolve_blueprint_text(target, env)?;
let composite = if is_file { composite_from_authored_text(&text, env)? } else { composite_from_any(&text, env)? };
let taps = composite.declared_taps();
if taps.is_empty() {
eprintln!("aura: note: {target} declares no taps");
return Ok(String::new());
}
let mut out = String::new();
for (name, wire, kind) in taps {
let _ = writeln!(out, "{name} {wire} {kind:?}");
}
Ok(out)
}
/// `aura graph register <blueprint.json> [--name <label>]` (#196, `--name`
/// #317): parse the blueprint through the project vocabulary, canonicalize,
/// content-address, and store — the `process register` pattern, printing
+6
View File
@@ -911,6 +911,12 @@ struct GraphIntrospectCmd {
/// name:kind line per open param. FILE path or 64-hex store content id (#196).
#[arg(long, value_name = "FILE|ID")]
params: Option<String>,
/// List a blueprint's declared taps, one row per tap (#337): name, source
/// wire (`node.field`), column kind. FILE path or 64-hex store content id
/// (#196), like `--params`. The positive twin of the undeclared-tap
/// refusal roster (#333).
#[arg(long, value_name = "FILE|ID")]
taps: Option<String>,
}
#[derive(Args)]
+48
View File
@@ -832,6 +832,54 @@ fn graph_params_tolerates_content_prefix_on_target() {
);
}
/// Property (#337): `--taps <FILE>` is the positive declared-tap discovery
/// view — before this, the only way to learn a blueprint's declared tap names
/// was provoking #333's undeclared-tap refusal roster. One row per declared
/// tap: name, source wire (`node.field`), column kind, in declaration order.
#[test]
fn graph_introspect_taps_lists_declared_taps_wire_and_kind() {
let dir = temp_cwd("taps-listed");
let ops = r#"[
{"op":"source","role":"price","kind":"F64"},
{"op":"add","type":"SMA","name":"fast","bind":{"length":{"I64":2}}},
{"op":"add","type":"SMA","name":"slow","bind":{"length":{"I64":4}}},
{"op":"add","type":"Sub"},
{"op":"feed","role":"price","into":["fast.series","slow.series"]},
{"op":"connect","from":"fast.value","to":"sub.lhs"},
{"op":"connect","from":"slow.value","to":"sub.rhs"},
{"op":"tap","from":"fast.value","as":"fast_ma"},
{"op":"tap","from":"sub.value","as":"spread"},
{"op":"expose","from":"sub.value","as":"bias"}
]"#;
let (build_out, build_err, build_code) = run_in_stdin(&dir, &["graph", "build"], ops);
assert_eq!(build_code, Some(0), "graph build: {build_out} {build_err}");
let bp = dir.join("tapped.json");
std::fs::write(&bp, &build_out).expect("write built blueprint");
let (stdout, stderr, code) =
run_in(&dir, &["graph", "introspect", "--taps", bp.to_str().unwrap()]);
assert_eq!(code, Some(0), "stdout: {stdout} stderr: {stderr}");
assert_eq!(
stdout, "fast_ma fast.value F64\nspread sub.value F64\n",
"one row per declared tap: name, source wire, column kind"
);
}
/// Property (#337): a blueprint with no declared taps prints nothing to
/// stdout (nothing was declared to list) plus a one-line stderr note — a
/// listing, not a fault, exit 0.
#[test]
fn graph_introspect_taps_reports_no_taps_on_stderr_when_none_declared() {
let dir = temp_cwd("taps-empty");
let bp = fixture("r_sma_open.json");
let (stdout, stderr, code) = run_in(&dir, &["graph", "introspect", "--taps", &bp]);
assert_eq!(code, Some(0), "a tap-less blueprint is a listing, not a fault: {stderr}");
assert_eq!(stdout, "", "no declared taps to list: {stdout}");
assert!(
stderr.contains("declares no taps"),
"stderr carries the one-line note: {stderr}"
);
}
/// 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