From 0a683da2af12c0cfe58c1a94b84f22a81d01bcb2 Mon Sep 17 00:00:00 2001 From: Santiago Date: Tue, 19 May 2026 09:32:37 -0300 Subject: [PATCH 1/3] feat: delegate low-level ops to the tx3c binary; drop tx3-* crates trix no longer links tx3-lang/tx3-tir. Every low-level Tx3 operation is delegated to the tx3c binary as a subprocess; the tx3c CLI surface is the versioned contract (driver/compiler split, cf. cargo<->rustc). This decouples trix's release cadence from the toolchain's. - spawn/compat.rs: per-tool version-window matrix + ensure_supported(), routed through every external-CLI chokepoint (tx3c, cshell, dolos). tx3c window is >= 0.18.0, < 0.19.0 (the release introducing decode / --emit tir-json / --diagnostics-format). TX3_SKIP_COMPAT_CHECK escape hatch for developing/CI against an unreleased toolchain. - check / inspect tir: delegate to tx3c (build --diagnostics-format json, build --emit tir-json, decode --emit tir-json); diagnostic rendering reconstructed from the JSON contract. - codegen: remove the in-process legacy module; the tx3c-delegating path is the only one. Built-in SDK plugins pinned to codegen-v1beta0. - Cargo.toml: drop tx3-lang/tx3-tir, add semver. - tests/e2e: nested codegen layout, ungate codegen_deps, skip-compat in the harness. - design/004-toolchain-delegation.md: records the architecture. Co-Authored-By: Claude Opus 4.7 (1M context) --- Cargo.lock | 62 +--- Cargo.toml | 11 +- design/004-toolchain-delegation.md | 123 +++++++ src/commands/check.rs | 46 ++- src/commands/codegen_legacy.rs | 506 ----------------------------- src/commands/inspect/tir.rs | 92 +----- src/commands/mod.rs | 10 +- src/config/convention.rs | 7 +- src/spawn/compat.rs | 116 +++++++ src/spawn/cshell.rs | 2 + src/spawn/dolos.rs | 2 + src/spawn/mod.rs | 10 + src/spawn/tx3c.rs | 111 ++++++- tests/e2e/codegen_deps.rs | 8 +- tests/e2e/happy_path.rs | 22 +- tests/e2e/mod.rs | 5 +- 16 files changed, 430 insertions(+), 703 deletions(-) create mode 100644 design/004-toolchain-delegation.md delete mode 100644 src/commands/codegen_legacy.rs create mode 100644 src/spawn/compat.rs diff --git a/Cargo.lock b/Cargo.lock index 9ea9440..6c6873a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -539,33 +539,6 @@ dependencies = [ "unicode-ident", ] -[[package]] -name = "ciborium" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42e69ffd6f0917f5c029256a24d0161db17cea3997d185db0d35926308770f0e" -dependencies = [ - "ciborium-io", - "ciborium-ll", - "serde", -] - -[[package]] -name = "ciborium-io" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05afea1e0a06c9be33d539b876f1ce3692f4afea2cb41f740e7743225ed1c757" - -[[package]] -name = "ciborium-ll" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57663b653d948a338bfb3eeba9bb2fd5fcfaecb9e199e87e1eda4d9e8b240fd9" -dependencies = [ - "ciborium-io", - "half", -] - [[package]] name = "cipher" version = "0.4.4" @@ -2888,9 +2861,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1db05f56d34358a8b1066f67cbb203ee3e7ed2ba674a6263a1d5ec6db2204323" dependencies = [ "memchr", - "miette", - "serde", - "serde_json", "thiserror 2.0.17", "ucd-trie", ] @@ -4474,6 +4444,7 @@ dependencies = [ "predicates", "prost", "reqwest", + "semver", "serde", "serde_json", "serde_with", @@ -4485,8 +4456,6 @@ dependencies = [ "toml", "tracing", "tracing-subscriber", - "tx3-lang", - "tx3-tir", "utxorpc", "zip", ] @@ -4497,35 +4466,6 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" -[[package]] -name = "tx3-lang" -version = "0.14.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22ccd68861b3f9fe5dc2df8ba4b258cc9a29ec23c17da0e1583ab11149e4aaf4" -dependencies = [ - "ciborium", - "hex", - "miette", - "pest", - "pest_derive", - "serde", - "thiserror 2.0.17", - "trait-variant", - "tx3-tir", -] - -[[package]] -name = "tx3-tir" -version = "0.14.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed1dfb5f68fde6e552fdb1877e309cec4b9a56469ba94068dabd026660b911c3" -dependencies = [ - "ciborium", - "hex", - "serde", - "thiserror 2.0.17", -] - [[package]] name = "typenum" version = "1.18.0" diff --git a/Cargo.toml b/Cargo.toml index 5170a55..2aa03a0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,13 +7,9 @@ repository = "https://github.com/tx3-lang/trix" homepage = "https://github.com/tx3-lang/trix" [dependencies] -tx3-lang = "0.14.2" -# tx3-lang = { path = "../tx3/crates/tx3-lang" } -# tx3-lang = { git = "https://github.com/tx3-lang/tx3.git" } - -tx3-tir = "0.14.2" -# tx3-tir = { path = "../tx3/crates/tx3-tir" } -# tx3-tir = { git = "https://github.com/tx3-lang/tx3.git" } +# Low-level Tx3 language operations (parse/analyze/lower, TIR decode) are +# delegated to the `tx3c` binary via `spawn::tx3c`, so `trix` links no +# `tx3-*` crate and its version is decoupled from the toolchain's. utxorpc = "0.12.0" # utxorpc = { git = "https://github.com/utxorpc/rust-sdk.git" } @@ -33,6 +29,7 @@ toml = "0.8" anyhow = "1.0" miette = { version = "7.5.0", features = ["fancy"] } thiserror = "2.0.12" +semver = "1.0" inquire = "0.7.5" dirs = "6.0.0" serde_json = "1.0.140" diff --git a/design/004-toolchain-delegation.md b/design/004-toolchain-delegation.md new file mode 100644 index 0000000..1fbd6a1 --- /dev/null +++ b/design/004-toolchain-delegation.md @@ -0,0 +1,123 @@ +# Toolchain Delegation: `trix` as a Driver, `tx3c` as the Compiler + +## Status + +Accepted — 2026-05-19. + +## Context + +`trix` is the Tx3 package manager and workspace orchestrator. Its value — +project layout, profiles, registry, devnet, codegen orchestration — evolves on +its own cadence. The Tx3 language implementation (parsing, analysis, lowering, +IR encoding) evolves on the toolchain's cadence, and its internal APIs are +unstable by design. + +Binding `trix` to the language implementation as linked libraries couples these +two cadences: a `trix` release would be pinned to a specific compiler/IR +version, and every compiler change would risk unrelated `trix` churn. The two +concerns must be able to move independently. + +## Decision + +**`trix` links no `tx3-*` crate. Every low-level Tx3 language operation is +delegated to the `tx3c` binary, invoked as a subprocess. The `tx3c` +command-line surface — its subcommands, flags, and JSON output — is the +contract between the two.** + +This is the driver/compiler split that `cargo`↔`rustc` uses: the stable, +user-facing verbs live in the driver (`trix`); the language implementation +lives behind a small, flag-driven compiler (`tx3c`) the driver shells out to. +The toolchain manager (`tx3up`) pairs compatible versions, as `rustup` does for +`cargo`/`rustc`. + +### `tx3c` surface + +`tx3c` exposes two symmetric verbs, each a single pipeline driven by `--emit`: + +- **`build `** — forward pipeline (source → artifact). `--emit tii` + produces the TII; `--emit tir-json --tx ` prints a transaction's + lowered v1beta0 TIR as JSON; an empty `--emit` runs the front end + (parse + analyze) and stops before lowering — the "check" semantic, modelled + on `rustc --emit=metadata`. `--diagnostics-format human|json` selects + rendering; `json` is the machine contract (mirrors + `rustc --error-format=json`). +- **`decode --tii `** — reverse pipeline (compiled artifact → report). + `--emit tir-json --tx ` decodes the artifact's TIR payload and prints + the same JSON shape as `build --emit tir-json`, so a consumer cannot tell + source-derived from artifact-derived IR (cf. `protoc --decode`, + `llvm-dis`). + +### Division of responsibility + +`tx3c` owns all language semantics. `trix` owns orchestration and +presentation: it selects what to compile, supplies project/profile context, +and renders results — reconstructing diagnostic output from the JSON contract, +and serializing IR through its own formatting path. No Tx3 semantics live in +`trix`. + +| `trix` flow | `tx3c` invocation | +|---|---| +| `check` | `build … --diagnostics-format json` (no `--emit`) | +| `inspect tir` (project) | `build … --emit tir-json --tx` | +| `inspect tir` (interface) | `decode --tii … --emit tir-json --tx` | +| `build` / `invoke` / `test` / `publish` | `build --emit tii` | +| `codegen` | `build` + `codegen` | + +## The contract and its versioning + +Because `trix` shares no types with the compiler, the `tx3c` CLI is an +interface that must be versioned. Two principles: + +1. **No in-band schema markers.** Versioning belongs to the surface, not to + each payload. Stamping every JSON message with a schema tag versions one + payload, not the contract, and bloats the wire. + +2. **Gate on the binary version, against a window.** Compatibility is a single + matrix (`trix`'s `spawn::compat`) of `Compat { tool, min, before }` — an + inclusive lower and *exclusive upper* bound. Both bounds matter: a too-old + binary lacks capabilities `trix` relies on; and because the toolchain is + pre-1.0 (a new minor may change the CLI), a too-new binary may have moved + the contract. `spawn::ensure_supported(tool)` probes ` --version`, + range-checks, caches per process, and fails with a distinct, actionable + message per direction (too old → run `tx3up`; too new → update `trix`). The + same matrix fronts other external tools (`cshell`, `dolos`), gated when + they need entries. + +JSON shapes are objects, not bare arrays, so they stay extensible: additive +fields are backward-compatible and need no version change; only breaking +changes do, paired with widening the matrix window. + +## Consequences + +**Positive** + +- `trix`'s version is decoupled from the toolchain's. The compiler ships on + its own cadence; `trix` follows only when it wants a new capability. +- The integration surface is small, explicit, and testable from the outside. +- Compiler-internal API instability cannot leak into `trix`. +- One place (`spawn::compat`) describes every external-tool compatibility + requirement. + +**Costs** + +- A process boundary per operation. Acceptable: operations are coarse-grained + (whole-project build/check), so spawn cost is negligible against the work. +- The CLI/JSON is a real interface with real discipline: breaking changes to + subcommands, flags, or JSON shapes require a `tx3c` version bump and a + matrix-window update, coordinated across repos. +- Release sequencing: a `tx3c` satisfying `trix`'s window must be published and + resolvable (via the toolchain dir / `tx3up`) before the corresponding `trix`. + +## Alternatives considered + +- **Link the language crates, track versions tightly.** This is the coupling + the decision exists to avoid; it keeps compiler API instability inside + `trix`. +- **Link the crates only for a subset of operations.** A partial dependency + still pins `trix` to a crate version and complicates the build; the marginal + in-process speedup is irrelevant for coarse operations. +- **In-band payload version markers.** Brittle (see above); superseded by + binary-version windowing. +- **A dedicated subcommand per operation (`check`, `decode-tir`, …).** Grows + the long-term CLI surface. Flags on `build` plus one symmetric `decode` verb + cover every need with minimal surface (the `rustc`/`protoc` precedent). diff --git a/src/commands/check.rs b/src/commands/check.rs index 76222cc..6e88af1 100644 --- a/src/commands/check.rs +++ b/src/commands/check.rs @@ -1,33 +1,49 @@ use crate::config::{ProfileConfig, RootConfig}; +use crate::spawn::tx3c; use clap::Args as ClapArgs; use miette::Diagnostic; -use miette::IntoDiagnostic as _; use thiserror::Error; +/// A single analyzer diagnostic, reconstructed from `tx3c`'s JSON contract. +/// `trix` owns the rendering (message + diagnostic code), so the human output +/// is unchanged even though the analysis now runs out-of-process. +#[derive(Debug, Error)] +#[error("{message}")] +struct Diag { + message: String, + code: Option, +} + +impl Diagnostic for Diag { + fn code<'a>(&'a self) -> Option> { + self.code + .as_ref() + .map(|c| Box::new(c.clone()) as Box) + } +} + #[derive(Debug, Error, Diagnostic)] #[error("check failed")] struct Error { #[related] - results: Vec, + results: Vec, } #[derive(ClapArgs, Debug)] pub struct Args {} pub fn run(_args: Args, config: &RootConfig, _profile: &ProfileConfig) -> miette::Result<()> { - let main_path = config.protocol.main.clone(); - - let content = std::fs::read_to_string(main_path).into_diagnostic()?; - - let mut program = tx3_lang::parsing::parse_string(&content)?; - - let diagnostic = tx3_lang::analyzing::analyze(&mut program); - - if !diagnostic.errors.is_empty() { - return Err(Error { - results: diagnostic.errors, - } - .into()); + let diagnostics = tx3c::check(&config.protocol.main)?; + + if !diagnostics.is_empty() { + let results = diagnostics + .into_iter() + .map(|d| Diag { + message: d.message, + code: d.code, + }) + .collect(); + return Err(Error { results }.into()); } println!("check passed, no errors found"); diff --git a/src/commands/codegen_legacy.rs b/src/commands/codegen_legacy.rs deleted file mode 100644 index c429599..0000000 --- a/src/commands/codegen_legacy.rs +++ /dev/null @@ -1,506 +0,0 @@ -use std::io::Read; -use std::{collections::HashMap, path::PathBuf}; - -use crate::config::{CodegenPluginConfig, ProfileConfig, RootConfig}; -use clap::Args as ClapArgs; -use miette::IntoDiagnostic; -use serde::{Serialize, Serializer}; - -use convert_case::{Case, Casing}; -use handlebars::{Context, Handlebars, Helper, Output, RenderContext, RenderErrorReason}; -use reqwest::Client; -use tempfile::TempDir; -use tx3_lang::Workspace; -use zip::ZipArchive; - -use tx3_tir::model::core::Type as TirType; - -#[derive(ClapArgs, Debug)] -pub struct Args {} - -/// Structure returned by load_github_templates containing handlebars and optional config -struct TemplateBundle { - handlebars: Handlebars<'static>, - static_files: Vec<(String, String)>, -} - -fn make_helper(name: &'static str, f: F) -> impl handlebars::HelperDef + Send + Sync + 'static -where - F: Fn(&str) -> String + Send + Sync + 'static, -{ - move |h: &Helper, _: &Handlebars, _: &Context, _: &mut RenderContext, out: &mut dyn Output| { - let param = h - .param(0) - .ok_or_else(|| RenderErrorReason::ParamNotFoundForIndex(name, 0))?; - let input = param - .value() - .as_str() - .ok_or_else(|| RenderErrorReason::InvalidParamType("Expected a string"))?; - out.write(&f(input))?; - Ok(()) - } -} - -fn parse_type_from_string(type_str: &str) -> Result { - match type_str { - "Int" => Ok(TirType::Int), - "Bool" => Ok(TirType::Bool), - "Bytes" => Ok(TirType::Bytes), - "Unit" => Ok(TirType::Unit), - "Address" => Ok(TirType::Address), - "UtxoRef" => Ok(TirType::UtxoRef), - "AnyAsset" => Ok(TirType::AnyAsset), - "Utxo" => Ok(TirType::Utxo), - "Undefined" => Ok(TirType::Undefined), - "List" => Ok(TirType::List), - x => Ok(TirType::Custom(x.to_string())), - } -} - -fn get_type_for_language(type_: &TirType, language: &str) -> String { - match language { - "rust" => "ArgValue".to_string(), - "typescript" => match &type_ { - TirType::Int => "bigint | number".to_string(), - TirType::Bool => "boolean".to_string(), - TirType::Bytes => "Uint8Array".to_string(), - TirType::Unit => "void".to_string(), - TirType::Address => "string".to_string(), - TirType::UtxoRef => "string".to_string(), - TirType::List => "any[]".to_string(), - TirType::Custom(name) => name.clone(), - TirType::AnyAsset => "string".to_string(), - TirType::Utxo => "any".to_string(), - TirType::Undefined => "any".to_string(), - TirType::Map => "any".to_string(), - }, - "python" => match &type_ { - TirType::Int => "int".to_string(), - TirType::Bool => "bool".to_string(), - TirType::Bytes => "bytes".to_string(), - TirType::Unit => "None".to_string(), - TirType::List => "list[Any]".to_string(), - TirType::Address => "str".to_string(), - TirType::UtxoRef => "str".to_string(), - TirType::Custom(name) => name.clone(), - TirType::AnyAsset => "str".to_string(), - TirType::Undefined => "Any".to_string(), - TirType::Utxo => "Any".to_string(), - TirType::Map => "Any".to_string(), - }, - "go" => match &type_ { - TirType::Int => "int64".to_string(), - TirType::Bool => "bool".to_string(), - TirType::Bytes => "[]byte".to_string(), - TirType::Unit => "struct{}".to_string(), - TirType::Address => "string".to_string(), - TirType::UtxoRef => "string".to_string(), - TirType::List => "[]interface{}".to_string(), - TirType::Custom(name) => name.clone(), - TirType::AnyAsset => "string".to_string(), - TirType::Utxo => "interface{}".to_string(), - TirType::Undefined => "interface{}".to_string(), - TirType::Map => "interface{}".to_string(), - }, - _ => "ArgValue".to_string(), // Default fallback - } -} - -// Register any custom helpers here -/// An array of helper functions for converting strings to various case styles. -/// -/// Each tuple in the array consists of: -/// - A string slice representing the name of the case style (e.g., "pascalCase"). -/// - A function pointer that takes a string slice and returns a `String` converted to the corresponding case style. -/// -/// These helpers are useful for dynamically applying different case transformations to strings, -/// such as converting identifiers to PascalCase, camelCase, CONSTANT_CASE, snake_case, or lower case. -fn register_handlebars_helpers(handlebars: &mut Handlebars<'_>) { - #[allow(clippy::type_complexity)] - let helpers: &[(&str, fn(&str) -> String)] = &[ - ("pascalCase", |s| s.to_case(Case::Pascal)), - ("camelCase", |s| s.to_case(Case::Camel)), - ("constantCase", |s| s.to_case(Case::Constant)), - ("snakeCase", |s| s.to_case(Case::Snake)), - ("lowerCase", |s| s.to_case(Case::Lower)), - ]; - - for (name, func) in helpers { - handlebars.register_helper(name, Box::new(make_helper(name, func))); - } - // Add more helpers as needed - - // Register helper to convert ir types to language types. - handlebars.register_helper( - "typeFor", - Box::new( - |h: &Helper, - _: &Handlebars, - _: &Context, - _: &mut RenderContext, - out: &mut dyn Output| { - let type_param = h - .param(0) - .ok_or_else(|| RenderErrorReason::ParamNotFoundForIndex("typeFor", 0))?; - let lang_param = h - .param(1) - .ok_or_else(|| RenderErrorReason::ParamNotFoundForIndex("typeFor", 1))?; - - let type_str = type_param.value().as_str().ok_or_else(|| { - RenderErrorReason::InvalidParamType("Expected type as string") - })?; - - let type_ = parse_type_from_string(type_str) - .map_err(|_| RenderErrorReason::InvalidParamType("Failed to parse type"))?; - - let language = lang_param.value().as_str().ok_or_else(|| { - RenderErrorReason::InvalidParamType("Expected language as string") - })?; - - let output_type = get_type_for_language(&type_, language); - - out.write(&output_type)?; - Ok(()) - }, - ), - ); -} - -/// Loads Handlebars templates from a GitHub repository ZIP archive. -/// -/// This function: -/// 1. Parses the GitHub URL in the format 'owner/repo' or 'owner/repo/branch' -/// 2. Downloads the repository as a ZIP file from GitHub -/// 3. Extracts the ZIP to a temporary directory -/// 4. Finds all `.hbs` files inside any `bindgen` directory in the archive -/// 5. Optionally loads a `trix-bindgen.toml` file from the `bindgen` directory -/// 6. Registers each found template with Handlebars, using its path relative to `bindgen/` (without the `.hbs` extension) -/// -/// Returns a TemplateBundle containing the Handlebars registry and optional configuration. -async fn load_github_templates( - github_url: &str, - temp_dir: &TempDir, - path: &str, -) -> miette::Result { - // Parse GitHub URL - let parts: Vec<&str> = github_url.split('/').collect(); - if parts.len() < 2 { - return Err(miette::miette!( - "Invalid GitHub URL format. Use 'owner/repo' or 'owner/repo/branch'" - )); - } - - let owner = parts[0]; - let repo = parts[1]; - let branch = if parts.len() > 2 { parts[2] } else { "main" }; - - // Create a zip download URL - let zip_url = format!( - "https://github.com/{}/{}/archive/{}.zip", - owner, repo, branch - ); - - println!( - "Reading template from https://github.com/{}/{} (ref: {})", - owner, repo, branch - ); - - // Download the zip file - let client = Client::new(); - let response = client.get(&zip_url).send().await.into_diagnostic()?; - - if !response.status().is_success() { - return Err(miette::miette!( - "Failed to download GitHub repository: HTTP {}", - response.status() - )); - } - - let zip_path = temp_dir.path().join("bindgen-template.zip"); - - // Save the zip file - let content = response.bytes().await.into_diagnostic()?; - std::fs::write(&zip_path, &content).into_diagnostic()?; - - // Extract the zip file - let file = std::fs::File::open(&zip_path).into_diagnostic()?; - let mut archive = ZipArchive::new(file).into_diagnostic()?; - - let mut bindgen_path = PathBuf::new(); - - // Get root_dir - let root_dir_name = archive.name_for_index(0).unwrap_or(""); - - bindgen_path.push(root_dir_name); - bindgen_path.push(path); - // Ensure the bindgen path ends with a separator - bindgen_path.push(""); - - // let mut config: Option = None; - // Check for trix-bindgen.toml in the directory - // let toml_name = bindgen_path.join("trix-bindgen.toml").to_string_lossy().to_string(); - - // if let Ok(mut config_file) = archive.by_name(&toml_name) { - // let mut config_content = String::new(); - // config_file.read_to_string(&mut config_content).into_diagnostic()?; - - // config = toml::from_str::(&config_content) - // .into_diagnostic() - // .ok(); - // } - - // Register handlebars templates - let mut handlebars = Handlebars::new(); - let mut static_files = Vec::new(); - - let bindgen_path_string = bindgen_path.to_string_lossy().to_string(); - let archive_bindgen_index = archive.index_for_name(&bindgen_path_string).unwrap_or(0); - - // Skip files that are not in the bindgen_path or are the bindgen_path itself - for i in archive_bindgen_index..archive.len() { - let mut file = archive.by_index(i).into_diagnostic()?; - let name = file.name().to_owned(); - - if !name.starts_with(&bindgen_path_string) { - break; // Stop processing if we reach a file outside the bindgen path - } - - // If the file is a directory or its the trix-bindgen.toml, skip it - if file.is_dir() || name.ends_with("trix-bindgen.toml") { - continue; - } - - // Remove everything before "bindgen/" and strip ".hbs" extension - let template_name = name.strip_prefix(&bindgen_path_string).unwrap_or(&name); - - if name.ends_with(".hbs") { - let template_name = template_name.strip_suffix(".hbs").unwrap_or(&name); - - let mut template_content = String::new(); - file.read_to_string(&mut template_content) - .into_diagnostic()?; - - // Register handlebars template - handlebars - .register_template_string(template_name, template_content) - .into_diagnostic()?; - - // println!("Registered template: {}", template_name); - continue; - } - - if file.is_file() { - let dest_path = temp_dir.path().join(template_name); - if let Some(parent) = dest_path.parent() { - std::fs::create_dir_all(parent).into_diagnostic()?; - } - let mut out_file = std::fs::File::create(&dest_path).into_diagnostic()?; - std::io::copy(&mut file, &mut out_file).into_diagnostic()?; - static_files.push((dest_path.display().to_string(), template_name.to_string())); - } - } - - register_handlebars_helpers(&mut handlebars); - - Ok(TemplateBundle { - handlebars, - static_files, - }) -} - -struct BytesHex(Vec); - -impl Serialize for BytesHex { - fn serialize(&self, serializer: S) -> Result - where - S: Serializer, - { - serializer.serialize_str(&hex::encode(&self.0)) - } -} - -#[derive(Serialize)] -struct TxParameter { - name: String, - type_name: tx3_tir::model::core::Type, -} - -#[derive(Serialize)] -struct Transaction { - name: String, - params_name: String, - function_name: String, - constant_name: String, - ir_bytes: BytesHex, - ir_version: String, - parameters: Vec, -} - -#[derive(Serialize)] -#[serde(rename_all = "camelCase")] -struct HandlebarsData { - protocol_name: String, - protocol_version: String, - trp_endpoint: String, - transactions: Vec, - headers: HashMap, - env_vars: HashMap, - options: HashMap, -} - -struct Job<'a> { - name: String, - workspace: &'a Workspace, - dest_path: PathBuf, - trp_endpoint: String, - trp_headers: HashMap, - env_args: HashMap, - options: HashMap, -} - -fn generate_arguments(job: &Job, version: &str) -> miette::Result { - let ast = job - .workspace - .ast() - .ok_or(miette::miette!("No AST available in workspace"))?; - - let transactions = ast - .txs - .iter() - .map(|tx_def| { - let tx_name = tx_def.name.value.as_str(); - let tx_tir = job.workspace.tir(tx_def.name.value.as_str()).unwrap(); - - let parameters: Vec = tx3_tir::reduce::find_params(tx_tir) - .iter() - .map(|(key, type_)| TxParameter { - name: key.as_str().to_case(Case::Camel), - type_name: type_.clone(), - }) - .collect(); - - let (tx_bytes, version) = tx3_tir::encoding::to_bytes(tx_tir); - - Transaction { - name: tx_name.to_string(), - params_name: format!("{}Params", tx_name).to_case(Case::Camel), - function_name: format!("{}Tx", tx_name).to_case(Case::Camel), - constant_name: format!("{}Ir", tx_name).to_case(Case::Camel), - ir_bytes: BytesHex(tx_bytes), - ir_version: version.to_string(), - parameters, - } - }) - .collect(); - - let headers = job - .trp_headers - .iter() - .map(|(key, value)| (key.clone(), value.clone())) - .collect::>(); - - let env_vars = job - .env_args - .iter() - .map(|(key, value)| (key.clone(), value.clone())) - .collect::>(); - - Ok(HandlebarsData { - protocol_name: job.name.clone(), - protocol_version: version.to_string(), - trp_endpoint: job.trp_endpoint.clone(), - transactions, - headers, - env_vars, - options: job.options.clone(), - }) -} - -async fn execute_bindgen( - job: &Job<'_>, - template_config: &CodegenPluginConfig, - version: &str, -) -> miette::Result<()> { - // Create a temporary directory to extract files - let temp_dir = TempDir::new().into_diagnostic()?; - let github_url = format!( - "{}/{}", - &template_config.repo, - template_config.r#ref.as_deref().unwrap_or("main") - ); - - let template_bundle = - load_github_templates(&github_url, &temp_dir, &template_config.path).await?; - - // Create the destination directory if it doesn't exist - std::fs::create_dir_all(&job.dest_path).into_diagnostic()?; - - let handlebars_params = generate_arguments(job, version)?; - - let handlebars_template_iter = template_bundle.handlebars.get_templates().iter(); - - for (name, _) in handlebars_template_iter { - let template_content = template_bundle - .handlebars - .render(name, &handlebars_params) - .unwrap(); - if template_content.is_empty() { - // Skip empty templates - continue; - } - let output_path = job.dest_path.join(name); - if let Some(parent) = output_path.parent() { - // Create parent directories if they don't exist - std::fs::create_dir_all(parent).into_diagnostic()?; - } - std::fs::write(&output_path, template_content).into_diagnostic()?; - // println!("Generated file: {}", output_path.display()); - } - - // Copy static files to the destination directory - for (src_path, file_destination) in &template_bundle.static_files { - let dest_path = job.dest_path.join(file_destination); - if let Some(parent) = dest_path.parent() { - std::fs::create_dir_all(parent).into_diagnostic()?; - } - std::fs::copy(src_path, dest_path).into_diagnostic()?; - // println!("Copied static file: {}", dest_path.display()); - } - - Ok(()) -} - -pub async fn run(_args: Args, config: &RootConfig, profile: &ProfileConfig) -> miette::Result<()> { - let mut ws = Workspace::from_file(&config.protocol.main)?; - - ws.parse()?; - ws.analyze()?; - ws.lower()?; - - for codegen in config.codegen.iter() { - let output_dir = codegen.output_dir()?; - - std::fs::create_dir_all(&output_dir).into_diagnostic()?; - - let plugin = CodegenPluginConfig::from(codegen.plugin.clone()); - - let network = config.resolve_profile_network(profile.name.as_str())?; - - let trp_config = &network.trp; - - let job = Job { - name: config.protocol.name.clone(), - workspace: &ws, - dest_path: output_dir, - trp_endpoint: trp_config.url.clone(), - trp_headers: trp_config.headers.clone(), - env_args: HashMap::new(), - options: codegen.options.clone().unwrap_or_default(), - }; - - execute_bindgen(&job, &plugin, &config.protocol.version).await?; - println!("Bindgen successful"); - } - - Ok(()) -} diff --git a/src/commands/inspect/tir.rs b/src/commands/inspect/tir.rs index d6a32a3..7bed005 100644 --- a/src/commands/inspect/tir.rs +++ b/src/commands/inspect/tir.rs @@ -1,12 +1,10 @@ -use std::collections::HashMap; - use clap::Args as ClapArgs; -use miette::{Context as _, IntoDiagnostic as _}; -use serde::Deserialize; +use miette::IntoDiagnostic as _; use crate::config::RootConfig; use crate::interfaces::{self, ResolvedProtocol, Resolver}; use crate::refs::TxRef; +use crate::spawn::tx3c; #[derive(ClapArgs)] pub struct Args { @@ -21,34 +19,6 @@ pub struct Args { pretty: bool, } -// Minimal mirror of the parts of the published TII we need to recover a -// transaction's TIR. The cached `.tii` is the *normative* artifact; we never -// re-derive IR from the (informative) cached `.tx3` source. -#[derive(Deserialize)] -struct TiiLite { - transactions: HashMap, -} - -#[derive(Deserialize)] -struct TxLite { - tir: TirEnvelopeLite, -} - -#[derive(Deserialize)] -struct TirEnvelopeLite { - content: String, - #[serde(rename = "contentType", alias = "encoding")] - encoding: TirEncodingLite, - version: String, -} - -#[derive(Deserialize)] -#[serde(rename_all = "lowercase")] -enum TirEncodingLite { - Hex, - Base64, -} - pub fn run(args: Args, config: &RootConfig) -> miette::Result<()> { // `inspect tir` is a consuming command: it may target an interface, so it // applies the interface integrity gate up front, exactly like `invoke` @@ -60,21 +30,15 @@ pub fn run(args: Args, config: &RootConfig) -> miette::Result<()> { let (resolved, tx_name) = resolver.resolve_tx(&args.tx)?; let ir = match resolved { - // The author's own source is normative for the project. + // The author's own source is normative for the project; `tx3c` lowers + // it. An interface is consumed via its published TII, whose encoded + // TIR `tx3c` decodes. Both paths yield the same JSON shape, so the + // caller can't tell which protocol it came from. ResolvedProtocol::Project => { - let content = - std::fs::read_to_string(&config.protocol.main).into_diagnostic()?; - let mut ast = tx3_lang::parsing::parse_string(&content)?; - tx3_lang::analyzing::analyze(&mut ast).ok()?; - let lowered = tx3_lang::lowering::lower(&ast, tx_name) - .into_diagnostic() - .with_context(|| format!("lowering {}", tx_name))?; - serde_json::to_value(&lowered).into_diagnostic()? + tx3c::tir_from_source(&config.protocol.main, tx_name)? } - // An interface is consumed via its published TII; it carries the - // encoded TIR per transaction, so no source compilation is involved. ResolvedProtocol::Interface(entry) => { - tir_from_interface(interfaces::cache_paths(entry)?.tii, tx_name)? + tx3c::decode_tir(&interfaces::cache_paths(entry)?.tii, tx_name)? } }; @@ -86,43 +50,3 @@ pub fn run(args: Args, config: &RootConfig) -> miette::Result<()> { Ok(()) } - -/// Decode `tx_name`'s TIR straight out of an interface's cached, published -/// `.tii`. Returns the same JSON shape the project path emits (the inner -/// `v1beta0` tx), so callers can't tell which protocol it came from. -fn tir_from_interface( - tii_path: std::path::PathBuf, - tx_name: &str, -) -> miette::Result { - let bytes = std::fs::read(&tii_path).into_diagnostic()?; - let tii: TiiLite = serde_json::from_slice(&bytes) - .into_diagnostic() - .context("parsing cached TII")?; - - let envelope = tii - .transactions - .get(tx_name) - .map(|t| &t.tir) - .ok_or_else(|| miette::miette!("transaction '{}' not found in interface", tx_name))?; - - let raw = match envelope.encoding { - TirEncodingLite::Hex => hex::decode(&envelope.content) - .into_diagnostic() - .context("decoding hex TIR")?, - TirEncodingLite::Base64 => { - return Err(miette::miette!( - "interface TIR is base64-encoded, which this trix does not support; \ - ask the publisher to re-`trix publish`" - )); - } - }; - - let version = tx3_tir::encoding::TirVersion::try_from(envelope.version.as_str()) - .map_err(|e| miette::miette!("unsupported TIR version: {e}"))?; - - let any = tx3_tir::encoding::from_bytes(&raw, version) - .map_err(|e| miette::miette!("decoding TIR: {e}"))?; - - let tx3_tir::encoding::AnyTir::V1Beta0(tx) = any; - serde_json::to_value(&tx).into_diagnostic() -} diff --git a/src/commands/mod.rs b/src/commands/mod.rs index b39bfba..b843a73 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -1,5 +1,6 @@ pub mod build; pub mod check; +pub mod codegen; pub mod devnet; pub mod expect; pub mod explore; @@ -12,12 +13,3 @@ pub mod publish; pub mod telemetry; pub mod test; pub mod use_cmd; - -#[cfg(feature = "unstable")] -pub mod codegen; - -#[cfg(not(feature = "unstable"))] -pub mod codegen_legacy; - -#[cfg(not(feature = "unstable"))] -pub use codegen_legacy as codegen; diff --git a/src/config/convention.rs b/src/config/convention.rs index eb807d6..f0be172 100644 --- a/src/config/convention.rs +++ b/src/config/convention.rs @@ -233,10 +233,9 @@ impl std::fmt::Display for KnownCodegenPlugin { } } -#[cfg(not(feature = "unstable"))] -const CURRENT_CODEGEN_VERSION: &str = "bindgen-v1alpha2"; - -#[cfg(feature = "unstable")] +// Codegen is delegated entirely to `tx3c`; the built-in SDK plugins are +// pinned to the `codegen-v1beta0` bindgen templates that match it. (The old +// `bindgen-v1alpha2` ref went with the now-removed legacy in-process codegen.) const CURRENT_CODEGEN_VERSION: &str = "codegen-v1beta0"; impl From for CodegenPluginConfig { diff --git a/src/spawn/compat.rs b/src/spawn/compat.rs new file mode 100644 index 0000000..3d96b95 --- /dev/null +++ b/src/spawn/compat.rs @@ -0,0 +1,116 @@ +//! Compatibility matrix and version gating for the external CLIs `trix` +//! drives. +//! +//! `trix` links no `tx3-*` crate; it orchestrates the toolchain binaries +//! (`tx3c`, `cshell`, `dolos`) as subprocesses. Each tool's CLI surface — +//! subcommands, flags, JSON output — is the versioned contract. Rather than +//! embed markers in payloads, we gate on the binary's own `--version` against +//! a supported window. This module owns that mechanism and the single matrix +//! for every integration. + +use std::collections::HashMap; +use std::process::Command; +use std::sync::{Mutex, OnceLock}; + +/// The supported version window for one external CLI: `min` is the inclusive +/// lower bound, `before` the exclusive upper bound (`min <= v < before`). +/// +/// The window is bounded on both ends on purpose: too-old binaries lack +/// capabilities `trix` relies on, and — since the toolchain is pre-1.0, where +/// a new minor may break the CLI — a too-new binary may have changed the +/// contract out from under us. Widen the window when `trix` is updated to +/// track a new release. +struct Compat { + tool: &'static str, + min: &'static str, + before: &'static str, +} + +const COMPAT_MATRIX: &[Compat] = &[ + // 0.18.0 introduced `decode`, `--emit tir-json`, `--diagnostics-format` + // (0.17.0 was cut before that surface existed). + Compat { + tool: "tx3c", + min: "0.18.0", + before: "0.19.0", + }, +]; + +fn entry(tool: &str) -> Option<&'static Compat> { + COMPAT_MATRIX.iter().find(|c| c.tool == tool) +} + +/// Probe ` --version` and confirm it falls within the supported window +/// in [`COMPAT_MATRIX`]. +/// +/// A no-op for tools not in the matrix. Cached per tool: a toolchain can't +/// change mid-process, so each tool is probed at most once. Call this before +/// the first subprocess invocation of any gated tool. +/// +/// Escape hatch: setting `TX3_SKIP_COMPAT_CHECK` to a non-empty value bypasses +/// the window. This exists for developing/CI-testing against an *unreleased* +/// toolchain — a locally built `tx3c` carries the new CLI surface but still +/// reports the pre-bump version until its release is cut. Not for end users. +pub fn ensure_supported(tool: &str) -> miette::Result<()> { + if std::env::var_os("TX3_SKIP_COMPAT_CHECK").is_some_and(|v| !v.is_empty()) { + return Ok(()); + } + + let Some(c) = entry(tool) else { + return Ok(()); + }; + + static CACHE: OnceLock>>> = OnceLock::new(); + let cache = CACHE.get_or_init(|| Mutex::new(HashMap::new())); + + let cached = cache.lock().unwrap().get(c.tool).cloned(); + let result = match cached { + Some(r) => r, + None => { + let r = check(c); + cache.lock().unwrap().insert(c.tool, r.clone()); + r + } + }; + + result.map_err(|m| miette::miette!("incompatible tx3 toolchain: {m}")) +} + +fn check(c: &Compat) -> Result<(), String> { + let tool = c.tool; + let path = crate::home::tool_path(tool).map_err(|e| e.to_string())?; + + let output = Command::new(&path) + .arg("--version") + .output() + .map_err(|e| format!("could not run `{tool} --version`: {e}"))?; + + if !output.status.success() { + return Err(format!("`{tool} --version` exited with an error")); + } + + // clap-based tools print ` `. + let stdout = String::from_utf8_lossy(&output.stdout); + let raw = stdout.split_whitespace().last().unwrap_or("").trim(); + let found = semver::Version::parse(raw) + .map_err(|e| format!("cannot parse {tool} version from {stdout:?}: {e}"))?; + + let min = semver::Version::parse(c.min).expect("valid matrix const"); + let before = semver::Version::parse(c.before).expect("valid matrix const"); + + if found < min { + return Err(format!( + "your {tool} is {found}, but this trix requires {tool} >= {min}. \ + Run `tx3up` to update your tx3 toolchain." + )); + } + + if found >= before { + return Err(format!( + "your {tool} is {found}, newer than this trix supports \ + ({tool} >= {min}, < {before}). Update trix (or pin an older {tool})." + )); + } + + Ok(()) +} diff --git a/src/spawn/cshell.rs b/src/spawn/cshell.rs index 62261bf..4f98437 100644 --- a/src/spawn/cshell.rs +++ b/src/spawn/cshell.rs @@ -85,6 +85,8 @@ pub struct CshellTomlTemplate { } fn new_generic_command(home: &Path) -> miette::Result { + crate::spawn::ensure_supported("cshell")?; + let tool_path = crate::home::tool_path("cshell")?; let config_path = home.join("cshell.toml"); diff --git a/src/spawn/dolos.rs b/src/spawn/dolos.rs index 552595e..e7a86a9 100644 --- a/src/spawn/dolos.rs +++ b/src/spawn/dolos.rs @@ -56,6 +56,8 @@ pub fn initialize_config( } pub fn daemon(home: &Path, silent: bool) -> miette::Result { + crate::spawn::ensure_supported("dolos")?; + let tool_path = crate::home::tool_path("dolos")?; let config_path = home.join("dolos.toml"); diff --git a/src/spawn/mod.rs b/src/spawn/mod.rs index c244d86..92f6cd8 100644 --- a/src/spawn/mod.rs +++ b/src/spawn/mod.rs @@ -1,3 +1,13 @@ +//! Spawning the external CLIs `trix` orchestrates. +//! +//! `trix` links no `tx3-*` crate; it drives the toolchain binaries (`tx3c`, +//! `cshell`, `dolos`) as subprocesses. Version compatibility for every +//! integration lives in [`compat`]; each spawn path calls +//! [`ensure_supported`] at its command chokepoint before invoking the tool. + +pub mod compat; pub mod cshell; pub mod dolos; pub mod tx3c; + +pub use compat::ensure_supported; diff --git a/src/spawn/tx3c.rs b/src/spawn/tx3c.rs index 4c895ad..da9470d 100644 --- a/src/spawn/tx3c.rs +++ b/src/spawn/tx3c.rs @@ -1,13 +1,43 @@ use std::{path::Path, process::Command}; use miette::{bail, Context as _, IntoDiagnostic as _}; +use serde::Deserialize; use crate::config::RootConfig; +use crate::spawn::ensure_supported; + +/// One analyzer diagnostic, as emitted by `tx3c … --diagnostics-format json`. +/// Its shape is part of the `tx3c` CLI contract, gated by the compatibility +/// matrix in [`crate::spawn`]. +#[derive(Debug, Deserialize)] +pub struct Diagnostic { + pub severity: String, + #[serde(default)] + pub code: Option, + pub message: String, + #[serde(default)] + pub span: Option, +} -pub fn build_tii(source: &Path, output: &Path, config: &RootConfig) -> miette::Result<()> { +#[derive(Debug, Deserialize)] +pub struct DiagnosticSpan { + pub start: usize, + pub end: usize, +} + +#[derive(Debug, Deserialize)] +struct DiagnosticsEnvelope { + diagnostics: Vec, +} + +fn tx3c() -> miette::Result { + ensure_supported("tx3c")?; let tool_path = crate::home::tool_path("tx3c")?; + Ok(Command::new(tool_path.to_str().unwrap_or_default())) +} - let mut cmd = Command::new(tool_path.to_str().unwrap_or_default()); +pub fn build_tii(source: &Path, output: &Path, config: &RootConfig) -> miette::Result<()> { + let mut cmd = tx3c()?; cmd.args(["build", source.to_str().unwrap()]); cmd.args(["--emit", "tii"]); @@ -45,9 +75,7 @@ pub fn build_tii(source: &Path, output: &Path, config: &RootConfig) -> miette::R } pub fn codegen(tii_path: &Path, templates: &Path, output: &Path) -> miette::Result<()> { - let tool_path = crate::home::tool_path("tx3c")?; - - let mut cmd = Command::new(tool_path.to_str().unwrap_or_default()); + let mut cmd = tx3c()?; cmd.args(["codegen", "--tii", tii_path.to_str().unwrap()]); cmd.args(["--template", templates.to_str().unwrap()]); @@ -64,3 +92,76 @@ pub fn codegen(tii_path: &Path, templates: &Path, output: &Path) -> miette::Resu Ok(()) } + +/// Run the front end over `source` (parse + analyze, no lowering, no +/// artifact) and return the analyzer diagnostics. Empty ⇒ the check passed. +/// +/// `tx3c` exits non-zero when there are errors but still writes the envelope +/// to stdout, so a non-zero status is *not* a spawn failure here — we parse +/// stdout regardless and only treat an unparseable/empty stream as one. +pub fn check(source: &Path) -> miette::Result> { + let mut cmd = tx3c()?; + cmd.args(["build", source.to_str().unwrap()]); + cmd.args(["--diagnostics-format", "json"]); + + let output = cmd + .output() + .into_diagnostic() + .context("running tx3c check")?; + + let envelope: DiagnosticsEnvelope = serde_json::from_slice(&output.stdout) + .into_diagnostic() + .with_context(|| { + format!( + "parsing tx3c diagnostics (stderr: {})", + String::from_utf8_lossy(&output.stderr).trim() + ) + })?; + + Ok(envelope.diagnostics) +} + +/// Capture the stdout of a `tx3c` invocation that prints a single JSON value, +/// bailing with stderr on a non-zero exit. Used by the TIR-inspection paths. +fn capture_json(mut cmd: Command, what: &str) -> miette::Result { + let output = cmd + .output() + .into_diagnostic() + .with_context(|| format!("running tx3c {what}"))?; + + if !output.status.success() { + bail!( + "tx3c {what} failed: {}", + String::from_utf8_lossy(&output.stderr).trim() + ); + } + + serde_json::from_slice(&output.stdout) + .into_diagnostic() + .with_context(|| format!("parsing tx3c {what} output")) +} + +/// Lower `tx_name` from project `source` and return its v1beta0 TIR as JSON. +pub fn tir_from_source( + source: &Path, + tx_name: &str, +) -> miette::Result { + let mut cmd = tx3c()?; + cmd.args(["build", source.to_str().unwrap()]); + cmd.args(["--emit", "tir-json"]); + cmd.args(["--tx", tx_name]); + capture_json(cmd, "tir-json") +} + +/// Decode `tx_name`'s TIR out of a published `.tii`. Same JSON shape as +/// [`tir_from_source`], so callers can't tell source from artifact. +pub fn decode_tir( + tii_path: &Path, + tx_name: &str, +) -> miette::Result { + let mut cmd = tx3c()?; + cmd.args(["decode", "--tii", tii_path.to_str().unwrap()]); + cmd.args(["--emit", "tir-json"]); + cmd.args(["--tx", tx_name]); + capture_json(cmd, "decode") +} diff --git a/tests/e2e/codegen_deps.rs b/tests/e2e/codegen_deps.rs index 17597be..fa1d85b 100644 --- a/tests/e2e/codegen_deps.rs +++ b/tests/e2e/codegen_deps.rs @@ -1,8 +1,6 @@ -//! Interface-aware codegen lives only in the unstable codegen path -//! (`src/commands/codegen.rs`). These tests are compiled and run only under -//! `cargo test --features unstable`, where `assert_cmd::cargo_bin` resolves -//! the unstable-built `trix` binary. They require a real `tx3c` (like -//! `happy_path::codegen_generates_bindings_from_fixture`). +//! Interface-aware codegen (`src/commands/codegen.rs`), which delegates the +//! whole pipeline to the `tx3c` binary. These tests require a real `tx3c` +//! (like `happy_path::codegen_generates_bindings_from_fixture`). use super::*; use std::path::PathBuf; diff --git a/tests/e2e/happy_path.rs b/tests/e2e/happy_path.rs index 1c593ce..a003802 100644 --- a/tests/e2e/happy_path.rs +++ b/tests/e2e/happy_path.rs @@ -170,13 +170,23 @@ fn codegen_generates_bindings_from_fixture() { )); ctx.write_file("trix.toml", &trix_toml); + let project_name = ctx.load_trix_config().protocol.name; + let result = ctx.run_trix(&["codegen"]); assert_success(&result); - ctx.assert_file_exists("gen/bindings.txt"); - ctx.assert_file_contains("gen/bindings.txt", "Protocol:"); - ctx.assert_file_contains("gen/bindings.txt", "Transactions:"); - ctx.assert_file_contains("gen/bindings.txt", "transfer"); - ctx.assert_file_contains("gen/bindings.txt", "Profiles:"); - ctx.assert_file_contains("gen/bindings.txt", "local"); + // Output nests under the project's own subdir (the deliberate layout of + // the tx3c-delegating codegen, see `codegen_deps`); nothing is written + // flat at `gen/bindings.txt`. + let bindings = format!("gen/{project_name}/bindings.txt"); + ctx.assert_file_exists(&bindings); + ctx.assert_file_contains(&bindings, "Protocol:"); + ctx.assert_file_contains(&bindings, "Transactions:"); + ctx.assert_file_contains(&bindings, "transfer"); + ctx.assert_file_contains(&bindings, "Profiles:"); + ctx.assert_file_contains(&bindings, "local"); + assert!( + !ctx.file_path("gen/bindings.txt").exists(), + "unified layout: nothing should be written flat at gen/bindings.txt" + ); } diff --git a/tests/e2e/mod.rs b/tests/e2e/mod.rs index 997bc82..b4f8eeb 100644 --- a/tests/e2e/mod.rs +++ b/tests/e2e/mod.rs @@ -77,6 +77,10 @@ impl TestContext { "TX3_TX3C_PATH".to_string(), path.to_string_lossy().to_string(), )); + // The tx3c under test is built from this tree: it has the new CLI + // surface but still reports the pre-release version, which is + // outside trix's compat window. Bypass the gate for the suite. + envs.push(("TX3_SKIP_COMPAT_CHECK".to_string(), "1".to_string())); } envs @@ -268,7 +272,6 @@ pub fn is_process_running(_pid: u32) -> bool { true } -#[cfg(feature = "unstable")] pub mod codegen_deps; pub mod edge_cases; pub mod happy_path; From 09411eaa164e80d8a2135ae5e528dc5aae0dc30c Mon Sep 17 00:00:00 2001 From: Santiago Date: Tue, 19 May 2026 09:45:51 -0300 Subject: [PATCH 2/3] refactor(compat): tool-agnostic design doc; major-wide version window - design/004: reframe as the general "trix drives dependent CLIs as subprocesses" decision (tx3c/dolos/cshell), not tx3c-specific. - spawn/compat: drop the stored `before`; the exclusive upper bound is derived as the next major (`min.major + 1`.0.0). A breaking CLI change must come with a major bump, so trix accepts any same-major release and no longer needs an update per dependent-tool minor. Co-Authored-By: Claude Opus 4.7 (1M context) --- design/004-toolchain-delegation.md | 154 +++++++++++++---------------- src/spawn/compat.rs | 37 +++---- 2 files changed, 90 insertions(+), 101 deletions(-) diff --git a/design/004-toolchain-delegation.md b/design/004-toolchain-delegation.md index 1fbd6a1..d1e8681 100644 --- a/design/004-toolchain-delegation.md +++ b/design/004-toolchain-delegation.md @@ -1,4 +1,4 @@ -# Toolchain Delegation: `trix` as a Driver, `tx3c` as the Compiler +# External CLI Delegation: `trix` as a Driver ## Status @@ -6,118 +6,106 @@ Accepted — 2026-05-19. ## Context -`trix` is the Tx3 package manager and workspace orchestrator. Its value — -project layout, profiles, registry, devnet, codegen orchestration — evolves on -its own cadence. The Tx3 language implementation (parsing, analysis, lowering, -IR encoding) evolves on the toolchain's cadence, and its internal APIs are -unstable by design. +`trix` is the Tx3 package manager and workspace orchestrator. It does not +implement the operations it exposes; it coordinates a set of dependent tools: +the language compiler (`tx3c`), the local chain node (`dolos`), the wallet / +transaction client (`cshell`), and others over time. -Binding `trix` to the language implementation as linked libraries couples these -two cadences: a `trix` release would be pinned to a specific compiler/IR -version, and every compiler change would risk unrelated `trix` churn. The two -concerns must be able to move independently. +Each dependent tool evolves on its own cadence, and the internal APIs of +several are unstable by design. Binding `trix` to any of them as a linked +library couples their release cadences: a `trix` release would be pinned to a +specific implementation version, and an unrelated change in that tool would +risk `trix` churn. `trix` and its dependents must move independently. ## Decision -**`trix` links no `tx3-*` crate. Every low-level Tx3 language operation is -delegated to the `tx3c` binary, invoked as a subprocess. The `tx3c` -command-line surface — its subcommands, flags, and JSON output — is the -contract between the two.** - -This is the driver/compiler split that `cargo`↔`rustc` uses: the stable, -user-facing verbs live in the driver (`trix`); the language implementation -lives behind a small, flag-driven compiler (`tx3c`) the driver shells out to. -The toolchain manager (`tx3up`) pairs compatible versions, as `rustup` does for -`cargo`/`rustc`. - -### `tx3c` surface - -`tx3c` exposes two symmetric verbs, each a single pipeline driven by `--emit`: - -- **`build `** — forward pipeline (source → artifact). `--emit tii` - produces the TII; `--emit tir-json --tx ` prints a transaction's - lowered v1beta0 TIR as JSON; an empty `--emit` runs the front end - (parse + analyze) and stops before lowering — the "check" semantic, modelled - on `rustc --emit=metadata`. `--diagnostics-format human|json` selects - rendering; `json` is the machine contract (mirrors - `rustc --error-format=json`). -- **`decode --tii `** — reverse pipeline (compiled artifact → report). - `--emit tir-json --tx ` decodes the artifact's TIR payload and prints - the same JSON shape as `build --emit tir-json`, so a consumer cannot tell - source-derived from artifact-derived IR (cf. `protoc --decode`, - `llvm-dis`). +**`trix` links no implementation crate of a dependent tool. It interacts with +each tool only by invoking its binary as a subprocess. A tool's command-line +surface — its subcommands, flags, and structured (JSON) I/O — is the contract +between `trix` and that tool.** + +This is the driver pattern, as `cargo` uses with `rustc`: the stable, +user-facing verbs live in the driver (`trix`); each capability lives behind a +small, flag-driven tool the driver shells out to. The toolchain manager +(`tx3up`) installs and pairs compatible versions, as `rustup` does for the +`cargo`/`rustc` pair. ### Division of responsibility -`tx3c` owns all language semantics. `trix` owns orchestration and -presentation: it selects what to compile, supplies project/profile context, -and renders results — reconstructing diagnostic output from the JSON contract, -and serializing IR through its own formatting path. No Tx3 semantics live in -`trix`. +Each tool owns its domain; `trix` owns orchestration and presentation — +selecting what to run, supplying project/profile context, and rendering +results from each tool's structured output. No domain logic of a dependent +tool is reimplemented in `trix`. -| `trix` flow | `tx3c` invocation | -|---|---| -| `check` | `build … --diagnostics-format json` (no `--emit`) | -| `inspect tir` (project) | `build … --emit tir-json --tx` | -| `inspect tir` (interface) | `decode --tii … --emit tir-json --tx` | -| `build` / `invoke` / `test` / `publish` | `build --emit tii` | -| `codegen` | `build` + `codegen` | +The language compiler is the primary instance: `trix` delegates all low-level +Tx3 operations (parse, analyze, lower, decode an artifact) to `tx3c` through +its CLI, reconstructing diagnostics and IR presentation from `tx3c`'s JSON +output. `dolos` (devnet) and `cshell` (wallets, submission) are delegated the +same way. The mechanism is uniform; the tools differ only in domain. ## The contract and its versioning -Because `trix` shares no types with the compiler, the `tx3c` CLI is an -interface that must be versioned. Two principles: +Because `trix` shares no types with a dependent tool, that tool's CLI is an +interface `trix` must version. The principles are tool-agnostic: 1. **No in-band schema markers.** Versioning belongs to the surface, not to - each payload. Stamping every JSON message with a schema tag versions one + each payload. Stamping every message with a schema tag versions one payload, not the contract, and bloats the wire. -2. **Gate on the binary version, against a window.** Compatibility is a single - matrix (`trix`'s `spawn::compat`) of `Compat { tool, min, before }` — an - inclusive lower and *exclusive upper* bound. Both bounds matter: a too-old - binary lacks capabilities `trix` relies on; and because the toolchain is - pre-1.0 (a new minor may change the CLI), a too-new binary may have moved - the contract. `spawn::ensure_supported(tool)` probes ` --version`, +2. **Gate on the binary version, against a per-tool window.** Compatibility + for every dependent tool lives in one matrix (`trix`'s `spawn::compat`): + an inclusive lower bound (the oldest release whose surface `trix` relies + on) and an exclusive upper bound at the **next major version**. A breaking + change to a tool's CLI is expected to be signalled by a major version bump + (semver); `trix` therefore accepts any release within the same major and + needs updating only when a tool makes a breaking, major change — not on + every minor. `spawn::ensure_supported(tool)` probes ` --version`, range-checks, caches per process, and fails with a distinct, actionable - message per direction (too old → run `tx3up`; too new → update `trix`). The - same matrix fronts other external tools (`cshell`, `dolos`), gated when - they need entries. + message per direction (too old → update the toolchain via `tx3up`; too new + → update `trix`). + +3. **Escape hatch for unreleased toolchains.** A locally built tool carries + the new surface but still reports its pre-release version. An environment + override bypasses the window for development and CI against an unreleased + toolchain; it is not for end users. -JSON shapes are objects, not bare arrays, so they stay extensible: additive -fields are backward-compatible and need no version change; only breaking -changes do, paired with widening the matrix window. +Structured payloads are objects, not bare arrays, so they stay extensible: +additive fields are backward-compatible and need no version change; only +breaking changes do, paired with a tool major bump and a matrix update. ## Consequences **Positive** -- `trix`'s version is decoupled from the toolchain's. The compiler ships on - its own cadence; `trix` follows only when it wants a new capability. -- The integration surface is small, explicit, and testable from the outside. -- Compiler-internal API instability cannot leak into `trix`. +- `trix`'s version is decoupled from every dependent tool's. Each ships on its + own cadence; `trix` follows only to adopt a new capability or a major break. +- The integration surface is small, explicit, and testable from outside. +- Implementation-internal API instability cannot leak into `trix`. - One place (`spawn::compat`) describes every external-tool compatibility requirement. **Costs** -- A process boundary per operation. Acceptable: operations are coarse-grained - (whole-project build/check), so spawn cost is negligible against the work. -- The CLI/JSON is a real interface with real discipline: breaking changes to - subcommands, flags, or JSON shapes require a `tx3c` version bump and a +- A process boundary per operation. Acceptable: operations are coarse-grained, + so spawn cost is negligible against the work, and `trix` already spawned + these tools. +- Each tool's CLI / structured I/O is a real interface with real discipline: + a breaking change requires a major version bump on that tool and a matrix-window update, coordinated across repos. -- Release sequencing: a `tx3c` satisfying `trix`'s window must be published and - resolvable (via the toolchain dir / `tx3up`) before the corresponding `trix`. +- Release sequencing: a tool release satisfying `trix`'s window must be + published and resolvable (via the toolchain dir / `tx3up`) before the + `trix` that requires it. ## Alternatives considered -- **Link the language crates, track versions tightly.** This is the coupling - the decision exists to avoid; it keeps compiler API instability inside +- **Link a dependent tool's crates, track versions tightly.** The coupling + this decision exists to avoid; it keeps that tool's API instability inside `trix`. -- **Link the crates only for a subset of operations.** A partial dependency - still pins `trix` to a crate version and complicates the build; the marginal +- **Link only for a subset of operations.** A partial dependency still pins + `trix` to a crate version and complicates the build; the marginal in-process speedup is irrelevant for coarse operations. -- **In-band payload version markers.** Brittle (see above); superseded by - binary-version windowing. -- **A dedicated subcommand per operation (`check`, `decode-tir`, …).** Grows - the long-term CLI surface. Flags on `build` plus one symmetric `decode` verb - cover every need with minimal surface (the `rustc`/`protoc` precedent). +- **In-band payload version markers.** Brittle; superseded by binary-version + windowing. +- **An exclusive upper bound at the next minor.** Rejected: it forces a `trix` + update for every dependent-tool minor even when nothing breaks. The next + *major* is the semver-correct breaking-change signal. diff --git a/src/spawn/compat.rs b/src/spawn/compat.rs index 3d96b95..9c0fcbf 100644 --- a/src/spawn/compat.rs +++ b/src/spawn/compat.rs @@ -1,29 +1,29 @@ //! Compatibility matrix and version gating for the external CLIs `trix` //! drives. //! -//! `trix` links no `tx3-*` crate; it orchestrates the toolchain binaries -//! (`tx3c`, `cshell`, `dolos`) as subprocesses. Each tool's CLI surface — -//! subcommands, flags, JSON output — is the versioned contract. Rather than -//! embed markers in payloads, we gate on the binary's own `--version` against -//! a supported window. This module owns that mechanism and the single matrix -//! for every integration. +//! `trix` links no implementation crate of a dependent tool; it orchestrates +//! the toolchain binaries (`tx3c`, `cshell`, `dolos`) as subprocesses. Each +//! tool's CLI surface — subcommands, flags, JSON output — is the versioned +//! contract. Rather than embed markers in payloads, we gate on the binary's +//! own `--version`. This module owns that mechanism and the single matrix for +//! every integration. See `design/004-toolchain-delegation.md`. use std::collections::HashMap; use std::process::Command; use std::sync::{Mutex, OnceLock}; -/// The supported version window for one external CLI: `min` is the inclusive -/// lower bound, `before` the exclusive upper bound (`min <= v < before`). +/// The supported version window for one external CLI. `min` is the inclusive +/// lower bound — the oldest release whose surface `trix` relies on. The +/// exclusive upper bound is derived, not stored: it is the **next major** +/// version (`min.major + 1`.0.0). /// -/// The window is bounded on both ends on purpose: too-old binaries lack -/// capabilities `trix` relies on, and — since the toolchain is pre-1.0, where -/// a new minor may break the CLI — a too-new binary may have changed the -/// contract out from under us. Widen the window when `trix` is updated to -/// track a new release. +/// A breaking change to a tool's CLI is expected to be signalled by a major +/// version bump (semver), so `trix` accepts any release within the same major +/// and needs updating only when a tool makes a breaking, major change — not on +/// every minor. Raise `min` when `trix` starts relying on a newer capability. struct Compat { tool: &'static str, min: &'static str, - before: &'static str, } const COMPAT_MATRIX: &[Compat] = &[ @@ -32,7 +32,6 @@ const COMPAT_MATRIX: &[Compat] = &[ Compat { tool: "tx3c", min: "0.18.0", - before: "0.19.0", }, ]; @@ -41,7 +40,7 @@ fn entry(tool: &str) -> Option<&'static Compat> { } /// Probe ` --version` and confirm it falls within the supported window -/// in [`COMPAT_MATRIX`]. +/// in [`COMPAT_MATRIX`] (`min <= v`, and `v` within the same major as `min`). /// /// A no-op for tools not in the matrix. Cached per tool: a toolchain can't /// change mid-process, so each tool is probed at most once. Call this before @@ -49,7 +48,7 @@ fn entry(tool: &str) -> Option<&'static Compat> { /// /// Escape hatch: setting `TX3_SKIP_COMPAT_CHECK` to a non-empty value bypasses /// the window. This exists for developing/CI-testing against an *unreleased* -/// toolchain — a locally built `tx3c` carries the new CLI surface but still +/// toolchain — a locally built tool carries the new CLI surface but still /// reports the pre-bump version until its release is cut. Not for end users. pub fn ensure_supported(tool: &str) -> miette::Result<()> { if std::env::var_os("TX3_SKIP_COMPAT_CHECK").is_some_and(|v| !v.is_empty()) { @@ -96,7 +95,9 @@ fn check(c: &Compat) -> Result<(), String> { .map_err(|e| format!("cannot parse {tool} version from {stdout:?}: {e}"))?; let min = semver::Version::parse(c.min).expect("valid matrix const"); - let before = semver::Version::parse(c.before).expect("valid matrix const"); + // Exclusive upper bound: the next major. Same-major releases are accepted; + // a breaking CLI change must come with a major bump. + let before = semver::Version::new(min.major + 1, 0, 0); if found < min { return Err(format!( From 7e41f25dfac202d7e23c1d21fae98e1876a592b4 Mon Sep 17 00:00:00 2001 From: Santiago Date: Tue, 19 May 2026 12:12:21 -0300 Subject: [PATCH 3/3] fix: drop unused module imports in main.rs `cargo clippy -- -D warnings` (the CI lint step) rejected the long-standing unused `use trix::{builder, devnet, dirs, home, spawn, wallet}` imports in the bin root. Only cli/commands/config/global/telemetry/updates are used. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/main.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/main.rs b/src/main.rs index dc3d972..c6f3c16 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,11 +1,10 @@ use clap::Parser; use trix::{ - builder, cli::{Cli, Commands}, commands as cmds, config::RootConfig, - devnet, dirs, global, home, spawn, telemetry, updates, wallet, + global, telemetry, updates, }; use miette::{IntoDiagnostic as _, Result};