plan: 0088 construction op-script — iteration 2 (§C CLI shell)
Iteration-2 tasks (9-12) appended to the cycle-0088 plan: the JSON op-list serde DTO (CLI-side, engine Op stays serde-free), `aura graph build` (replay -> emit #155 / fail fast at op N), `aura graph introspect` (--vocabulary / --node / --unwired), and an E2E suite driving the aura binary. All changes in aura-cli; the engine core (ea1ca32+27ac4dc) is consumed unchanged. Wire-format forks derived + recorded on #157. refs #157
This commit is contained in:
@@ -1211,3 +1211,589 @@ Expected: clean (no warnings).
|
||||
`builder_harness_compiles_identically_to_hand_wired` (all confirmed present in
|
||||
the tree this session). The full-workspace gate (Task 8) is unfiltered with a
|
||||
no-regression expectation. ✓
|
||||
|
||||
---
|
||||
|
||||
# Iteration 2 — §C CLI shell (Tasks 9-12)
|
||||
|
||||
**Goal:** the JSON op-list surface over the iteration-1 engine core — `aura
|
||||
graph build` (replay a document → emit the #155 blueprint, or fail fast at the
|
||||
op) and `aura graph introspect` (vocabulary / a node's ports+kinds / a partial
|
||||
document's unwired slots), build-free.
|
||||
|
||||
**Architecture:** all changes are in `aura-cli` — the engine `Op` stays
|
||||
serde-free. A CLI-side serde DTO (`OpDoc`, `#[serde(tag="op",
|
||||
rename_all="lowercase")]`) deserializes the document and maps into the engine
|
||||
`Op`; the CLI retains the parsed list to recover the op-kind label for the
|
||||
`op N (kind): cause` message; a CLI format helper phrases each `OpError` (the
|
||||
engine error types stay `Display`-free). Bind values use the typed `Scalar` form
|
||||
(`{"I64":2}`) and `ScalarKind` its capitalized #155 form (`"F64"`).
|
||||
|
||||
**Tech Stack:** `crates/aura-cli/src/main.rs` (argv arms), new
|
||||
`crates/aura-cli/src/graph_construct.rs` (DTO + subcommand cores), new
|
||||
`crates/aura-cli/tests/graph_construct.rs` (E2E). `serde`/`serde_json`,
|
||||
`aura-engine`, `aura-std`, `aura-core` are already deps.
|
||||
|
||||
**Files this iteration creates or modifies:**
|
||||
|
||||
- Create: `crates/aura-cli/src/graph_construct.rs` — the `OpDoc` DTO, the
|
||||
`build`/`introspect` subcommand cores, the error formatter.
|
||||
- Modify: `crates/aura-cli/src/main.rs` — `mod graph_construct;` + two argv arms.
|
||||
- Test: `crates/aura-cli/tests/graph_construct.rs` — E2E driving the `aura` binary.
|
||||
|
||||
> Fork decisions for this iteration are derived + recorded on #157 (the
|
||||
> wire-format comment): CLI-side DTO (engine stays serde-free); typed `Scalar`
|
||||
> binds; capitalized `ScalarKind`; op-kind label via the retained parsed list;
|
||||
> CLI-side error phrasing.
|
||||
|
||||
### Task 9: the `OpDoc` serde DTO + mapping into the engine `Op`
|
||||
|
||||
**Files:**
|
||||
- Create: `crates/aura-cli/src/graph_construct.rs`
|
||||
- Modify: `crates/aura-cli/src/main.rs` (add `mod graph_construct;`)
|
||||
|
||||
- [ ] **Step 1: Create the module with the DTO, the mapping, and a test**
|
||||
|
||||
Create `crates/aura-cli/src/graph_construct.rs`:
|
||||
|
||||
```rust
|
||||
//! The `aura graph build` / `aura graph introspect` CLI surface (#157, §C): a
|
||||
//! JSON op-list document is deserialized into a CLI-side `OpDoc` (the engine
|
||||
//! `Op` stays serde-free), mapped into `aura_engine::Op`, and replayed through
|
||||
//! the injected `aura_std::std_vocabulary`. The build path emits the #155
|
||||
//! blueprint or fails fast at the offending op; introspection answers
|
||||
//! build-free.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use aura_engine::{blueprint_to_json, replay, GraphSession, Op, OpError, Scalar, ScalarKind};
|
||||
use aura_std::{std_vocabulary, std_vocabulary_types};
|
||||
use serde::Deserialize;
|
||||
|
||||
/// The wire DTO for one construction op — the document's by-identifier shape,
|
||||
/// internally tagged by `"op"`, mapped into the serde-free engine `Op`. Bind
|
||||
/// values are the typed `Scalar` form (`{"I64":2}`); `kind` the capitalized
|
||||
/// `ScalarKind` form (`"F64"`) — both the #155 representations.
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(tag = "op", rename_all = "lowercase")]
|
||||
enum OpDoc {
|
||||
Source { role: String, kind: ScalarKind },
|
||||
Input { role: String },
|
||||
Add {
|
||||
#[serde(rename = "type")]
|
||||
type_id: String,
|
||||
#[serde(rename = "as", default)]
|
||||
as_name: Option<String>,
|
||||
#[serde(default)]
|
||||
bind: BTreeMap<String, Scalar>,
|
||||
},
|
||||
Feed { role: String, into: Vec<String> },
|
||||
Connect { from: String, to: String },
|
||||
Expose {
|
||||
from: String,
|
||||
#[serde(rename = "as")]
|
||||
as_name: String,
|
||||
},
|
||||
}
|
||||
|
||||
impl OpDoc {
|
||||
/// The op-kind label for the `op N (kind): cause` message.
|
||||
fn kind_label(&self) -> &'static str {
|
||||
match self {
|
||||
OpDoc::Source { .. } => "source",
|
||||
OpDoc::Input { .. } => "input",
|
||||
OpDoc::Add { .. } => "add",
|
||||
OpDoc::Feed { .. } => "feed",
|
||||
OpDoc::Connect { .. } => "connect",
|
||||
OpDoc::Expose { .. } => "expose",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<OpDoc> for Op {
|
||||
fn from(d: OpDoc) -> Op {
|
||||
match d {
|
||||
OpDoc::Source { role, kind } => Op::Source { role, kind },
|
||||
OpDoc::Input { role } => Op::Input { role },
|
||||
OpDoc::Add { type_id, as_name, bind } => {
|
||||
Op::Add { type_id, as_name, bind: bind.into_iter().collect() }
|
||||
}
|
||||
OpDoc::Feed { role, into } => Op::Feed { role, into },
|
||||
OpDoc::Connect { from, to } => Op::Connect { from, to },
|
||||
OpDoc::Expose { from, as_name } => Op::Expose { from, as_name },
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The spec's worked-example document deserializes into the op vocabulary and
|
||||
/// maps into engine `Op`s that replay into a compilable signal blueprint —
|
||||
/// the DTO is a faithful front-end over the engine surface.
|
||||
#[test]
|
||||
fn opdoc_document_deserializes_and_replays() {
|
||||
let doc = r#"[
|
||||
{"op":"source","role":"price","kind":"F64"},
|
||||
{"op":"add","type":"SMA","as":"fast","bind":{"length":{"I64":2}}},
|
||||
{"op":"add","type":"SMA","as":"slow","bind":{"length":{"I64":4}}},
|
||||
{"op":"add","type":"Sub"},
|
||||
{"op":"add","type":"Bias"},
|
||||
{"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":"connect","from":"sub.value","to":"bias.signal"},
|
||||
{"op":"expose","from":"bias.bias","as":"bias"}
|
||||
]"#;
|
||||
let docs: Vec<OpDoc> = serde_json::from_str(doc).expect("document parses");
|
||||
assert_eq!(docs.len(), 10);
|
||||
assert_eq!(docs[1].kind_label(), "add");
|
||||
let ops: Vec<Op> = docs.into_iter().map(Op::from).collect();
|
||||
// both SMA lengths are bound, so the only open param is bias.scale (f64).
|
||||
let composite = replay("graph", ops, &std_vocabulary).expect("replay resolves");
|
||||
composite.compile_with_params(&[Scalar::f64(0.5)]).expect("compiles");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Add `mod graph_construct;` to `crates/aura-cli/src/main.rs` — place it with the
|
||||
other `mod` declarations near the top (find them with `grep -n "^mod \|^use "
|
||||
crates/aura-cli/src/main.rs | head`).
|
||||
|
||||
- [ ] **Step 2: Run to verify it fails, then passes**
|
||||
|
||||
Run: `cargo test -p aura-cli opdoc_document_deserializes_and_replays`
|
||||
Expected: first FAIL (module/symbols absent or test red), then PASS after Step 1
|
||||
lands. (The single test in this task is created and satisfied by Step 1's code.)
|
||||
|
||||
> If `compile_with_params` rejects on param arity, the bound-length assumption is
|
||||
> wrong — print `replay(...).unwrap().param_space()` to read the real space and
|
||||
> adjust the bound `params` vector (the spec's iteration-1 acceptance test
|
||||
> confirmed bias carries one f64 `scale` param).
|
||||
|
||||
### Task 10: `aura graph build` — replay a document, emit or fail fast
|
||||
|
||||
**Files:**
|
||||
- Modify: `crates/aura-cli/src/graph_construct.rs` (add `build_from_str`,
|
||||
`format_op_error`, `build_cmd`)
|
||||
- Modify: `crates/aura-cli/src/main.rs` (add the `["graph","build"]` arm)
|
||||
|
||||
- [ ] **Step 1: Write the failing tests**
|
||||
|
||||
Add to `graph_construct.rs`'s `mod tests`:
|
||||
|
||||
```rust
|
||||
#[test]
|
||||
fn build_from_str_emits_blueprint_json_for_valid_document() {
|
||||
let doc = r#"[
|
||||
{"op":"source","role":"price","kind":"F64"},
|
||||
{"op":"add","type":"SMA","as":"fast","bind":{"length":{"I64":2}}},
|
||||
{"op":"add","type":"SMA","as":"slow","bind":{"length":{"I64":4}}},
|
||||
{"op":"add","type":"Sub"},
|
||||
{"op":"add","type":"Bias"},
|
||||
{"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":"connect","from":"sub.value","to":"bias.signal"},
|
||||
{"op":"expose","from":"bias.bias","as":"bias"}
|
||||
]"#;
|
||||
let json = super::build_from_str(doc).expect("valid document builds");
|
||||
assert!(json.contains("\"format_version\":1"), "emits the #155 envelope: {json}");
|
||||
assert!(json.contains("\"SMA\""), "carries the SMA nodes: {json}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_from_str_reports_unknown_node_at_its_op_index() {
|
||||
let doc = r#"[{"op":"add","type":"SMA","as":"fast"},{"op":"add","type":"Nope"}]"#;
|
||||
let err = super::build_from_str(doc).unwrap_err();
|
||||
assert_eq!(err, "op 1 (add): unknown node type \"Nope\"");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_from_str_reports_unconnected_slot_at_finalize() {
|
||||
// sub.rhs left unwired -> the holistic 0-arm fires at the finalize step.
|
||||
let doc = r#"[
|
||||
{"op":"source","role":"price","kind":"F64"},
|
||||
{"op":"add","type":"SMA","as":"fast"},
|
||||
{"op":"add","type":"SMA","as":"slow"},
|
||||
{"op":"add","type":"Sub"},
|
||||
{"op":"feed","role":"price","into":["fast.series","slow.series"]},
|
||||
{"op":"connect","from":"fast.value","to":"sub.lhs"}
|
||||
]"#;
|
||||
let err = super::build_from_str(doc).unwrap_err();
|
||||
assert!(err.starts_with("finalize: "), "finalize fault, got: {err}");
|
||||
assert!(err.contains("Unconnected"), "names the unconnected slot, got: {err}");
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run to verify they fail**
|
||||
|
||||
Run: `cargo test -p aura-cli build_from_str_emits_blueprint_json_for_valid_document`
|
||||
Expected: FAIL — compile error `cannot find function `build_from_str``.
|
||||
|
||||
- [ ] **Step 3: Implement `build_from_str`, `format_op_error`, `build_cmd`**
|
||||
|
||||
Add to `graph_construct.rs` (module scope, after the `OpDoc` impls):
|
||||
|
||||
```rust
|
||||
/// Phrase one `OpError` as a human cause (the engine error types are
|
||||
/// `Display`-free by convention; this is the CLI's presentation layer). The
|
||||
/// exhaustive match means a new `OpError` variant forces an update here.
|
||||
fn format_op_error(e: &OpError) -> String {
|
||||
match e {
|
||||
OpError::UnknownNodeType(t) => format!("unknown node type {t:?}"),
|
||||
OpError::DuplicateIdentifier(s) => format!("duplicate identifier {s:?}"),
|
||||
OpError::DuplicateOutput(s) => format!("duplicate output name {s:?}"),
|
||||
OpError::DuplicateRole(s) => format!("duplicate role {s:?}"),
|
||||
OpError::UnknownIdentifier(s) => format!("unknown node identifier {s:?}"),
|
||||
OpError::UnknownRole(s) => format!("unknown role {s:?}"),
|
||||
OpError::MalformedPort(s) => format!("malformed port reference {s:?}"),
|
||||
OpError::UnknownInPort { node, name } => format!("node {node:?} has no input port {name:?}"),
|
||||
OpError::AmbiguousInPort { node, name } => format!("node {node:?} has ambiguous input port {name:?}"),
|
||||
OpError::UnknownOutPort { node, name } => format!("node {node:?} has no output field {name:?}"),
|
||||
OpError::AmbiguousOutPort { node, name } => format!("node {node:?} has ambiguous output field {name:?}"),
|
||||
OpError::KindMismatch { from, to, producer, consumer } => {
|
||||
format!("kind mismatch {from} -> {to} (producer {producer:?}, consumer {consumer:?})")
|
||||
}
|
||||
OpError::SlotAlreadyWired { node, slot } => format!("slot {node}.{slot} already wired"),
|
||||
OpError::BadParam { node, err } => format!("bad param on {node}: {err:?}"),
|
||||
OpError::Incomplete(ce) => format!("{ce:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a JSON op-list document, replay it through `std_vocabulary`, and return
|
||||
/// the emitted #155 blueprint JSON — or a `op N (kind): cause` message (a per-op
|
||||
/// fault, attributed by the retained op-kind list) / `finalize: cause` (a
|
||||
/// holistic fault past the last op).
|
||||
pub fn build_from_str(doc: &str) -> Result<String, String> {
|
||||
let docs: Vec<OpDoc> = serde_json::from_str(doc).map_err(|e| format!("invalid op-list: {e}"))?;
|
||||
let labels: Vec<&'static str> = docs.iter().map(OpDoc::kind_label).collect();
|
||||
let ops: Vec<Op> = docs.into_iter().map(Op::from).collect();
|
||||
match replay("graph", ops, &std_vocabulary) {
|
||||
Ok(composite) => blueprint_to_json(&composite).map_err(|e| format!("serialize error: {e:?}")),
|
||||
Err((idx, err)) => {
|
||||
let cause = format_op_error(&err);
|
||||
match labels.get(idx) {
|
||||
Some(kind) => Err(format!("op {idx} ({kind}): {cause}")),
|
||||
None => Err(format!("finalize: {cause}")),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `aura graph build`: read the op-list document from stdin, build, and print the
|
||||
/// blueprint to stdout — or the cause to stderr and exit non-zero.
|
||||
pub fn build_cmd() {
|
||||
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(2);
|
||||
}
|
||||
match build_from_str(&doc) {
|
||||
Ok(json) => println!("{json}"),
|
||||
Err(msg) => {
|
||||
eprintln!("aura: {msg}");
|
||||
std::process::exit(2);
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
In `crates/aura-cli/src/main.rs`, add the arm to the argv `match` (right after
|
||||
the bare `["graph"] => ...` arm at `:3313`):
|
||||
|
||||
```rust
|
||||
["graph", "build"] => graph_construct::build_cmd(),
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run the build tests**
|
||||
|
||||
Run: `cargo test -p aura-cli build_from_str_emits_blueprint_json_for_valid_document`
|
||||
Expected: PASS.
|
||||
|
||||
Run: `cargo test -p aura-cli build_from_str_reports_unknown_node_at_its_op_index`
|
||||
Expected: PASS.
|
||||
|
||||
Run: `cargo test -p aura-cli build_from_str_reports_unconnected_slot_at_finalize`
|
||||
Expected: PASS.
|
||||
|
||||
### Task 11: `aura graph introspect` — vocabulary / node / unwired
|
||||
|
||||
**Files:**
|
||||
- Modify: `crates/aura-cli/src/graph_construct.rs` (add `introspect_node`,
|
||||
`introspect_unwired`, `introspect_cmd`)
|
||||
- Modify: `crates/aura-cli/src/main.rs` (add the `["graph","introspect", ..]` arm)
|
||||
|
||||
- [ ] **Step 1: Write the failing tests**
|
||||
|
||||
Add to `graph_construct.rs`'s `mod tests`:
|
||||
|
||||
```rust
|
||||
#[test]
|
||||
fn introspect_node_lists_ports_kinds_and_params() {
|
||||
let out = super::introspect_node("SMA").expect("SMA is in the vocabulary");
|
||||
assert!(out.contains("series"), "lists the input port: {out}");
|
||||
assert!(out.contains("value"), "lists the output field: {out}");
|
||||
assert!(out.contains("length"), "lists the param path: {out}");
|
||||
assert!(super::introspect_node("Nope").is_err(), "rejects an unknown type");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn introspect_unwired_reports_open_slots_of_a_partial_document() {
|
||||
let doc = r#"[
|
||||
{"op":"add","type":"SMA","as":"fast"},
|
||||
{"op":"add","type":"Sub"},
|
||||
{"op":"connect","from":"fast.value","to":"sub.lhs"}
|
||||
]"#;
|
||||
let out = super::introspect_unwired(doc).expect("partial document introspects");
|
||||
assert!(out.contains("sub.rhs"), "sub.rhs is still open: {out}");
|
||||
assert!(out.contains("fast.series"), "fast.series is still open: {out}");
|
||||
assert!(!out.contains("sub.lhs"), "sub.lhs is covered: {out}");
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run to verify they fail**
|
||||
|
||||
Run: `cargo test -p aura-cli introspect_node_lists_ports_kinds_and_params`
|
||||
Expected: FAIL — compile error `cannot find function `introspect_node``.
|
||||
|
||||
- [ ] **Step 3: Implement the introspection cores + `introspect_cmd`**
|
||||
|
||||
Add to `graph_construct.rs`:
|
||||
|
||||
```rust
|
||||
/// `aura graph introspect --node <T>`: a type's ports + kinds + param paths,
|
||||
/// read off the pre-build schema (no graph built). `Err` if `T` is not in the
|
||||
/// closed vocabulary.
|
||||
pub fn introspect_node(type_id: &str) -> Result<String, String> {
|
||||
let builder = std_vocabulary(type_id).ok_or_else(|| format!("unknown node type {type_id:?}"))?;
|
||||
let schema = builder.schema();
|
||||
let mut out = format!("{}\n", builder.label());
|
||||
for port in &schema.inputs {
|
||||
out.push_str(&format!(" in {}:{:?}\n", port.name, port.kind));
|
||||
}
|
||||
for field in &schema.output {
|
||||
out.push_str(&format!(" out {}:{:?}\n", field.name, field.kind));
|
||||
}
|
||||
for p in builder.params() {
|
||||
out.push_str(&format!(" param {}:{:?}\n", p.name, p.kind));
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// `aura graph introspect --unwired`: the still-open interior slots of a partial
|
||||
/// op-list document, by-identifier (applies the ops, does NOT finalize).
|
||||
pub fn introspect_unwired(doc: &str) -> Result<String, String> {
|
||||
let docs: Vec<OpDoc> = serde_json::from_str(doc).map_err(|e| format!("invalid op-list: {e}"))?;
|
||||
let mut session = GraphSession::new("introspect", &std_vocabulary);
|
||||
for (i, d) in docs.into_iter().enumerate() {
|
||||
session.apply(Op::from(d)).map_err(|e| format!("op {i}: {}", format_op_error(&e)))?;
|
||||
}
|
||||
let mut out = String::new();
|
||||
for (slot, kind) in session.unwired() {
|
||||
out.push_str(&format!("{slot}:{kind:?}\n"));
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// `aura graph introspect`: dispatch the three read-only queries.
|
||||
pub fn introspect_cmd(rest: &[&str]) {
|
||||
match rest {
|
||||
["--vocabulary"] => {
|
||||
for t in std_vocabulary_types() {
|
||||
println!("{t}");
|
||||
}
|
||||
}
|
||||
["--node", type_id] => match introspect_node(type_id) {
|
||||
Ok(s) => print!("{s}"),
|
||||
Err(m) => {
|
||||
eprintln!("aura: {m}");
|
||||
std::process::exit(2);
|
||||
}
|
||||
},
|
||||
["--unwired"] => {
|
||||
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(2);
|
||||
}
|
||||
match introspect_unwired(&doc) {
|
||||
Ok(s) => print!("{s}"),
|
||||
Err(m) => {
|
||||
eprintln!("aura: {m}");
|
||||
std::process::exit(2);
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
eprintln!("aura: usage: aura graph introspect --vocabulary | --node <T> | --unwired");
|
||||
std::process::exit(2);
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
In `crates/aura-cli/src/main.rs`, add the arm right after the
|
||||
`["graph", "build"]` arm:
|
||||
|
||||
```rust
|
||||
["graph", "introspect", rest @ ..] => graph_construct::introspect_cmd(rest),
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run the introspection tests**
|
||||
|
||||
Run: `cargo test -p aura-cli introspect_node_lists_ports_kinds_and_params`
|
||||
Expected: PASS.
|
||||
|
||||
Run: `cargo test -p aura-cli introspect_unwired_reports_open_slots_of_a_partial_document`
|
||||
Expected: PASS.
|
||||
|
||||
### Task 12: E2E — drive the `aura` binary end-to-end
|
||||
|
||||
**Files:**
|
||||
- Create: `crates/aura-cli/tests/graph_construct.rs`
|
||||
|
||||
- [ ] **Step 1: Write the E2E integration test**
|
||||
|
||||
Create `crates/aura-cli/tests/graph_construct.rs` (models the binary-driving
|
||||
pattern of `crates/aura-cli/tests/cli_run.rs` + the stdin-piping of
|
||||
`crates/aura-cli/tests/cli_broken_pipe.rs`):
|
||||
|
||||
```rust
|
||||
//! End-to-end coverage for the `aura graph build` / `aura graph introspect`
|
||||
//! op-script CLI (#157, §C): drives the built `aura` binary over stdin/argv,
|
||||
//! the exact surface a data-level author uses.
|
||||
|
||||
use std::io::Write;
|
||||
use std::process::{Command, Stdio};
|
||||
|
||||
const BIN: &str = env!("CARGO_BIN_EXE_aura");
|
||||
|
||||
/// Run `aura <args>` with `stdin_doc` piped in; return (stdout, stderr, success).
|
||||
fn run(args: &[&str], stdin_doc: &str) -> (String, String, bool) {
|
||||
let mut child = Command::new(BIN)
|
||||
.args(args)
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.expect("spawn aura");
|
||||
child.stdin.take().unwrap().write_all(stdin_doc.as_bytes()).unwrap();
|
||||
let out = child.wait_with_output().expect("wait aura");
|
||||
(
|
||||
String::from_utf8_lossy(&out.stdout).into_owned(),
|
||||
String::from_utf8_lossy(&out.stderr).into_owned(),
|
||||
out.status.success(),
|
||||
)
|
||||
}
|
||||
|
||||
const SIGNAL_DOC: &str = r#"[
|
||||
{"op":"source","role":"price","kind":"F64"},
|
||||
{"op":"add","type":"SMA","as":"fast","bind":{"length":{"I64":2}}},
|
||||
{"op":"add","type":"SMA","as":"slow","bind":{"length":{"I64":4}}},
|
||||
{"op":"add","type":"Sub"},
|
||||
{"op":"add","type":"Bias"},
|
||||
{"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":"connect","from":"sub.value","to":"bias.signal"},
|
||||
{"op":"expose","from":"bias.bias","as":"bias"}
|
||||
]"#;
|
||||
|
||||
#[test]
|
||||
fn graph_build_emits_blueprint_for_a_valid_document() {
|
||||
let (stdout, _stderr, ok) = run(&["graph", "build"], SIGNAL_DOC);
|
||||
assert!(ok, "exit success");
|
||||
assert!(stdout.contains("\"format_version\":1"), "emits the #155 envelope: {stdout}");
|
||||
assert!(stdout.contains("\"SMA\""), "carries the nodes: {stdout}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn graph_build_fails_fast_at_the_offending_op() {
|
||||
let doc = r#"[{"op":"add","type":"SMA","as":"fast"},{"op":"add","type":"Nope"}]"#;
|
||||
let (stdout, stderr, ok) = run(&["graph", "build"], doc);
|
||||
assert!(!ok, "non-zero exit");
|
||||
assert!(stdout.is_empty(), "no blueprint emitted on error: {stdout}");
|
||||
assert!(stderr.contains("op 1 (add)"), "names the failing op: {stderr}");
|
||||
assert!(stderr.contains("Nope"), "names the cause: {stderr}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn graph_introspect_vocabulary_lists_the_node_types() {
|
||||
let (stdout, _stderr, ok) = run(&["graph", "introspect", "--vocabulary"], "");
|
||||
assert!(ok, "exit success");
|
||||
assert!(stdout.contains("SMA"), "lists SMA: {stdout}");
|
||||
assert!(stdout.contains("Bias"), "lists Bias: {stdout}");
|
||||
assert!(!stdout.contains("Recorder"), "a sink is not in the zero-arg vocabulary: {stdout}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn graph_introspect_node_answers_ports_without_a_build() {
|
||||
let (stdout, _stderr, ok) = run(&["graph", "introspect", "--node", "SMA"], "");
|
||||
assert!(ok, "exit success");
|
||||
assert!(stdout.contains("series"), "lists the input port: {stdout}");
|
||||
assert!(stdout.contains("value"), "lists the output field: {stdout}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn graph_introspect_unwired_reports_open_slots() {
|
||||
let doc = r#"[
|
||||
{"op":"add","type":"SMA","as":"fast"},
|
||||
{"op":"add","type":"Sub"},
|
||||
{"op":"connect","from":"fast.value","to":"sub.lhs"}
|
||||
]"#;
|
||||
let (stdout, _stderr, ok) = run(&["graph", "introspect", "--unwired"], doc);
|
||||
assert!(ok, "exit success");
|
||||
assert!(stdout.contains("sub.rhs"), "sub.rhs still open: {stdout}");
|
||||
assert!(!stdout.contains("sub.lhs"), "sub.lhs is covered: {stdout}");
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the E2E suite**
|
||||
|
||||
Run: `cargo test -p aura-cli --test graph_construct`
|
||||
Expected: PASS (5 tests).
|
||||
|
||||
- [ ] **Step 3: Full workspace gates**
|
||||
|
||||
Run: `cargo build --workspace`
|
||||
Expected: clean build.
|
||||
|
||||
Run: `cargo test --workspace`
|
||||
Expected: all pass (iteration-1 tests + the new aura-cli tests; no regressions).
|
||||
|
||||
Run: `cargo clippy --workspace --all-targets -- -D warnings`
|
||||
Expected: clean.
|
||||
|
||||
## Self-review (iteration 2, planner Step 5)
|
||||
|
||||
1. **Spec coverage:** §C maps to: DTO + serde encoding → Task 9; `aura graph
|
||||
build` (replay → emit / fail-fast) → Task 10; `aura graph introspect`
|
||||
(`--vocabulary` / `--node` / `--unwired`) → Task 11; E2E → Task 12. The spec's
|
||||
three acceptance criteria are now demonstrated through the CLI (Task 12). ✓
|
||||
2. **Placeholder scan:** no "TBD"/"TODO"/"implement later"/"similar to"/"add
|
||||
appropriate". ✓
|
||||
3. **Type consistency:** `OpDoc`, `build_from_str`, `format_op_error`,
|
||||
`build_cmd`, `introspect_node`, `introspect_unwired`, `introspect_cmd`,
|
||||
`graph_construct` — identical across the tasks and the `main.rs` arms. ✓
|
||||
4. **Step granularity:** each step is one test, one impl block, or one run. ✓
|
||||
5. **No commit steps:** none. ✓
|
||||
6. **Pin/replacement contiguity:** the E2E pins (`"op 1 (add)"`,
|
||||
`"\"format_version\":1"`) are matched against the exact strings
|
||||
`build_from_str` and `blueprint_to_json` emit — `op {idx} ({kind})` with
|
||||
idx=1/kind="add", and the #155 envelope `blueprint_serde` already emits
|
||||
(iteration-1 golden). No soft-wrap split. ✓
|
||||
7. **Compile-gate vs deferred-caller ordering:** each argv arm is added in the
|
||||
same task as the handler fn it calls (Task 10 adds the `build` arm + `build_cmd`;
|
||||
Task 11 adds the `introspect` arm + `introspect_cmd`), so every task's tree
|
||||
compiles. No signature change to an existing fn (the engine is untouched; the
|
||||
DTO + cores are net-new). ✓
|
||||
8. **Verification-filter strings resolve:** every Run filter names a test created
|
||||
in this iteration (Tasks 9-12) — `opdoc_document_deserializes_and_replays`,
|
||||
`build_from_str_*`, `introspect_*`, and the `--test graph_construct` target.
|
||||
The full-workspace gate (Task 12) is unfiltered with a no-regression
|
||||
expectation. The engine `Op`/`replay`/`blueprint_to_json` symbols the tests
|
||||
import were all confirmed exported this session (commit `27ac4dc`). ✓
|
||||
|
||||
Reference in New Issue
Block a user