diff --git a/TODO/TODO_codegen_dag.md b/TODO/TODO_codegen_dag.md new file mode 100644 index 00000000000..9e7164a64c6 --- /dev/null +++ b/TODO/TODO_codegen_dag.md @@ -0,0 +1,240 @@ +# Codegen-on-DAG Migration + +**Status**: Design +**Date**: 2026-02-03 + +## Problem + +The current pipeline is structured everywhere except the last step: + +``` +DAG definition (structured, typed) + → DagAnalysis (structured — ports, edges, cardinalities) + → ObligationSet (structured — 4 buckets, proof statuses) + → Phase generators (structured — 7 phases, clear responsibilities) + → format!() strings ← cliff + → Generated Rust source +``` + +`codegen.rs` (2500 lines) converts structured obligation/analysis data +into generated test code via raw `format!()` string interpolation. This +is where every testgen hack lives: `value_to_rust_literal`'s catch-all +arm, the `Satisfies` comment-not-assertion, the `List` `filter_map` that +drops non-string elements. The compiler can't enforce exhaustiveness on +string templates, and the entire emission layer is hardcoded to Rust +syntax. + +Additionally, there are two parallel functions doing the same thing: +`value_to_rust_literal()` in codegen.rs and `value_to_code()` in +mock_spec.rs. Both had silent catch-all arms. Both need the same +fix. This duplication is a symptom of having no shared value-rendering +abstraction. + +## Observation + +The codebase already models types as DAGs (`Dag`), patterns as +DAGs (`Dag`), and languages as DAGs (`Dag`). Code +emission is the one pipeline stage that breaks out of this model into +ad-hoc strings. + +## Design: a minimal cross-language expression language + +The goal is not just a codegen refactor — it's to define a small language +of operations that every target language (Rust, Python, TypeScript, etc.) +can express. Each operation should be simple enough that its semantics are +unambiguous across languages. Operations that require careful per-language +negotiation (e.g., integer overflow, string encoding, struct construction) +should be explicit so they can be litigated once per backend. + +### Type language (proto-like, minimal) + +The type language is the intersection of what every target language can +express without ambiguity. Think protobuf — deliberately small, maps +cleanly everywhere. Complexity goes into backends, not into the core. + +**In scope:** + +| Type | Rationale | +|------|-----------| +| `bool` | Universal. | +| `i64` | Single integer type. No unsigned — it's implementable per-backend but adds complexity with zero benefit to test code. | +| `string` | Universal. | +| `json` | Escape hatch for unstructured data. Render as `serde_json::json!()` / dict literal / plain object. Keep as explicit variant rather than decomposing into primitives. | +| `list(T)` | Homogeneous ordered collection. | +| `map(string, T)` | Dynamic string-keyed collection (proto `map`). | +| `struct { name, fields }` | Named product type with statically known fields. Distinct from map — backends render as Rust struct / Python dataclass / TS interface. | +| `unit` | Absence of data — null/None/undefined/Unit. | + +**Out of scope (and why):** + +- **Unsigned integers** — Python doesn't distinguish, JS numbers are + floats. Implementable per-backend but doesn't help test code. +- **Pointers/references** — no target language needs them for test + assertions. +- **Enums/sum types** — would need per-language pattern matching. Model + as struct + tag field if needed. +- **Generics** — proto doesn't have them. The cardinality system handles + "zero or more of T." + +### Cardinality as the typing system + +Cardinality is not part of the type — it's metadata on the *port*. A +port has a base type (string, bool, struct Foo) and a cardinality +interval ([0,1], [1,∞), etc.). Whether something is "optional" or +"repeatable" falls out of the interval: + +- `[0,0]` — absent (Hack #5: this is what Empty should test) +- `[0,1]` — optional: Rust `Option`, Python `Optional[T]`, TS `T | undefined` +- `[1,1]` — required scalar (default) +- `[0,∞)` — optional repeatable: Rust `Vec`, Python `list[T]`, TS `T[]` +- `[1,∞)` — required repeatable + +The backend picks the idiomatic representation based on the interval, +not based on the type being called "List". This is already how +`DagAnalysis` works internally (via `allows_empty()`, `test_cases()`) — +it just isn't wired through to emission. Struct/object types in each +language can leverage cardinality to decide field optionality, collection +wrapping, etc. + +### Two layers: ValueExpr and CodeOp + +Separate value representation from code structure. Values are data +(literals, collections, structs). Code is control flow and assertions. + +**ValueExpr** — what a value looks like in the target language: + +``` +ValueExpr::Unit — null/None/undefined/Unit +ValueExpr::Bool(bool) — true/false/True/False +ValueExpr::Str(String) — "hello" / 'hello' +ValueExpr::Int(i64) — 42 +ValueExpr::List(Vec) — [a, b, c] / vec![a, b, c] +ValueExpr::Map(Vec<(String, ValueExpr)>) — {k: v} / BTreeMap::from(...) +ValueExpr::Json(serde_json::Value) — serde_json::json!() / JSON.parse() +ValueExpr::Struct { name, fields } — MyStruct { a, b } / { a, b } +``` + +Converting `Value → ValueExpr` is a total function — the compiler forces +exhaustive handling. No catch-all possible. `Request`/`Response` must be +explicitly modeled as structs (they have known fields) rather than +silently degraded to `""`. + +**CodeOp** — what a test does, independent of language: + +``` +CodeOp::Let(name, ValueExpr) — bind a value to a name +CodeOp::FieldAccess(name) — access a field/key +CodeOp::Call(fn_name, Vec) — call a function with args +CodeOp::AssertEq(Box, Box) — equality assertion +CodeOp::AssertTrue(Box) — truthiness assertion +CodeOp::AssertNonEmpty(Box) — non-emptiness assertion +CodeOp::Construct(kind, Vec) — build a List/Map/Struct +CodeOp::TestBlock(name, Vec) — wrap body in a test fn +CodeOp::Comment(String) — emit a comment +``` + +Each variant maps to a simple, well-defined operation that every language +can express. No variant requires language-specific knowledge. The ports on +each node carry `TypeId` and `Cardinality` as they do today, so the +existing type/cardinality checking infrastructure applies. + +### Cross-language semantics to litigate + +These are operations where languages differ and the backend must make +explicit choices: + +| Operation | Rust | Python | TypeScript | +|---|---|---|---| +| Integer literal | `42_i64` | `42` | `42` | +| Empty string | `String::new()` | `""` | `""` | +| List construction | `vec![...]` | `[...]` | `[...]` | +| Map construction | `BTreeMap::from([...])` | `{...}` | `new Map([...])` | +| Struct construction | `Foo { a, b }` | `Foo(a=.., b=..)` | `{ a, b }` | +| Equality assertion | `assert_eq!(a, b)` | `assert a == b` | `expect(a).toEqual(b)` | +| Non-empty assertion | `assert!(!x.is_empty())` | `assert x` or `assert len(x)` | `expect(x).toBeTruthy()` | +| Test function | `#[test] fn name()` | `def test_name():` | `test('name', () =>)` | + +The backend trait makes these choices explicit: + +``` +trait CodeRenderer { + fn render_value(&self, expr: &ValueExpr) -> String; + fn render_assert_eq(&self, left: &str, right: &str) -> String; + fn render_assert_true(&self, expr: &str) -> String; + fn render_assert_non_empty(&self, expr: &str) -> String; + fn render_test_block(&self, name: &str, body: &str) -> String; + fn render_var_bind(&self, name: &str, expr: &str) -> String; + fn render_call(&self, fn_name: &str, args: &[String]) -> String; + fn render_field_access(&self, expr: &str, field: &str) -> String; + fn render_imports(&self) -> String; +} +``` + +`render_value` replaces both `value_to_rust_literal` and `value_to_code`. +Adding a new `Value` variant breaks all backends at compile time. + +### Migration path + +Language stubs (Python, TypeScript) should exist from Phase 0 — they +validate the abstraction. If a CodeOp variant can't be cleanly expressed +in a stub backend, the abstraction is wrong and we find out immediately, +not at Phase 4. + +1. **Phase 0**: Define `ValueExpr`, `CodeOp`, `CodeRenderer` trait. + Implement Rust backend. Add Python and TypeScript stubs that panic + with `todo!("not yet implemented")` on every method — but the type + signatures must compile. This validates the trait surface. +2. **Phase 1**: Migrate `value_to_rust_literal` and `value_to_code` → + `Value → ValueExpr → render_value`. Consolidates the two duplicated + functions into one path. Immediately fixes Hacks 3, 4, and the + List bug. +3. **Phase 2**: Migrate assertion emission (`to_check_code`) → + `CodeOp::AssertEq` / `CodeOp::AssertTrue`. Fixes Hack 1 (Satisfies). +4. **Phase 3**: Migrate test function scaffolding → `CodeOp::TestBlock`. + After this, `codegen.rs` contains zero `format!()` calls for code + emission. + +Each phase is independently shippable. After Phase 1, the catch-all hack +is structurally impossible. After Phase 3, adding a new test phase or a +new target language is a matter of implementing a trait, not writing a +template engine. + +## What this replaces + +- `value_to_rust_literal()` + `value_to_code()` → `ValueExpr` + + `CodeRenderer::render_value` (consolidates two duplicated functions) +- `OutputMatcher::to_check_code()` → `CodeOp::AssertEq/AssertTrue` + + renderer +- `cardinality_case_mock_value()` → `ValueExpr` (same path, no special + case) +- All `format!("assert_eq!(...)")` in phase generators → CodeOp + construction + +## Analysis data is currently severed from emission + +Five phase generators accept `&DagAnalysis` and immediately discard it +(`_analysis`). The cardinality algebra exists — `allows_empty()`, +`test_cases()`, `satisfies()`, the full lattice — but `codegen.rs` never +consults it. Instead, `cardinality_case_mock_value()` reconstructs +cardinality behavior from ad-hoc `type_id` string matching (`"String"`, +`"Bool"`, `"Int"`), ignoring the port's actual `Cardinality` interval. + +The migration must wire `DagAnalysis` into the `CodeOp` construction +phase so that cardinality-driven decisions (empty case generation, list +vs scalar emission, optional handling) come from the structured analysis, +not from string heuristics. Concretely: + +- `ValueExpr` nodes should carry the port's `Cardinality`, not just a + `type_id` string. +- Mock value generation should use `cardinality.allows_empty()` and + `cardinality.test_cases()` instead of type-name pattern matching. +- The `_analysis` parameters should become live inputs to CodeOp + construction, not dead signatures. + +## What this does NOT replace + +The obligation collector (`collect_obligations`) and DAG analysis +(`analyze_dag`) remain unchanged — they produce structured data that +feeds into CodeOp construction. The change is purely in the emission +layer, but the emission layer must actually *consume* the analysis data +it receives. diff --git a/TODO/TODO_hacks.md b/TODO/TODO_hacks.md index c820c7c0df6..af720e04da8 100644 --- a/TODO/TODO_hacks.md +++ b/TODO/TODO_hacks.md @@ -294,32 +294,31 @@ represented as empty collection). ## Tasks - [ ] Hack 1: Replace `Satisfies` comment-only codegen with runtime callback or typed matcher variants -- [ ] Hack 2: Add `Value::Response`/`Value::Request` support to `value_to_rust_literal()` -- [ ] Hack 2: Update parse node examples to use real transport responses once ^^ lands -- [ ] Hack 3: Make `NonEmpty` codegen type-aware (or add `Value::is_empty()`) -- [ ] Hack 4: Replace `value_to_rust_literal` catch-all with `panic!()` or `compile_error!()` -- [ ] Hack 4: Fix `Value::List` filter_map silent dropping of non-string elements +- [x] Hack 2: Add `Value::Response`/`Value::Request` support (done via ValueExpr pipeline) +- [ ] Hack 2: Update parse node examples to use real transport responses +- [x] Hack 3: Make `NonEmpty` codegen type-aware (or add `Value::is_empty()`) +- [x] Hack 4: Replace `value_to_rust_literal` catch-all with `panic!()` or `compile_error!()` +- [x] Hack 4: Fix `Value::List` filter_map silent dropping of non-string elements - [ ] Hack 5: Make cardinality Empty tests represent absence, not empty content ## Notes -- Hacks 2, 3, and 4 are all consequences of the same root limitation: - `value_to_rust_literal()` doesn't cover all `Value` variants. Fixing - hack 2 (adding Request/Response serialization) would also make hack 3 - less common (fewer non-string outputs needing NonEmpty) and hack 4 - less dangerous (fewer variants hitting the catch-all). +- **Root cause fixed**: Hacks 2, 3, and 4 were all consequences of the + same limitation: `value_to_rust_literal()` didn't cover all `Value` + variants. This is now resolved — the `ValueExpr` intermediate + representation handles every `Value` variant exhaustively (including + `Request`/`Response` transport types), rendered via `RustRenderer`. + The old `value_to_rust_literal` is a one-liner delegating to this + pipeline; the old `value_to_code` and `to_check_code` in mock_spec.rs + have been deleted as dead code. - Hack 1 is independent — it's about closure serialization, not Value serialization. The "typed matcher variants" approach (option 2) is probably the cleanest since it keeps generated tests self-contained. Typed matchers form a small finite logic language that's both serializable to codegen and amenable to future proof generation. -- Hack 3 note: the runtime `OutputMatcher::check()` already handles - `Value::List` and non-string types correctly. The codegen path - (`to_check_code()`) just needs to mirror that logic. - Hack 5 is the most architecturally important — it blocks meaningful cardinality boundary testing. If Empty doesn't test absence, the B.3 tests exercise a subset of what they claim to cover. -- None of these are blocking. Tests compile, run, and pass. The risk - is false confidence — tests that look like they verify behavior but - actually don't. The enforcement mechanism (hack 0, already fixed) - ensures coverage *exists*; these hacks are about coverage *depth*. +- None of the remaining hacks are blocking. Tests compile, run, and + pass. The risk is false confidence — tests that look like they verify + behavior but actually don't. diff --git a/TODO/TODO_type_system.md b/TODO/TODO_type_system.md index 5829795ffd0..2627cda0c46 100644 --- a/TODO/TODO_type_system.md +++ b/TODO/TODO_type_system.md @@ -140,12 +140,13 @@ addressed in the codegen rework. - [ ] Move toward structural type DAG comparison in Port (item 1) - [ ] Wire cardinality through to codegen emptiness checks (item 2, codegen rework) -- [ ] Model target language elements as structured data (item 3, codegen rework) +- [ ] Model target language elements as structured data (item 3, codegen rework — in progress) - [ ] Add `cardinality` field to `CliEntrypoint` (item 6, codegen rework) - [ ] Wire `contract::witnesses()` into testgen (codegen rework) ## Completed +- [x] Add `Value::is_empty()` method to `core/ir` (item 2, convenience method) - [x] Add `Value::Set` as concrete type with set algebra (item 4) - [x] Add `WrapperKind::Set` / `NonEmptySet` to type system - [x] Add `SetOp` to collection primitives diff --git a/core/codegen/src/testgen/codegen.rs b/core/codegen/src/testgen/codegen.rs index 4646249f4d6..6a967030475 100644 --- a/core/codegen/src/testgen/codegen.rs +++ b/core/codegen/src/testgen/codegen.rs @@ -22,11 +22,14 @@ use crate::testgen::analyze::{analyze_dag, DagAnalysis}; use crate::testgen::obligation::{collect_obligations, DischargeStatus, Obligation, ObligationSet}; +use crate::testgen::render::TestRenderer; +use crate::testgen::render_rust::RustRenderer; +use crate::testgen::test_ir::{Assert, Expr}; use gunbc_ir::language::traits::comment::{generated_header, RUST_COMMENTS}; use gunbc_ir::language::NamingCase; use gunbc_ir::types::CardinalityCase; -use gunbc_ir::{Dag, Value}; -use gunbc_test::MockSpec; +use gunbc_ir::{Dag, Value, ValueExpr}; +use gunbc_test::{MockSpec, OutputMatcher}; use std::hash::{Hash, Hasher}; /// Configuration for test generation. @@ -1742,10 +1745,7 @@ impl<'a, T> TestGenerator<'a, T> { prefix, var_name, port, port )); - let check_code = matcher.to_check_code(&format!( - "output_{}", - var_name - )); + let check_code = render_output_matcher_check(matcher, &var_name); code.push_str(&format!(" {}\n", check_code)); } @@ -1888,80 +1888,125 @@ fn sanitize_resource_id(id: &str) -> String { result } -/// Convert a Value to a Rust literal string. +/// Convert a Value to a Rust literal string via ValueExpr. +/// +/// Uses the ValueExpr intermediate representation — every Value variant +/// is handled exhaustively in `Value → ValueExpr`, and every ValueExpr +/// variant is handled exhaustively in `RustRenderer::render_value`. +/// No catch-all at either stage. fn value_to_rust_literal(value: &Value) -> String { - match value { - Value::Unit => "Value::Unit".to_string(), - Value::Bool(b) => format!("Value::Bool({})", b), - Value::Str(s) => format!( - "Value::Str(\"{}\".to_string())", - s.replace('\"', "\\\"") - ), - Value::Int(i) => format!("Value::Int({})", i), - Value::List(list) => { - let items: Vec = list - .iter() - .filter_map(|v| v.as_str()) - .map(|s| format!("\"{}\".to_string()", s.replace('\"', "\\\""))) - .collect(); - format!("Value::str_list(vec![{}])", items.join(", ")) + RustRenderer.render_value(&ValueExpr::from(value)) +} + +/// Render an output matcher assertion as a single line of Rust code. +/// +/// Uses the test IR (Assert, Expr) and RustRenderer to produce the assertion, +/// replacing the old `OutputMatcher::to_check_code()` string interpolation. +/// The `var_name` is the snake_case port name; the actual variable is `output_{var_name}`. +fn render_output_matcher_check(matcher: &OutputMatcher, var_name: &str) -> String { + let output_var = format!("output_{}", var_name); + let result = match matcher { + OutputMatcher::Exact(expected) => { + let assert = Assert::Eq { + left: Expr::var(&output_var).deref(), + right: Expr::Value(ValueExpr::from(expected)), + message: "expected exact value".to_string(), + }; + RustRenderer.render_assert(&assert, 0) } - Value::Json(json) => { - format!("Value::Json(serde_json::json!({}))", json) + OutputMatcher::Contains(substring) => { + let assert = Assert::Contains { + expr: Expr::var(&output_var), + substring: substring.clone(), + message: format!("expected to contain '{}', got: {{:?}}", substring), + }; + RustRenderer.render_assert(&assert, 0) } - Value::Secret(_) => { - "Value::Secret(gunbc_ir::SecretString::new(\"\"))".to_string() + OutputMatcher::NonEmpty => { + let assert = Assert::NonEmpty { + expr: Expr::var(&output_var), + message: "expected non-empty value".to_string(), + }; + RustRenderer.render_assert(&assert, 0) } - Value::Skipped => "Value::Skipped".to_string(), - _ => "Value::Str(\"\".to_string())".to_string(), - } + OutputMatcher::Satisfies { description, .. } => { + format!("// Custom assertion: {}\n", description) + } + OutputMatcher::Any => { + format!("// Any value accepted for {}\n", output_var) + } + }; + // render_assert appends \n; strip it since the call site adds its own \n + result.trim_end_matches('\n').to_string() } /// Generate a mock value for a specific cardinality case and type. +/// +/// Builds a ValueExpr and renders it via RustRenderer. The type_id string +/// matching is a known limitation — ideally this should consult DagAnalysis +/// cardinality data instead (see TODO_codegen_dag.md, "severed analysis"). fn cardinality_case_mock_value(case: CardinalityCase, type_id: &str) -> String { - match case { + let expr = match case { CardinalityCase::Empty => match type_id { - "String" => "Value::Str(String::new())".to_string(), - "Bool" => "Value::Bool(false)".to_string(), - "Int" | "i64" | "i32" => "Value::Int(0)".to_string(), - _ => "Value::List(vec![])".to_string(), + "String" => ValueExpr::Str(String::new()), + "Bool" => ValueExpr::Bool(false), + "Int" | "i64" | "i32" => ValueExpr::Int(0), + _ => ValueExpr::List(vec![]), }, CardinalityCase::One => match type_id { - "String" => "Value::Str(\"\".to_string())".to_string(), - "Bool" => "Value::Bool(true)".to_string(), - "Int" | "i64" | "i32" => "Value::Int(1)".to_string(), - _ => "Value::str_list(vec![\"\".to_string()])".to_string(), + "String" => ValueExpr::Str("".to_string()), + "Bool" => ValueExpr::Bool(true), + "Int" | "i64" | "i32" => ValueExpr::Int(1), + _ => ValueExpr::List(vec![ValueExpr::Str("".to_string())]), }, CardinalityCase::Many => match type_id { - "String" => "Value::str_list(vec![\"\".to_string(), \"\".to_string(), \"\".to_string()])".to_string(), - "Bool" => "Value::List(vec![Value::Bool(true), Value::Bool(false), Value::Bool(true)])".to_string(), - "Int" | "i64" | "i32" => "Value::List(vec![Value::Int(1), Value::Int(2), Value::Int(3)])".to_string(), - _ => "Value::str_list(vec![\"\".to_string(), \"\".to_string(), \"\".to_string()])".to_string(), + "String" => ValueExpr::List(vec![ + ValueExpr::Str("".to_string()), + ValueExpr::Str("".to_string()), + ValueExpr::Str("".to_string()), + ]), + "Bool" => ValueExpr::List(vec![ + ValueExpr::Bool(true), + ValueExpr::Bool(false), + ValueExpr::Bool(true), + ]), + "Int" | "i64" | "i32" => ValueExpr::List(vec![ + ValueExpr::Int(1), + ValueExpr::Int(2), + ValueExpr::Int(3), + ]), + _ => ValueExpr::List(vec![ + ValueExpr::Str("".to_string()), + ValueExpr::Str("".to_string()), + ValueExpr::Str("".to_string()), + ]), }, - } + }; + RustRenderer.render_value(&expr) } /// Generate a default mock value for a type. +/// +/// Builds a ValueExpr and renders it via RustRenderer. Transport types +/// are now modeled as Struct ValueExprs rather than hardcoded format strings. fn default_mock_for_type(type_id: &str) -> String { - match type_id { - "String" => "Value::Str(\"\".to_string())".to_string(), - "Bool" => "Value::Bool(true)".to_string(), - "Int" | "i64" | "i32" => "Value::Int(0)".to_string(), - "List" => "Value::str_list(vec![\"\".to_string()])".to_string(), - "Secret" => { - "Value::Secret(gunbc_ir::SecretString::new(\"\"))".to_string() - } - "TransportResponse" => { - "Value::Response(gunbc_ir::transport::TransportResponse::Shell(\ - gunbc_ir::transport::ShellResponse { \ - exit_code: 0, \ - stdout: \"\".to_string(), \ - stderr: String::new() \ - }))" - .to_string() - } - _ => "Value::Str(\"\".to_string())".to_string(), - } + let expr = match type_id { + "String" => ValueExpr::Str("".to_string()), + "Bool" => ValueExpr::Bool(true), + "Int" | "i64" | "i32" => ValueExpr::Int(0), + "List" => ValueExpr::List(vec![ValueExpr::Str("".to_string())]), + "Secret" => ValueExpr::Secret("".to_string()), + "TransportResponse" => ValueExpr::Struct { + name: "TransportResponse::Shell".to_string(), + fields: vec![ + ("exit_code".to_string(), ValueExpr::Int(0)), + ("stdout".to_string(), ValueExpr::Str("".to_string())), + ("stderr".to_string(), ValueExpr::Str(String::new())), + ], + }, + _ => ValueExpr::Str("".to_string()), + }; + RustRenderer.render_value(&expr) } #[cfg(test)] diff --git a/core/codegen/src/testgen/mod.rs b/core/codegen/src/testgen/mod.rs index 7c0f1addc81..df460c8804e 100644 --- a/core/codegen/src/testgen/mod.rs +++ b/core/codegen/src/testgen/mod.rs @@ -23,6 +23,11 @@ pub mod analyze; pub mod codegen; pub mod obligation; +pub mod render; +pub mod render_python; +pub mod render_rust; +pub mod render_ts; +pub mod test_ir; pub use analyze::{analyze_dag, analyze_dag_with_obligations, DagAnalysis}; pub use codegen::{TestConfig, TestGenerator}; diff --git a/core/codegen/src/testgen/obligation.rs b/core/codegen/src/testgen/obligation.rs index 2bc108ef50d..af9286c27be 100644 --- a/core/codegen/src/testgen/obligation.rs +++ b/core/codegen/src/testgen/obligation.rs @@ -718,6 +718,24 @@ fn collect_scenario_obligations(dag: &Dag, obligations: &mut Vec bool { + matches!( + type_id.0.as_str(), + "ToolHandle" | "Lock" | "Lease" | "SharedLock" + ) +} + +/// Whether a port is a resource port (by name prefix or type). +fn is_resource_port(port: &gunbc_ir::dag::Port) -> bool { + port.name.0.starts_with("resource:") + || port.name.0.starts_with("tool:") + || is_resource_type(&port.type_id) +} + /// Bucket D: Resource hygiene obligations. fn collect_resource_obligations( dag: &Dag, @@ -727,14 +745,7 @@ fn collect_resource_obligations( // D.1: Resource input connectivity — every resource/tool input has an edge for node in &dag.nodes { for port in &node.inputs { - let is_resource = port.name.0.starts_with("resource:") - || port.name.0.starts_with("tool:") - || port.type_id.0 == "ToolHandle" - || port.type_id.0 == "Lock" - || port.type_id.0 == "Lease" - || port.type_id.0 == "SharedLock"; - - if is_resource { + if is_resource_port(port) { let has_edge = dag .edges .iter() @@ -782,14 +793,7 @@ fn collect_resource_obligations( // D.2: Resource owner validity — nodes that output resources should be env/owner nodes for node in &dag.nodes { - let outputs_resource = node.outputs.iter().any(|p| { - p.name.0.starts_with("resource:") - || p.name.0.starts_with("tool:") - || p.type_id.0 == "ToolHandle" - || p.type_id.0 == "Lock" - || p.type_id.0 == "Lease" - || p.type_id.0 == "SharedLock" - }); + let outputs_resource = node.outputs.iter().any(is_resource_port); if outputs_resource { obligations.push(ProofObligation::runtime( @@ -808,14 +812,7 @@ fn collect_resource_obligations( // D.3: No orphan resources — resources acquired should be consumed for node in &dag.nodes { for port in &node.outputs { - let is_resource = port.name.0.starts_with("resource:") - || port.name.0.starts_with("tool:") - || port.type_id.0 == "ToolHandle" - || port.type_id.0 == "Lock" - || port.type_id.0 == "Lease" - || port.type_id.0 == "SharedLock"; - - if is_resource { + if is_resource_port(port) { let has_consumer = dag .edges .iter() @@ -901,12 +898,7 @@ fn collect_resource_obligations( // verify the consumer can handle contention (acquisition failure). for node in &dag.nodes { for port in &node.inputs { - let is_resource = port.type_id.0 == "ToolHandle" - || port.type_id.0 == "Lock" - || port.type_id.0 == "Lease" - || port.type_id.0 == "SharedLock"; - - if is_resource { + if is_resource_type(&port.type_id) { obligations.push(ProofObligation::runtime( Obligation::ResourceContentionHandling { resource_port: format!("{}.{}", node.id.0, port.name.0), diff --git a/core/codegen/src/testgen/render.rs b/core/codegen/src/testgen/render.rs new file mode 100644 index 00000000000..47e1cea5c00 --- /dev/null +++ b/core/codegen/src/testgen/render.rs @@ -0,0 +1,37 @@ +//! Rendering trait for converting test IR to target-language source text. +//! +//! Each backend (Rust, Python, TypeScript) implements `TestRenderer` to +//! produce syntactically valid source for that language. The trait surface +//! is validated by language stubs at Phase 0 — if a method signature +//! can't be cleanly implemented for a language, the abstraction is wrong. + +use super::test_ir::*; +use gunbc_ir::ValueExpr; + +/// Render a `TestFile` to source text in a target language. +pub trait TestRenderer { + /// File extension for the generated file (e.g., "rs", "py", "ts"). + fn extension(&self) -> &str; + + /// Render a value literal to source text. + /// + /// This is the core function that replaces `value_to_rust_literal` and + /// `value_to_code`. It must handle every `ValueExpr` variant — the + /// compiler enforces this since `ValueExpr` has no catch-all. + fn render_value(&self, expr: &ValueExpr) -> String; + + /// Render a full test file to source text. + fn render_file(&self, file: &TestFile) -> String; + + /// Render a single expression to source text. + fn render_expr(&self, expr: &Expr) -> String; + + /// Render a single statement to source text. + fn render_stmt(&self, stmt: &Stmt, indent: usize) -> String; + + /// Render an assertion to source text. + fn render_assert(&self, assert: &Assert, indent: usize) -> String; + + /// Render an import to source text. + fn render_import(&self, import: &Import) -> String; +} diff --git a/core/codegen/src/testgen/render_python.rs b/core/codegen/src/testgen/render_python.rs new file mode 100644 index 00000000000..02123b1df23 --- /dev/null +++ b/core/codegen/src/testgen/render_python.rs @@ -0,0 +1,41 @@ +//! Python stub backend for test rendering. +//! +//! This stub exists at Phase 0 to validate the `TestRenderer` trait surface. +//! Every method compiles but panics at runtime. If a method signature can't +//! be cleanly implemented for Python, the abstraction is wrong. + +use super::render::TestRenderer; +use super::test_ir::*; +use gunbc_ir::ValueExpr; + +pub struct PythonRenderer; + +impl TestRenderer for PythonRenderer { + fn extension(&self) -> &str { + "py" + } + + fn render_value(&self, _expr: &ValueExpr) -> String { + todo!("Python value rendering not yet implemented") + } + + fn render_file(&self, _file: &TestFile) -> String { + todo!("Python file rendering not yet implemented") + } + + fn render_expr(&self, _expr: &Expr) -> String { + todo!("Python expression rendering not yet implemented") + } + + fn render_stmt(&self, _stmt: &Stmt, _indent: usize) -> String { + todo!("Python statement rendering not yet implemented") + } + + fn render_assert(&self, _assert: &Assert, _indent: usize) -> String { + todo!("Python assertion rendering not yet implemented") + } + + fn render_import(&self, _import: &Import) -> String { + todo!("Python import rendering not yet implemented") + } +} diff --git a/core/codegen/src/testgen/render_rust.rs b/core/codegen/src/testgen/render_rust.rs new file mode 100644 index 00000000000..ce717139d1a --- /dev/null +++ b/core/codegen/src/testgen/render_rust.rs @@ -0,0 +1,502 @@ +//! Rust backend for test rendering. +//! +//! Renders `TestFile` → valid Rust source compatible with `#[test]` and +//! the gunbc test harness. + +use super::render::TestRenderer; +use super::test_ir::*; +use gunbc_ir::ValueExpr; + +/// Escape a string for embedding in a Rust string literal. +fn escape_rust_str(s: &str) -> String { + s.replace('\\', "\\\\").replace('"', "\\\"") +} + +pub struct RustRenderer; + +/// Whether to render a ValueExpr as a `Value::X(...)` constructor or as a +/// bare Rust type (for transport struct fields). +#[derive(Clone, Copy, PartialEq, Eq)] +enum ValueMode { + /// Render as `Value` enum constructor: `Value::Str("x".to_string())` + Wrapped, + /// Render as bare Rust type: `"x".to_string()` + Bare, +} + +impl TestRenderer for RustRenderer { + fn extension(&self) -> &str { + "rs" + } + + fn render_value(&self, expr: &ValueExpr) -> String { + self.render_value_inner(expr, ValueMode::Wrapped) + } + + fn render_file(&self, file: &TestFile) -> String { + let mut out = String::new(); + + // Header comments + for line in &file.header { + out.push_str(&format!("// {}\n", line)); + } + out.push('\n'); + + // Imports + out.push('\n'); + for import in &file.imports { + out.push_str(&self.render_import(import)); + out.push('\n'); + } + out.push('\n'); + + // Helper functions + for helper in &file.helpers { + out.push_str(&format!( + "fn {}() -> {} {{\n {}\n}}\n\n", + helper.name, helper.return_type, helper.body_expr + )); + } + + // Test sections + for section in &file.sections { + out.push_str(&format!( + "// =========================================================================\n\ + // {}\n\ + // =========================================================================\n\n", + section.title + )); + + for test_fn in §ion.tests { + // Doc comments + for line in &test_fn.doc { + out.push_str(&format!("/// {}\n", line)); + } + // Test attribute and function signature + out.push_str("#[test]\n"); + out.push_str(&format!("fn {}() {{\n", test_fn.name)); + + // Body + for stmt in &test_fn.body { + out.push_str(&self.render_stmt(stmt, 1)); + } + + out.push_str("}\n\n"); + } + } + + out + } + + fn render_expr(&self, expr: &Expr) -> String { + match expr { + Expr::Value(v) => self.render_value(v), + Expr::Var(name) => name.clone(), + Expr::Str(s) => format!("\"{}\"", escape_rust_str(s)), + Expr::Call { func, args } => { + let func_str = self.render_expr(func); + let args_str: Vec = args.iter().map(|a| self.render_expr(a)).collect(); + format!("{}({})", func_str, args_str.join(", ")) + } + Expr::MethodCall { + receiver, + method, + args, + } => { + let recv = self.render_expr(receiver); + let args_str: Vec = args.iter().map(|a| self.render_expr(a)).collect(); + format!("{}.{}({})", recv, method, args_str.join(", ")) + } + Expr::Field(expr, field) => { + format!("{}.{}", self.render_expr(expr), field) + } + Expr::Deref(expr) => format!("*{}", self.render_expr(expr)), + Expr::Ref(expr) => format!("&{}", self.render_expr(expr)), + Expr::Path(segments) => segments.join("::"), + Expr::Struct { name, fields } => { + let field_strs: Vec = fields + .iter() + .map(|(k, v)| format!("{}: {}", k, self.render_expr(v))) + .collect(); + format!("{} {{ {} }}", name, field_strs.join(", ")) + } + Expr::Closure { args, body } => { + let body_str = self.render_expr(body); + if args.is_empty() { + format!("|| {}", body_str) + } else { + format!("|{}| {}", args.join(", "), body_str) + } + } + } + } + + fn render_stmt(&self, stmt: &Stmt, indent: usize) -> String { + let pad = " ".repeat(indent); + match stmt { + Stmt::Let { + name, + mutable, + expr, + } => { + let mut_kw = if *mutable { "mut " } else { "" }; + let expr_str = self.render_expr(expr); + format!("{}let {}{} = {};\n", pad, mut_kw, name, expr_str) + } + Stmt::Expr(expr) => { + format!("{}{};\n", pad, self.render_expr(expr)) + } + Stmt::Assert(assert) => self.render_assert(assert, indent), + Stmt::Comment(text) => { + if text.is_empty() { + format!("{}\n", pad) + } else { + format!("{}// {}\n", pad, text) + } + } + Stmt::Blank => "\n".to_string(), + } + } + + fn render_assert(&self, assert: &Assert, indent: usize) -> String { + let pad = " ".repeat(indent); + match assert { + Assert::Eq { + left, + right, + message, + } => { + format!( + "{}assert_eq!({}, {}, \"{}\");\n", + pad, + self.render_expr(left), + self.render_expr(right), + escape_rust_str(message) + ) + } + Assert::True { expr, message } => { + format!( + "{}assert!({}, \"{}\");\n", + pad, + self.render_expr(expr), + escape_rust_str(message) + ) + } + Assert::NonEmpty { expr, message } => { + format!( + "{}assert!(!{}.is_empty(), \"{}\");\n", + pad, + self.render_expr(expr), + escape_rust_str(message) + ) + } + Assert::Contains { + expr, + substring, + message, + } => { + format!( + "{}assert!({}.as_str().map(|s| s.contains(\"{}\")).unwrap_or(false), \"{}\", {});\n", + pad, + self.render_expr(expr), + escape_rust_str(substring), + escape_rust_str(message), + self.render_expr(expr), + ) + } + } + } + + fn render_import(&self, import: &Import) -> String { + let path = import.path.join("::"); + if import.items.is_empty() { + format!("use {};", path) + } else if import.items.len() == 1 { + format!("use {}::{};", path, import.items[0]) + } else { + format!("use {}::{{{}}};", path, import.items.join(", ")) + } + } +} + +impl RustRenderer { + /// Core value rendering: a single exhaustive match over ValueExpr. + /// + /// `Wrapped` mode emits `Value::X(...)` constructors (for test assertions). + /// `Bare` mode emits native Rust types (for transport struct fields). + fn render_value_inner(&self, expr: &ValueExpr, mode: ValueMode) -> String { + let bare = mode == ValueMode::Bare; + match expr { + ValueExpr::Unit => { + if bare { "None" } else { "Value::Unit" }.to_string() + } + ValueExpr::Bool(b) => { + if bare { + format!("{}", b) + } else { + format!("Value::Bool({})", b) + } + } + ValueExpr::Str(s) => { + let escaped = escape_rust_str(s); + if bare { + format!("\"{}\".to_string()", escaped) + } else { + format!("Value::Str(\"{}\".to_string())", escaped) + } + } + ValueExpr::Int(i) => { + if bare { + format!("{}", i) + } else { + format!("Value::Int({})", i) + } + } + ValueExpr::List(items) => { + let rendered: Vec = items + .iter() + .map(|v| self.render_value_inner(v, mode)) + .collect(); + let inner = format!("vec![{}]", rendered.join(", ")); + if bare { + inner + } else { + format!("Value::List({})", inner) + } + } + ValueExpr::Map(entries) => { + // Wrapped uses BTreeMap (Value::Map's backing type). + // Bare uses HashMap (transport struct field type). + if bare { + if entries.is_empty() { + return "std::collections::HashMap::new()".to_string(); + } + let rendered: Vec = entries + .iter() + .map(|(k, v)| { + format!( + "(\"{}\".to_string(), {})", + escape_rust_str(k), + self.render_value_inner(v, mode) + ) + }) + .collect(); + format!( + "std::collections::HashMap::from([{}])", + rendered.join(", ") + ) + } else { + if entries.is_empty() { + return "Value::Map(std::collections::BTreeMap::new())".to_string(); + } + let rendered: Vec = entries + .iter() + .map(|(k, v)| { + format!( + "(\"{}\".to_string(), {})", + escape_rust_str(k), + self.render_value_inner(v, mode) + ) + }) + .collect(); + format!( + "Value::Map(std::collections::BTreeMap::from([{}]))", + rendered.join(", ") + ) + } + } + ValueExpr::Json(json) => { + let inner = format!("serde_json::json!({})", json); + if bare { + inner + } else { + format!("Value::Json({})", inner) + } + } + ValueExpr::Struct { name, fields } => { + if bare { + let field_strs: Vec = fields + .iter() + .map(|(k, v)| { + format!("{}: {}", k, self.render_value_inner(v, mode)) + }) + .collect(); + format!("{} {{ {} }}", name, field_strs.join(", ")) + } else { + self.render_rust_struct(name, fields) + } + } + ValueExpr::Secret(s) => { + let escaped = escape_rust_str(s); + let inner = format!("gunbc_ir::SecretString::new(\"{}\")", escaped); + if bare { + inner + } else { + format!("Value::Secret({})", inner) + } + } + ValueExpr::Skipped => "Value::Skipped".to_string(), + } + } + + /// Render a struct-typed ValueExpr to Rust source. + /// + /// Handles transport types (TransportRequest::Shell, etc.) by + /// emitting fully-qualified Rust struct construction wrapped in + /// Value::Request/Response. + fn render_rust_struct(&self, name: &str, fields: &[(String, ValueExpr)]) -> String { + // Parse transport variant name once to determine all three values: + // wrapper (Value::Request/Response), enum path, and inner struct type. + let (wrapper, enum_path, struct_type) = + if let Some(variant) = name.strip_prefix("TransportRequest::") { + ( + Some("Value::Request"), + format!("gunbc_ir::transport::TransportRequest::{}", variant), + format!("gunbc_ir::transport::{}Request", variant), + ) + } else if let Some(variant) = name.strip_prefix("TransportResponse::") { + ( + Some("Value::Response"), + format!("gunbc_ir::transport::TransportResponse::{}", variant), + format!("gunbc_ir::transport::{}Response", variant), + ) + } else { + (None, name.to_string(), name.to_string()) + }; + + let field_strs: Vec = fields + .iter() + .map(|(k, v)| { + format!("{}: {}", k, self.render_value_inner(v, ValueMode::Bare)) + }) + .collect(); + + let struct_lit = format!("{} {{ {} }}", struct_type, field_strs.join(", ")); + + match wrapper { + Some(w) => format!("{}({}({}))", w, enum_path, struct_lit), + None => struct_lit, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn render_value_covers_all_variants() { + let r = RustRenderer; + assert_eq!(r.render_value(&ValueExpr::Unit), "Value::Unit"); + assert_eq!(r.render_value(&ValueExpr::Bool(true)), "Value::Bool(true)"); + assert_eq!( + r.render_value(&ValueExpr::Str("hello".into())), + "Value::Str(\"hello\".to_string())" + ); + assert_eq!(r.render_value(&ValueExpr::Int(42)), "Value::Int(42)"); + assert_eq!( + r.render_value(&ValueExpr::List(vec![ValueExpr::Int(1), ValueExpr::Bool(true)])), + "Value::List(vec![Value::Int(1), Value::Bool(true)])" + ); + assert_eq!(r.render_value(&ValueExpr::Skipped), "Value::Skipped"); + } + + #[test] + fn render_value_string_escaping() { + let r = RustRenderer; + assert_eq!( + r.render_value(&ValueExpr::Str("say \"hi\"".into())), + "Value::Str(\"say \\\"hi\\\"\".to_string())" + ); + } + + #[test] + fn render_bare_value_no_wrapper() { + let r = RustRenderer; + assert_eq!( + r.render_value_inner(&ValueExpr::Unit, ValueMode::Bare), + "None" + ); + assert_eq!( + r.render_value_inner(&ValueExpr::Bool(true), ValueMode::Bare), + "true" + ); + assert_eq!( + r.render_value_inner(&ValueExpr::Str("hi".into()), ValueMode::Bare), + "\"hi\".to_string()" + ); + assert_eq!( + r.render_value_inner(&ValueExpr::Int(42), ValueMode::Bare), + "42" + ); + assert_eq!( + r.render_value_inner(&ValueExpr::List(vec![ValueExpr::Str("a".into())]), ValueMode::Bare), + "vec![\"a\".to_string()]" + ); + } + + #[test] + fn render_expr_method_chain() { + let r = RustRenderer; + let expr = Expr::var("mocks") + .method("insert", vec![ + Expr::str_lit("node"), + Expr::str_lit("port"), + Expr::Value(ValueExpr::Bool(true)), + ]); + assert_eq!( + r.render_expr(&expr), + "mocks.insert(\"node\", \"port\", Value::Bool(true))" + ); + } + + #[test] + fn render_import() { + let r = RustRenderer; + let imp = Import { + path: vec!["gunbc_exec".into()], + items: vec!["execute_with_mode".into(), "BoundaryMocks".into()], + }; + assert_eq!( + r.render_import(&imp), + "use gunbc_exec::{execute_with_mode, BoundaryMocks};" + ); + } + + #[test] + fn render_let_stmt() { + let r = RustRenderer; + let stmt = Stmt::let_bind("dag", Expr::call("gist_graph", vec![])); + assert_eq!( + r.render_stmt(&stmt, 1), + " let dag = gist_graph();\n" + ); + } + + #[test] + fn render_assert_eq() { + let r = RustRenderer; + let a = Assert::Eq { + left: Expr::var("output").deref(), + right: Expr::Value(ValueExpr::Int(42)), + message: "expected exact value".into(), + }; + assert_eq!( + r.render_assert(&a, 1), + " assert_eq!(*output, Value::Int(42), \"expected exact value\");\n" + ); + } + + #[test] + fn render_assert_non_empty() { + let r = RustRenderer; + let a = Assert::NonEmpty { + expr: Expr::var("output"), + message: "expected non-empty value".into(), + }; + assert_eq!( + r.render_assert(&a, 1), + " assert!(!output.is_empty(), \"expected non-empty value\");\n" + ); + } +} diff --git a/core/codegen/src/testgen/render_ts.rs b/core/codegen/src/testgen/render_ts.rs new file mode 100644 index 00000000000..cb128aeaa50 --- /dev/null +++ b/core/codegen/src/testgen/render_ts.rs @@ -0,0 +1,41 @@ +//! TypeScript stub backend for test rendering. +//! +//! This stub exists at Phase 0 to validate the `TestRenderer` trait surface. +//! Every method compiles but panics at runtime. If a method signature can't +//! be cleanly implemented for TypeScript, the abstraction is wrong. + +use super::render::TestRenderer; +use super::test_ir::*; +use gunbc_ir::ValueExpr; + +pub struct TypeScriptRenderer; + +impl TestRenderer for TypeScriptRenderer { + fn extension(&self) -> &str { + "ts" + } + + fn render_value(&self, _expr: &ValueExpr) -> String { + todo!("TypeScript value rendering not yet implemented") + } + + fn render_file(&self, _file: &TestFile) -> String { + todo!("TypeScript file rendering not yet implemented") + } + + fn render_expr(&self, _expr: &Expr) -> String { + todo!("TypeScript expression rendering not yet implemented") + } + + fn render_stmt(&self, _stmt: &Stmt, _indent: usize) -> String { + todo!("TypeScript statement rendering not yet implemented") + } + + fn render_assert(&self, _assert: &Assert, _indent: usize) -> String { + todo!("TypeScript assertion rendering not yet implemented") + } + + fn render_import(&self, _import: &Import) -> String { + todo!("TypeScript import rendering not yet implemented") + } +} diff --git a/core/codegen/src/testgen/test_ir.rs b/core/codegen/src/testgen/test_ir.rs new file mode 100644 index 00000000000..ce912ffa66b --- /dev/null +++ b/core/codegen/src/testgen/test_ir.rs @@ -0,0 +1,233 @@ +//! Structured intermediate representation for generated test files. +//! +//! These types capture the *intent* of a test (what to set up, execute, +//! and assert) without encoding any target-language syntax. A `TestFile` +//! is rendered to source text by a `TestRenderer` backend. + +use gunbc_ir::ValueExpr; + +// =========================================================================== +// File structure +// =========================================================================== + +/// A complete generated test file. +pub struct TestFile { + /// Comment lines at the top (generated-by notice, hash, stats). + pub header: Vec, + /// Module-level imports. + pub imports: Vec, + /// Helper functions (e.g., `fn mock_spec() -> MockSpec`). + pub helpers: Vec, + /// Test sections, each with a header and list of tests. + pub sections: Vec, +} + +/// A module-level import. +pub struct Import { + /// Module path segments (e.g., ["gunbc_exec"] or ["gunbc_ir"]). + pub path: Vec, + /// Items imported from the module (e.g., ["Value", "Cardinality"]). + pub items: Vec, +} + +/// A helper function defined at module scope. +pub struct HelperFn { + pub name: String, + pub return_type: String, + /// The function body as a single expression (e.g., "crate::graph_mock::mock_spec()"). + pub body_expr: String, +} + +/// A group of related tests with a section header. +pub struct TestSection { + /// Section title (e.g., "Bucket A: Execution semantics"). + pub title: String, + pub tests: Vec, +} + +// =========================================================================== +// Test functions +// =========================================================================== + +/// A single test function. +pub struct TestFn { + /// Function name (e.g., "test_a1_dry_run_execution"). + pub name: String, + /// Doc comment lines (rendered as `///` in Rust, docstrings elsewhere). + pub doc: Vec, + /// Ordered statements in the test body. + pub body: Vec, +} + +// =========================================================================== +// Statements +// =========================================================================== + +/// A statement in test code. +pub enum Stmt { + /// Variable binding: `let [mut] name = expr;` + Let { + name: String, + mutable: bool, + expr: Expr, + }, + /// Expression as statement (for side effects like `map.insert(...)`). + Expr(Expr), + /// An assertion. + Assert(Assert), + /// A comment line. + Comment(String), + /// Blank line for readability. + Blank, +} + +// =========================================================================== +// Expressions +// =========================================================================== + +/// An expression in generated code. +pub enum Expr { + /// A value literal. + Value(ValueExpr), + /// A variable reference. + Var(String), + /// A string literal (for keys, messages, identifiers — not Value::Str). + Str(String), + /// Function call: `func(args...)`. + Call { + func: Box, + args: Vec, + }, + /// Method call: `receiver.method(args...)`. + MethodCall { + receiver: Box, + method: String, + args: Vec, + }, + /// Field access: `expr.field`. + Field(Box, String), + /// Dereference: `*expr`. + Deref(Box), + /// Reference: `&expr`. + Ref(Box), + /// Path expression: `path::to::Item` (for enum variants, associated fns). + Path(Vec), + /// Struct construction: `Name { field: value, ... }`. + Struct { + name: String, + fields: Vec<(String, Expr)>, + }, + /// Closure/lambda: `|args| body` / `lambda args: body`. + Closure { + args: Vec, + body: Box, + }, +} + +// =========================================================================== +// Assertions +// =========================================================================== + +/// A test assertion. +pub enum Assert { + /// Equality: `assert_eq!(left, right, message)`. + Eq { + left: Expr, + right: Expr, + message: String, + }, + /// Truthiness: `assert!(expr, message)`. + True { + expr: Expr, + message: String, + }, + /// Non-emptiness: `assert!(!expr.is_empty(), message)`. + NonEmpty { + expr: Expr, + message: String, + }, + /// String containment: `assert!(expr.contains(substring), message)`. + Contains { + expr: Expr, + substring: String, + message: String, + }, +} + +// =========================================================================== +// Convenience constructors +// =========================================================================== + +impl Expr { + /// Shorthand for `Expr::Var(name)`. + pub fn var(name: impl Into) -> Self { + Expr::Var(name.into()) + } + + /// Shorthand for `Expr::Str(s)`. + pub fn str_lit(s: impl Into) -> Self { + Expr::Str(s.into()) + } + + /// Shorthand for `Expr::Path(segments)`. + pub fn path(segments: &[&str]) -> Self { + Expr::Path(segments.iter().map(|s| s.to_string()).collect()) + } + + /// Shorthand for a free function call: `func(args...)`. + pub fn call(func: impl Into, args: Vec) -> Self { + Expr::Call { + func: Box::new(Expr::Var(func.into())), + args, + } + } + + /// Shorthand for a method call on self: `self.method(args...)`. + pub fn method(self, method: impl Into, args: Vec) -> Self { + Expr::MethodCall { + receiver: Box::new(self), + method: method.into(), + args, + } + } + + /// Shorthand for `.expect("message")`. + pub fn expect(self, msg: impl Into) -> Self { + self.method("expect", vec![Expr::Str(msg.into())]) + } + + /// Shorthand for `*self`. + pub fn deref(self) -> Self { + Expr::Deref(Box::new(self)) + } + + /// Shorthand for `&self`. + pub fn ref_of(self) -> Self { + Expr::Ref(Box::new(self)) + } +} + +impl Stmt { + /// `let name = expr;` + pub fn let_bind(name: impl Into, expr: Expr) -> Self { + Stmt::Let { + name: name.into(), + mutable: false, + expr, + } + } + + /// `let mut name = expr;` + pub fn let_mut(name: impl Into, expr: Expr) -> Self { + Stmt::Let { + name: name.into(), + mutable: true, + expr, + } + } + + /// A comment line. + pub fn comment(text: impl Into) -> Self { + Stmt::Comment(text.into()) + } +} diff --git a/core/ir/src/lib.rs b/core/ir/src/lib.rs index ba0dcb61d01..e9f41b1d03d 100644 --- a/core/ir/src/lib.rs +++ b/core/ir/src/lib.rs @@ -67,6 +67,7 @@ pub mod type_registry; pub mod types; pub mod validate; pub mod value; +pub mod value_expr; // Re-exports for convenience pub use boundary::{detect_boundaries, BoundaryInfo}; @@ -91,6 +92,7 @@ pub use type_op::{BaseType, Coercion, Predicate, PredicateValue, TypeOp, Wrapper pub use type_registry::{TypeNotFoundError, TypeRegistry}; pub use types::{Cardinality, CardinalityCase, CardinalityMismatch, NodeId, PortName, TypeId}; pub use value::{SecretString, Value}; +pub use value_expr::ValueExpr; pub use render::Renderable; pub use validate::{validate_subdag_interfaces, SubDagError, PortDirection}; pub use algebra::{ diff --git a/core/ir/src/value.rs b/core/ir/src/value.rs index 0abaa96938d..992ba82ad98 100644 --- a/core/ir/src/value.rs +++ b/core/ir/src/value.rs @@ -250,6 +250,32 @@ impl Value { } } + // ========================================================================= + // Emptiness + // ========================================================================= + + /// Whether this value is semantically empty. + /// + /// Emptiness is defined per variant: + /// - `Unit`, `Skipped` — always empty (no data / didn't run) + /// - `Str` — empty string + /// - `List` — zero elements + /// - `Map` — zero entries + /// - `Secret` — empty inner string + /// - `Bool`, `Int`, `Json`, `Request`, `Response` — never empty + /// (they carry data by existence) + pub fn is_empty(&self) -> bool { + match self { + Value::Unit | Value::Skipped => true, + Value::Str(s) => s.is_empty(), + Value::List(v) | Value::Set(v) => v.is_empty(), + Value::Map(m) => m.is_empty(), + Value::Secret(s) => s.is_empty(), + Value::Bool(_) | Value::Int(_) | Value::Json(_) + | Value::Request(_) | Value::Response(_) => false, + } + } + // ========================================================================= // Type predicates // ========================================================================= diff --git a/core/ir/src/value_expr.rs b/core/ir/src/value_expr.rs new file mode 100644 index 00000000000..9cfded7797a --- /dev/null +++ b/core/ir/src/value_expr.rs @@ -0,0 +1,262 @@ +//! Language-independent value representation for code generation. +//! +//! `ValueExpr` sits between the runtime `Value` type and target-language +//! source text. Converting `Value → ValueExpr` is total — every variant +//! is handled, no catch-all. Rendering `ValueExpr → String` is per-backend. + +use crate::transport::{TransportRequest, TransportResponse}; +use crate::Value; + +/// Language-independent representation of a value literal. +/// +/// This is the proto-like minimal type language: bool, i64, string, json, +/// list, map, struct, unit. No unsigned integers, no pointers, no generics. +/// Complexity goes into backends, not into the core. +#[derive(Debug, Clone, PartialEq)] +pub enum ValueExpr { + /// Absence of data — null/None/undefined/Unit per language. + Unit, + /// Boolean — true/false/True/False per language. + Bool(bool), + /// String — "hello" / 'hello' per language. + Str(String), + /// Signed 64-bit integer — single integer type, no unsigned. + Int(i64), + /// Homogeneous ordered collection — vec![...] / [...] per language. + List(Vec), + /// Dynamic string-keyed collection — BTreeMap / dict / Record per language. + Map(Vec<(String, ValueExpr)>), + /// Opaque JSON — serde_json::json!() / dict literal / plain object per language. + Json(serde_json::Value), + /// Named product type with statically known fields. + /// Distinct from Map: backends render as struct / dataclass / interface. + Struct { + name: String, + fields: Vec<(String, ValueExpr)>, + }, + /// Secret/redacted value — rendered as a secret constructor per language. + Secret(String), + /// Node was skipped (guard false) — rendered as skip sentinel per language. + Skipped, +} + +// =========================================================================== +// Value → ValueExpr (total conversion — no catch-all) +// =========================================================================== + +impl From<&Value> for ValueExpr { + fn from(value: &Value) -> Self { + match value { + Value::Unit => ValueExpr::Unit, + Value::Bool(b) => ValueExpr::Bool(*b), + Value::Str(s) => ValueExpr::Str(s.clone()), + Value::Int(i) => ValueExpr::Int(*i), + Value::List(v) | Value::Set(v) => { + ValueExpr::List(v.iter().map(ValueExpr::from).collect()) + } + Value::Map(m) => { + ValueExpr::Map(m.iter().map(|(k, v)| (k.clone(), ValueExpr::from(v))).collect()) + } + Value::Json(j) => ValueExpr::Json(j.clone()), + Value::Request(r) => request_to_value_expr(r), + Value::Response(r) => response_to_value_expr(r), + Value::Secret(s) => ValueExpr::Secret(s.expose().to_string()), + Value::Skipped => ValueExpr::Skipped, + } + } +} + +impl From for ValueExpr { + fn from(value: Value) -> Self { + ValueExpr::from(&value) + } +} + +// =========================================================================== +// Transport → Struct conversion +// =========================================================================== + +/// Convert an optional value to ValueExpr (Some → value, None → Unit). +fn opt_str(s: &Option) -> ValueExpr { + match s { + Some(v) => ValueExpr::Str(v.clone()), + None => ValueExpr::Unit, + } +} + +/// Convert a HashMap to a sorted ValueExpr::Map. +fn str_map_expr(m: &std::collections::HashMap) -> ValueExpr { + let mut entries: Vec<_> = m.iter().map(|(k, v)| (k.clone(), ValueExpr::Str(v.clone()))).collect(); + entries.sort_by(|a, b| a.0.cmp(&b.0)); + ValueExpr::Map(entries) +} + +fn request_to_value_expr(req: &TransportRequest) -> ValueExpr { + match req { + TransportRequest::Shell(s) => ValueExpr::Struct { + name: "TransportRequest::Shell".to_string(), + fields: vec![ + ("command".to_string(), ValueExpr::Str(s.command.clone())), + ( + "args".to_string(), + ValueExpr::List(s.args.iter().map(|a| ValueExpr::Str(a.clone())).collect()), + ), + ("env".to_string(), str_map_expr(&s.env)), + ("cwd".to_string(), opt_str(&s.cwd)), + ("stdin".to_string(), opt_str(&s.stdin)), + ], + }, + TransportRequest::Rest(r) => ValueExpr::Struct { + name: "TransportRequest::Rest".to_string(), + fields: vec![ + ("method".to_string(), ValueExpr::Str(format!("{:?}", r.method))), + ("url".to_string(), ValueExpr::Str(r.url.clone())), + ("headers".to_string(), str_map_expr(&r.headers)), + ( + "body".to_string(), + match &r.body { + Some(j) => ValueExpr::Json(j.clone()), + None => ValueExpr::Unit, + }, + ), + ("query".to_string(), str_map_expr(&r.query)), + ], + }, + TransportRequest::Http(h) => ValueExpr::Struct { + name: "TransportRequest::Http".to_string(), + fields: vec![ + ("method".to_string(), ValueExpr::Str(format!("{:?}", h.method))), + ("url".to_string(), ValueExpr::Str(h.url.clone())), + ("headers".to_string(), str_map_expr(&h.headers)), + ("body".to_string(), opt_str(&h.body)), + ], + }, + TransportRequest::File(f) => ValueExpr::Struct { + name: "TransportRequest::File".to_string(), + fields: vec![ + ("path".to_string(), ValueExpr::Str(f.path.clone())), + ("operation".to_string(), ValueExpr::Str(format!("{:?}", f.operation))), + ("content".to_string(), opt_str(&f.content)), + ("create_parents".to_string(), ValueExpr::Bool(f.create_parents)), + ], + }, + TransportRequest::Tcp(t) => ValueExpr::Struct { + name: "TransportRequest::Tcp".to_string(), + fields: vec![ + ("host".to_string(), ValueExpr::Str(t.host.clone())), + ("port".to_string(), ValueExpr::Int(t.port as i64)), + ("data".to_string(), opt_str(&t.data)), + ], + }, + } +} + +fn response_to_value_expr(resp: &TransportResponse) -> ValueExpr { + match resp { + TransportResponse::Shell(s) => ValueExpr::Struct { + name: "TransportResponse::Shell".to_string(), + fields: vec![ + ("exit_code".to_string(), ValueExpr::Int(s.exit_code as i64)), + ("stdout".to_string(), ValueExpr::Str(s.stdout.clone())), + ("stderr".to_string(), ValueExpr::Str(s.stderr.clone())), + ], + }, + TransportResponse::Rest(r) => ValueExpr::Struct { + name: "TransportResponse::Rest".to_string(), + fields: vec![ + ("status".to_string(), ValueExpr::Int(r.status as i64)), + ("body".to_string(), ValueExpr::Json(r.body.clone())), + ("headers".to_string(), str_map_expr(&r.headers)), + ], + }, + TransportResponse::Http(h) => ValueExpr::Struct { + name: "TransportResponse::Http".to_string(), + fields: vec![ + ("status".to_string(), ValueExpr::Int(h.status as i64)), + ("body".to_string(), ValueExpr::Str(h.body.clone())), + ("headers".to_string(), str_map_expr(&h.headers)), + ], + }, + TransportResponse::File(f) => ValueExpr::Struct { + name: "TransportResponse::File".to_string(), + fields: vec![ + ("path".to_string(), ValueExpr::Str(f.path.clone())), + ("operation".to_string(), ValueExpr::Str(format!("{:?}", f.operation))), + ("success".to_string(), ValueExpr::Bool(f.success)), + ("content".to_string(), opt_str(&f.content)), + ( + "exists".to_string(), + match f.exists { + Some(b) => ValueExpr::Bool(b), + None => ValueExpr::Unit, + }, + ), + ("error".to_string(), opt_str(&f.error)), + ], + }, + TransportResponse::Tcp(t) => ValueExpr::Struct { + name: "TransportResponse::Tcp".to_string(), + fields: vec![ + ("connected".to_string(), ValueExpr::Bool(t.connected)), + ("data".to_string(), opt_str(&t.data)), + ("bytes_sent".to_string(), ValueExpr::Int(t.bytes_sent as i64)), + ("bytes_received".to_string(), ValueExpr::Int(t.bytes_received as i64)), + ("error".to_string(), opt_str(&t.error)), + ], + }, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::BTreeMap; + + #[test] + fn value_to_value_expr_is_total() { + // Every Value variant must convert without panic. + let values = vec![ + Value::Unit, + Value::Bool(true), + Value::Str("hello".into()), + Value::Int(42), + Value::List(vec![Value::Int(1), Value::Bool(true)]), + Value::Map(BTreeMap::from([("k".into(), Value::Int(1))])), + Value::Json(serde_json::json!({"a": 1})), + Value::Skipped, + ]; + for v in &values { + let _expr = ValueExpr::from(v); + } + } + + #[test] + fn list_preserves_heterogeneous_elements() { + let v = Value::List(vec![Value::Int(1), Value::Str("two".into()), Value::Bool(true)]); + let expr = ValueExpr::from(&v); + assert_eq!( + expr, + ValueExpr::List(vec![ + ValueExpr::Int(1), + ValueExpr::Str("two".into()), + ValueExpr::Bool(true), + ]) + ); + } + + #[test] + fn map_preserves_all_entries() { + let mut m = BTreeMap::new(); + m.insert("a".into(), Value::Int(1)); + m.insert("b".into(), Value::Bool(false)); + let v = Value::Map(m); + let expr = ValueExpr::from(&v); + assert_eq!( + expr, + ValueExpr::Map(vec![ + ("a".into(), ValueExpr::Int(1)), + ("b".into(), ValueExpr::Bool(false)), + ]) + ); + } +} diff --git a/core/test/src/mock_spec.rs b/core/test/src/mock_spec.rs index 2af91f4e96c..64789e27e54 100644 --- a/core/test/src/mock_spec.rs +++ b/core/test/src/mock_spec.rs @@ -793,55 +793,13 @@ impl OutputMatcher { } } - /// Whether `to_check_code` emits an executable assertion (vs. a comment). + /// Whether this matcher produces an executable assertion (vs. a comment). /// /// Used by codegen to decide whether to prefix the output variable with `_`. pub fn generates_assertion(&self) -> bool { matches!(self, OutputMatcher::Exact(_) | OutputMatcher::Contains(_) | OutputMatcher::NonEmpty) } - /// Convert to Rust code for generated tests. - pub fn to_check_code(&self, value_expr: &str) -> String { - match self { - OutputMatcher::Exact(expected) => { - format!( - "assert_eq!(*{}, {}, \"expected exact value\");", - value_expr, - value_to_code(expected) - ) - } - OutputMatcher::Contains(substring) => { - format!( - "assert!({}.as_str().map(|s| s.contains(\"{}\")).unwrap_or(false), \"expected to contain '{}', got: {{:?}}\", {});", - value_expr, substring.replace('\"', "\\\""), substring.replace('\"', "\\\""), value_expr - ) - } - OutputMatcher::NonEmpty => { - format!( - "assert!(!{}.as_str().map(|s| s.is_empty()).unwrap_or(false), \"expected non-empty\");", - value_expr - ) - } - OutputMatcher::Satisfies { description, .. } => { - // For custom predicates, we can only emit a comment - format!("// Custom assertion: {}", description) - } - OutputMatcher::Any => { - format!("// Any value accepted for {}", value_expr) - } - } - } -} - -/// Convert a Value to Rust code. -fn value_to_code(value: &Value) -> String { - match value { - Value::Unit => "Value::Unit".to_string(), - Value::Bool(b) => format!("Value::Bool({})", b), - Value::Str(s) => format!("Value::Str(\"{}\".to_string())", s.replace('\"', "\\\"")), - Value::Int(i) => format!("Value::Int({})", i), - _ => "/* complex value */".to_string(), - } } #[cfg(test)] @@ -991,18 +949,4 @@ mod tests { assert_eq!(spec.node_examples[0].node_id, "parse"); } - #[test] - fn test_output_matcher_to_check_code() { - let exact = OutputMatcher::exact(Value::Str("hello".into())); - let code = exact.to_check_code("output"); - assert!(code.contains("assert_eq!")); - - let contains = OutputMatcher::contains("world"); - let code = contains.to_check_code("output"); - assert!(code.contains("contains")); - - let non_empty = OutputMatcher::non_empty(); - let code = non_empty.to_check_code("output"); - assert!(code.contains("is_empty")); - } } diff --git a/gunbc-dag/src/bootstrap/generated_tests.rs b/gunbc-dag/src/bootstrap/generated_tests.rs index 232d9d83eb6..2d4c0878e77 100644 --- a/gunbc-dag/src/bootstrap/generated_tests.rs +++ b/gunbc-dag/src/bootstrap/generated_tests.rs @@ -3,7 +3,7 @@ // Generated by gunbc-testgen // DO NOT EDIT - regenerate with: make testgen// Obligations: 31 obligations (8 discharged, 23 testable: A=10, B=8, C=5, D=0) // Proven by construction: acyclicity, type compatibility, cardinality satisfaction. -// Content-Hash: b0487b6be880d6a0 +// Content-Hash: bdb3c9258a2ded9d use gunbc_exec::{execute_with_mode, BoundaryMocks, ExecutionMode}; @@ -230,7 +230,7 @@ fn test_example_prepare_scan_workspace_prepares_a_workspace_scan_transport_reque // Check output port 'request' let output_request = outputs.get("request").expect("output port 'request' should exist"); - assert!(!output_request.as_str().map(|s| s.is_empty()).unwrap_or(false), "expected non-empty"); + assert!(!output_request.is_empty(), "expected non-empty value"); } /// Node example: parse_scan_result - Handles skipped transport response gracefully diff --git a/gunbc-dag/src/ci/generated_tests.rs b/gunbc-dag/src/ci/generated_tests.rs index 5b8f4fce267..abc9229a04d 100644 --- a/gunbc-dag/src/ci/generated_tests.rs +++ b/gunbc-dag/src/ci/generated_tests.rs @@ -3,7 +3,7 @@ // Generated by gunbc-testgen // DO NOT EDIT - regenerate with: make testgen// Obligations: 91 obligations (37 discharged, 54 testable: A=19, B=22, C=11, D=2) // Proven by construction: acyclicity, type compatibility, cardinality satisfaction. -// Content-Hash: a0d5a9714880a43a +// Content-Hash: ebec59b2fe4f66dc use gunbc_exec::{execute_with_mode, BoundaryMocks, ExecutionMode}; @@ -156,7 +156,7 @@ fn test_cardinality_parse_codegen_exists_codegen_ran_one() { fn test_cardinality_parse_codegen_exists_prep_message_empty() { let dag = crate::build_ci_graph().unwrap(); let mut mocks = mock_spec().to_boundary_mocks(); - mocks.set_value("parse_codegen_exists", "prep_message", Value::Str(String::new())); + mocks.set_value("parse_codegen_exists", "prep_message", Value::Str("".to_string())); let _log = execute_with_mode(&dag, ExecutionMode::DryRun(mocks)) .expect("cardinality empty case should not crash"); } @@ -180,7 +180,7 @@ fn test_cardinality_parse_codegen_exists_prep_message_one() { fn test_cardinality_execute_build_skip_reason_empty() { let dag = crate::build_ci_graph().unwrap(); let mut mocks = mock_spec().to_boundary_mocks(); - mocks.set_value("execute_build", "skip_reason", Value::Str(String::new())); + mocks.set_value("execute_build", "skip_reason", Value::Str("".to_string())); let _log = execute_with_mode(&dag, ExecutionMode::DryRun(mocks)) .expect("cardinality empty case should not crash"); } @@ -204,7 +204,7 @@ fn test_cardinality_execute_build_skip_reason_one() { fn test_cardinality_execute_test_skip_reason_empty() { let dag = crate::build_ci_graph().unwrap(); let mut mocks = mock_spec().to_boundary_mocks(); - mocks.set_value("execute_test", "skip_reason", Value::Str(String::new())); + mocks.set_value("execute_test", "skip_reason", Value::Str("".to_string())); let _log = execute_with_mode(&dag, ExecutionMode::DryRun(mocks)) .expect("cardinality empty case should not crash"); } @@ -617,7 +617,7 @@ fn test_example_prepare_codegen_exists_prepares_file_exists_check_for_codegen_di // Check output port 'request' let output_request = outputs.get("request").expect("output port 'request' should exist"); - assert!(!output_request.as_str().map(|s| s.is_empty()).unwrap_or(false), "expected non-empty"); + assert!(!output_request.is_empty(), "expected non-empty value"); } /// Node example: prepare_codegen_cmd - Codegen command prepare emits skip flag diff --git a/gunbc-dag/src/makegen/generated_tests.rs b/gunbc-dag/src/makegen/generated_tests.rs index a2b85905f74..911c85516ee 100644 --- a/gunbc-dag/src/makegen/generated_tests.rs +++ b/gunbc-dag/src/makegen/generated_tests.rs @@ -3,7 +3,7 @@ // Generated by gunbc-testgen // DO NOT EDIT - regenerate with: make testgen// Obligations: 14 obligations (3 discharged, 11 testable: A=5, B=4, C=2, D=0) // Proven by construction: acyclicity, type compatibility, cardinality satisfaction. -// Content-Hash: 1dc86237c3b2ccc8 +// Content-Hash: 90cb5866f7943be7 use gunbc_exec::{execute_with_mode, BoundaryMocks, ExecutionMode}; @@ -76,7 +76,7 @@ fn test_transport_interception() { fn test_cardinality_load_registry_tool_names_one() { let dag = crate::build_makegen_graph().unwrap(); let mut mocks = mock_spec().to_boundary_mocks(); - mocks.set_value("load_registry", "tool_names", Value::str_list(vec!["".to_string()])); + mocks.set_value("load_registry", "tool_names", Value::List(vec![Value::Str("".to_string())])); let _log = execute_with_mode(&dag, ExecutionMode::DryRun(mocks)) .expect("cardinality one case should not crash"); } @@ -88,7 +88,7 @@ fn test_cardinality_load_registry_tool_names_one() { fn test_cardinality_load_registry_tool_names_many() { let dag = crate::build_makegen_graph().unwrap(); let mut mocks = mock_spec().to_boundary_mocks(); - mocks.set_value("load_registry", "tool_names", Value::str_list(vec!["".to_string(), "".to_string(), "".to_string()])); + mocks.set_value("load_registry", "tool_names", Value::List(vec![Value::Str("".to_string()), Value::Str("".to_string()), Value::Str("".to_string())])); let _log = execute_with_mode(&dag, ExecutionMode::DryRun(mocks)) .expect("cardinality many case should not crash"); } @@ -192,7 +192,7 @@ fn test_example_load_registry_default_registry_loads_with_expected_tools() { // Custom assertion: at least 2 tools registered // Check output port 'tool_names' let output_tool_names = outputs.get("tool_names").expect("output port 'tool_names' should exist"); - assert!(!output_tool_names.as_str().map(|s| s.is_empty()).unwrap_or(false), "expected non-empty"); + assert!(!output_tool_names.is_empty(), "expected non-empty value"); } /// Node example: render_makefile - Rendered Makefile contains gist target diff --git a/lib/llm-ops/src/generated_tests.rs b/lib/llm-ops/src/generated_tests.rs index 9aaccf5142e..423cb8dfea0 100644 --- a/lib/llm-ops/src/generated_tests.rs +++ b/lib/llm-ops/src/generated_tests.rs @@ -3,7 +3,7 @@ // Generated by gunbc-testgen // DO NOT EDIT - regenerate with: make testgen// Obligations: 13 obligations (3 discharged, 10 testable: A=4, B=3, C=3, D=0) // Proven by construction: acyclicity, type compatibility, cardinality satisfaction. -// Content-Hash: d53ea3a28fe8a076 +// Content-Hash: 17fbce4939166e58 use gunbc_exec::{execute_with_mode, BoundaryMocks, ExecutionMode}; @@ -170,7 +170,7 @@ fn test_example_prepare_openai_prepare_emits_rest_request_and_echoes_provider() assert_eq!(*output_provider, Value::Str("openai".to_string()), "expected exact value"); // Check output port 'request' let output_request = outputs.get("request").expect("output port 'request' should exist"); - assert!(!output_request.as_str().map(|s| s.is_empty()).unwrap_or(false), "expected non-empty"); + assert!(!output_request.is_empty(), "expected non-empty value"); } /// Node example: parse - OpenAI parse handles skipped transport response diff --git a/lib/llm-ops/src/generated_tests_anthropic.rs b/lib/llm-ops/src/generated_tests_anthropic.rs index 8cefe19ff77..81918a7ac1a 100644 --- a/lib/llm-ops/src/generated_tests_anthropic.rs +++ b/lib/llm-ops/src/generated_tests_anthropic.rs @@ -3,7 +3,7 @@ // Generated by gunbc-testgen // DO NOT EDIT - regenerate with: make testgen// Obligations: 13 obligations (3 discharged, 10 testable: A=4, B=3, C=3, D=0) // Proven by construction: acyclicity, type compatibility, cardinality satisfaction. -// Content-Hash: 343a48b5fa9d4e4f +// Content-Hash: 4d333da0584a8a46 use gunbc_exec::{execute_with_mode, BoundaryMocks, ExecutionMode}; @@ -170,7 +170,7 @@ fn test_example_prepare_anthropic_prepare_emits_rest_request_and_echoes_provider assert_eq!(*output_provider, Value::Str("anthropic".to_string()), "expected exact value"); // Check output port 'request' let output_request = outputs.get("request").expect("output port 'request' should exist"); - assert!(!output_request.as_str().map(|s| s.is_empty()).unwrap_or(false), "expected non-empty"); + assert!(!output_request.is_empty(), "expected non-empty value"); } /// Node example: parse - Anthropic parse handles skipped transport response diff --git a/lib/llm-ops/src/generated_tests_code_review.rs b/lib/llm-ops/src/generated_tests_code_review.rs index 7a55903afbf..2880ed7b82d 100644 --- a/lib/llm-ops/src/generated_tests_code_review.rs +++ b/lib/llm-ops/src/generated_tests_code_review.rs @@ -3,7 +3,7 @@ // Generated by gunbc-testgen // DO NOT EDIT - regenerate with: make testgen// Obligations: 13 obligations (3 discharged, 10 testable: A=4, B=3, C=3, D=0) // Proven by construction: acyclicity, type compatibility, cardinality satisfaction. -// Content-Hash: 51190722f07c86a4 +// Content-Hash: 890ac4deee380199 use gunbc_exec::{execute_with_mode, BoundaryMocks, ExecutionMode}; @@ -167,10 +167,10 @@ fn test_example_prepare_code_review_prepare_emits_rest_request() { // Check output port 'provider' let output_provider = outputs.get("provider").expect("output port 'provider' should exist"); - assert!(!output_provider.as_str().map(|s| s.is_empty()).unwrap_or(false), "expected non-empty"); + assert!(!output_provider.is_empty(), "expected non-empty value"); // Check output port 'request' let output_request = outputs.get("request").expect("output port 'request' should exist"); - assert!(!output_request.as_str().map(|s| s.is_empty()).unwrap_or(false), "expected non-empty"); + assert!(!output_request.is_empty(), "expected non-empty value"); } /// Node example: parse - Code review parse handles skipped transport response diff --git a/lib/llm-ops/src/generated_tests_secrets.rs b/lib/llm-ops/src/generated_tests_secrets.rs index c60ca7f37ce..599ed4bc88c 100644 --- a/lib/llm-ops/src/generated_tests_secrets.rs +++ b/lib/llm-ops/src/generated_tests_secrets.rs @@ -3,7 +3,7 @@ // Generated by gunbc-testgen // DO NOT EDIT - regenerate with: make testgen// Obligations: 13 obligations (3 discharged, 10 testable: A=4, B=3, C=3, D=0) // Proven by construction: acyclicity, type compatibility, cardinality satisfaction. -// Content-Hash: 39a6458b05ac8330 +// Content-Hash: ba0327d86f25fe3c use gunbc_exec::{execute_with_mode, BoundaryMocks, ExecutionMode}; @@ -195,10 +195,10 @@ fn test_example_prepare_secret_auth_prepare_emits_rest_request() { // Check output port 'provider' let output_provider = outputs.get("provider").expect("output port 'provider' should exist"); - assert!(!output_provider.as_str().map(|s| s.is_empty()).unwrap_or(false), "expected non-empty"); + assert!(!output_provider.is_empty(), "expected non-empty value"); // Check output port 'request' let output_request = outputs.get("request").expect("output port 'request' should exist"); - assert!(!output_request.as_str().map(|s| s.is_empty()).unwrap_or(false), "expected non-empty"); + assert!(!output_request.is_empty(), "expected non-empty value"); } /// Node example: parse - Secret auth parse handles skipped transport response