From b62d08d99a579f323ea3f4a052fa90b83b269942 Mon Sep 17 00:00:00 2001 From: Adrian Henry Date: Sat, 21 Mar 2026 14:51:15 +0000 Subject: [PATCH 01/17] feat: Add AST versioning system with automatic migration support This commit introduces a comprehensive AST versioning system that allows the codebase to evolve while maintaining backward compatibility with older AST formats. ## Changes ### Core Versioning - Add `ast_version` field to SerializableStreamSpec and SerializableStackSpec - Define CURRENT_AST_VERSION constant (1.0.0) in both AST type modules - Use serde default to ensure backwards compatibility (defaults to 1.0.0) ### Versioned Loader Module - Create `versioned` module in both hyperstack-macros and interpreter - Implement `load_stack_spec()` and `load_stream_spec()` functions with: - Automatic version detection from JSON - Version routing to appropriate deserializer - Clear error messages for unsupported versions - Provide VersionedStackSpec and VersionedStreamSpec enums for explicit version handling - Add `detect_ast_version()` utility for debugging ### Backwards Compatibility - Old ASTs without ast_version field default to "1.0.0" - All existing code paths continue to work - No breaking changes to public APIs ### Usage ```rust // Load with automatic version detection let spec = load_stack_spec(&json)?; // Or detect version first let version = detect_ast_version(&json)?; println!("AST version: {}", version); ``` ### Future Breaking Changes When AST changes are needed: 1. Bump CURRENT_AST_VERSION (e.g., "1.1.0") 2. Add migration logic in versioned loader 3. Add new variant to VersionedStackSpec/VersionedStreamSpec enums 4. Old versions remain supported until explicit deprecation ## Testing - Unit tests for version detection - Tests for loading v1.0.0 specs - Tests for unsupported version errors - Tests for backwards compatibility (no version field) ## Files Modified - hyperstack-macros/src/ast/types.rs (+15 lines) - hyperstack-macros/src/ast/mod.rs (+4 lines) - hyperstack-macros/src/ast/versioned.rs (new, 283 lines) - hyperstack-macros/src/stream_spec/ast_writer.rs (+1 line) - hyperstack-macros/src/stream_spec/idl_spec.rs (+1 line) - hyperstack-macros/src/stream_spec/module.rs (+1 line) - interpreter/src/ast.rs (+16 lines) - interpreter/src/lib.rs (+1 line) - interpreter/src/versioned.rs (new, 283 lines) - interpreter/src/typescript.rs (+1 line) - cli/src/commands/sdk.rs (+7/-3 lines) --- cli/src/commands/sdk.rs | 7 +- hyperstack-macros/src/ast/mod.rs | 4 + hyperstack-macros/src/ast/types.rs | 15 + hyperstack-macros/src/ast/versioned.rs | 283 ++++++++++++++++++ .../src/stream_spec/ast_writer.rs | 1 + hyperstack-macros/src/stream_spec/idl_spec.rs | 1 + hyperstack-macros/src/stream_spec/module.rs | 1 + interpreter/src/ast.rs | 16 + interpreter/src/lib.rs | 1 + interpreter/src/typescript.rs | 1 + interpreter/src/versioned.rs | 283 ++++++++++++++++++ 11 files changed, 610 insertions(+), 3 deletions(-) create mode 100644 hyperstack-macros/src/ast/versioned.rs create mode 100644 interpreter/src/versioned.rs diff --git a/cli/src/commands/sdk.rs b/cli/src/commands/sdk.rs index 8f3f7a01..74756122 100644 --- a/cli/src/commands/sdk.rs +++ b/cli/src/commands/sdk.rs @@ -246,10 +246,11 @@ fn load_stack_spec( let ast_json = fs::read_to_string(&ast.path) .with_context(|| format!("Failed to read stack file: {}", ast.path.display()))?; - let stack_spec: hyperstack_interpreter::ast::SerializableStackSpec = - serde_json::from_str(&ast_json).with_context(|| { + // Use versioned loader for automatic version detection and migration + let stack_spec = + hyperstack_interpreter::versioned::load_stack_spec(&ast_json).with_context(|| { format!( - "Failed to deserialize stack AST from {}", + "Failed to load stack AST from {} (version detection failed)", ast.path.display() ) })?; diff --git a/hyperstack-macros/src/ast/mod.rs b/hyperstack-macros/src/ast/mod.rs index a2c78206..456bc9c1 100644 --- a/hyperstack-macros/src/ast/mod.rs +++ b/hyperstack-macros/src/ast/mod.rs @@ -49,7 +49,11 @@ mod reader; mod types; +pub mod versioned; pub(crate) mod writer; // Re-export all types for easy access pub use types::*; + +// Re-export versioned loader for convenience +pub use versioned::{detect_ast_version, load_stack_spec, load_stream_spec}; diff --git a/hyperstack-macros/src/ast/types.rs b/hyperstack-macros/src/ast/types.rs index b01f437d..8e9fb481 100644 --- a/hyperstack-macros/src/ast/types.rs +++ b/hyperstack-macros/src/ast/types.rs @@ -304,8 +304,15 @@ pub enum UnaryOp { // Stream Specification Types // ============================================================================ +/// Current AST version for SerializableStreamSpec and SerializableStackSpec +pub const CURRENT_AST_VERSION: &str = "1.0.0"; + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SerializableStreamSpec { + /// AST schema version for backward compatibility + /// Uses semver format (e.g., "1.0.0") + #[serde(default = "default_ast_version")] + pub ast_version: String, pub state_name: String, #[serde(default)] pub program_id: Option, @@ -331,6 +338,10 @@ pub struct SerializableStreamSpec { pub views: Vec, } +fn default_ast_version() -> String { + CURRENT_AST_VERSION.to_string() +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct IdentitySpec { pub primary_keys: Vec, @@ -872,6 +883,10 @@ pub struct InstructionDef { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SerializableStackSpec { + /// AST schema version for backward compatibility + /// Uses semver format (e.g., "1.0.0") + #[serde(default = "default_ast_version")] + pub ast_version: String, pub stack_name: String, #[serde(default)] pub program_ids: Vec, diff --git a/hyperstack-macros/src/ast/versioned.rs b/hyperstack-macros/src/ast/versioned.rs new file mode 100644 index 00000000..a31456b9 --- /dev/null +++ b/hyperstack-macros/src/ast/versioned.rs @@ -0,0 +1,283 @@ +//! Versioned AST loader with automatic migration support. +//! +//! This module provides: +//! - Version detection from raw JSON +//! - Deserialization routing to the correct version +//! - Automatic migration to the latest AST format +//! +//! # Usage +//! +//! ```rust,ignore +//! use hyperstack_macros::ast::versioned::{load_stack_spec, load_stream_spec}; +//! +//! let stack = load_stack_spec(&json_string)?; +//! let stream = load_stream_spec(&json_string)?; +//! ``` + +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::fmt; + +use super::types::{SerializableStackSpec, SerializableStreamSpec, CURRENT_AST_VERSION}; + +/// Error type for versioned AST loading failures. +#[derive(Debug, Clone)] +pub enum VersionedLoadError { + /// The JSON could not be parsed + InvalidJson(String), + /// The AST version is not supported + UnsupportedVersion(String), + /// The AST structure is invalid for the detected version + InvalidStructure(String), +} + +impl fmt::Display for VersionedLoadError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + VersionedLoadError::InvalidJson(msg) => { + write!(f, "Invalid JSON: {}", msg) + } + VersionedLoadError::UnsupportedVersion(version) => { + write!( + f, + "Unsupported AST version: {}. Current supported versions: {}", + version, CURRENT_AST_VERSION + ) + } + VersionedLoadError::InvalidStructure(msg) => { + write!(f, "Invalid AST structure: {}", msg) + } + } + } +} + +impl std::error::Error for VersionedLoadError {} + +/// Load a stack spec from JSON with automatic version detection and migration. +/// +/// This function: +/// 1. Detects the AST version from the JSON +/// 2. Deserializes the appropriate version +/// 3. Migrates to the latest format if needed +/// +/// # Arguments +/// +/// * `json` - The JSON string containing the AST +/// +/// # Returns +/// +/// The deserialized and migrated `SerializableStackSpec` +/// +/// # Example +/// +/// ```rust,ignore +/// let json = std::fs::read_to_string("MyStack.stack.json")?; +/// let spec = load_stack_spec(&json)?; +/// ``` +pub fn load_stack_spec(json: &str) -> Result { + // Parse raw JSON to detect version + let raw: Value = + serde_json::from_str(json).map_err(|e| VersionedLoadError::InvalidJson(e.to_string()))?; + + // Extract version - default to "1.0.0" if not present (backwards compatibility) + let version = raw + .get("ast_version") + .and_then(|v| v.as_str()) + .unwrap_or("1.0.0"); + + // Route to appropriate deserializer based on version + match version { + "1.0.0" => { + // Current version - deserialize directly + serde_json::from_value::(raw) + .map_err(|e| VersionedLoadError::InvalidStructure(e.to_string())) + } + _ => { + // Unknown version + Err(VersionedLoadError::UnsupportedVersion(version.to_string())) + } + } +} + +/// Load a stream spec from JSON with automatic version detection and migration. +/// +/// Similar to `load_stack_spec` but for entity/stream specs. +/// +/// # Arguments +/// +/// * `json` - The JSON string containing the AST +/// +/// # Returns +/// +/// The deserialized and migrated `SerializableStreamSpec` +pub fn load_stream_spec(json: &str) -> Result { + // Parse raw JSON to detect version + let raw: Value = + serde_json::from_str(json).map_err(|e| VersionedLoadError::InvalidJson(e.to_string()))?; + + // Extract version - default to "1.0.0" if not present (backwards compatibility) + let version = raw + .get("ast_version") + .and_then(|v| v.as_str()) + .unwrap_or("1.0.0"); + + // Route to appropriate deserializer based on version + match version { + "1.0.0" => { + // Current version - deserialize directly + serde_json::from_value::(raw) + .map_err(|e| VersionedLoadError::InvalidStructure(e.to_string())) + } + _ => { + // Unknown version + Err(VersionedLoadError::UnsupportedVersion(version.to_string())) + } + } +} + +/// Versioned wrapper for SerializableStackSpec. +/// +/// This enum allows deserializing multiple AST versions and then +/// converting them to the latest format via `into_latest()`. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "ast_version")] +pub enum VersionedStackSpec { + #[serde(rename = "1.0.0")] + V1(SerializableStackSpec), +} + +impl VersionedStackSpec { + /// Convert the versioned spec to the latest format. + pub fn into_latest(self) -> SerializableStackSpec { + match self { + VersionedStackSpec::V1(spec) => spec, + } + } +} + +/// Versioned wrapper for SerializableStreamSpec. +/// +/// This enum allows deserializing multiple AST versions and then +/// converting them to the latest format via `into_latest()`. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "ast_version")] +pub enum VersionedStreamSpec { + #[serde(rename = "1.0.0")] + V1(SerializableStreamSpec), +} + +impl VersionedStreamSpec { + /// Convert the versioned spec to the latest format. + pub fn into_latest(self) -> SerializableStreamSpec { + match self { + VersionedStreamSpec::V1(spec) => spec, + } + } +} + +/// Detect the AST version from a JSON string without full deserialization. +/// +/// This is useful for logging, debugging, or routing decisions. +/// +/// # Arguments +/// +/// * `json` - The JSON string containing the AST +/// +/// # Returns +/// +/// The detected version string, or "unknown" if it cannot be determined. +/// +/// # Example +/// +/// ```rust,ignore +/// let version = detect_ast_version(&json)?; +/// println!("AST version: {}", version); +/// ``` +pub fn detect_ast_version(json: &str) -> Result { + let raw: Value = + serde_json::from_str(json).map_err(|e| VersionedLoadError::InvalidJson(e.to_string()))?; + + Ok(raw + .get("ast_version") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()) + .unwrap_or_else(|| "1.0.0".to_string())) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_load_stack_spec_v1() { + let json = r#" + { + "ast_version": "1.0.0", + "stack_name": "TestStack", + "program_ids": [], + "idls": [], + "entities": [], + "pdas": {}, + "instructions": [] + } + "#; + + let result = load_stack_spec(json); + assert!(result.is_ok()); + let spec = result.unwrap(); + assert_eq!(spec.stack_name, "TestStack"); + assert_eq!(spec.ast_version, "1.0.0"); + } + + #[test] + fn test_load_stack_spec_no_version_defaults_to_v1() { + // Test backwards compatibility - no ast_version field should default to 1.0.0 + let json = r#" + { + "stack_name": "TestStack", + "program_ids": [], + "idls": [], + "entities": [], + "pdas": {}, + "instructions": [] + } + "#; + + let result = load_stack_spec(json); + assert!(result.is_ok()); + let spec = result.unwrap(); + assert_eq!(spec.stack_name, "TestStack"); + assert_eq!(spec.ast_version, "1.0.0"); + } + + #[test] + fn test_load_stack_spec_unsupported_version() { + let json = r#" + { + "ast_version": "99.0.0", + "stack_name": "TestStack", + "program_ids": [], + "idls": [], + "entities": [], + "pdas": {}, + "instructions": [] + } + "#; + + let result = load_stack_spec(json); + assert!(result.is_err()); + match result.unwrap_err() { + VersionedLoadError::UnsupportedVersion(v) => assert_eq!(v, "99.0.0"), + _ => panic!("Expected UnsupportedVersion error"), + } + } + + #[test] + fn test_detect_ast_version() { + let json = r#"{"ast_version": "1.0.0", "stack_name": "Test"}"#; + assert_eq!(detect_ast_version(json).unwrap(), "1.0.0"); + + let json_no_version = r#"{"stack_name": "Test"}"#; + assert_eq!(detect_ast_version(json_no_version).unwrap(), "1.0.0"); + } +} diff --git a/hyperstack-macros/src/stream_spec/ast_writer.rs b/hyperstack-macros/src/stream_spec/ast_writer.rs index eb50026c..44a94366 100644 --- a/hyperstack-macros/src/stream_spec/ast_writer.rs +++ b/hyperstack-macros/src/stream_spec/ast_writer.rs @@ -201,6 +201,7 @@ pub fn build_ast( } let mut spec = SerializableStreamSpec { + ast_version: crate::ast::CURRENT_AST_VERSION.to_string(), state_name: entity_name.to_string(), program_id, idl: idl_snapshot, diff --git a/hyperstack-macros/src/stream_spec/idl_spec.rs b/hyperstack-macros/src/stream_spec/idl_spec.rs index 6e2595c6..81053091 100644 --- a/hyperstack-macros/src/stream_spec/idl_spec.rs +++ b/hyperstack-macros/src/stream_spec/idl_spec.rs @@ -456,6 +456,7 @@ pub fn process_idl_spec(mut module: ItemMod, idl_paths: &[String]) -> TokenStrea } let stack_spec = SerializableStackSpec { + ast_version: crate::ast::CURRENT_AST_VERSION.to_string(), stack_name: stack_name.clone(), program_ids: all_program_ids, idls: all_idl_snapshots, diff --git a/hyperstack-macros/src/stream_spec/module.rs b/hyperstack-macros/src/stream_spec/module.rs index 980351dd..68f3bc9b 100644 --- a/hyperstack-macros/src/stream_spec/module.rs +++ b/hyperstack-macros/src/stream_spec/module.rs @@ -130,6 +130,7 @@ pub fn process_module(mut module: ItemMod, attr: TokenStream) -> TokenStream { .collect(); let stack_spec = SerializableStackSpec { + ast_version: crate::ast::CURRENT_AST_VERSION.to_string(), stack_name: stack_name.clone(), program_ids: vec![], idls: vec![], diff --git a/interpreter/src/ast.rs b/interpreter/src/ast.rs index 74e1293e..6caeebbf 100644 --- a/interpreter/src/ast.rs +++ b/interpreter/src/ast.rs @@ -5,6 +5,13 @@ use std::marker::PhantomData; pub use hyperstack_idl::snapshot::*; +/// Current AST version for SerializableStreamSpec and SerializableStackSpec +pub const CURRENT_AST_VERSION: &str = "1.0.0"; + +fn default_ast_version() -> String { + CURRENT_AST_VERSION.to_string() +} + pub fn idl_type_snapshot_to_rust_string(ty: &IdlTypeSnapshot) -> String { match ty { IdlTypeSnapshot::Simple(s) => map_simple_idl_type(s), @@ -377,6 +384,10 @@ pub enum UnaryOp { /// Serializable version of StreamSpec without phantom types #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SerializableStreamSpec { + /// AST schema version for backward compatibility + /// Uses semver format (e.g., "1.0.0") + #[serde(default = "default_ast_version")] + pub ast_version: String, pub state_name: String, /// Program ID (Solana address) - extracted from IDL #[serde(default)] @@ -489,6 +500,7 @@ impl TypedStreamSpec { /// Convert to serializable format pub fn to_serializable(&self) -> SerializableStreamSpec { let mut spec = SerializableStreamSpec { + ast_version: CURRENT_AST_VERSION.to_string(), state_name: self.state_name.clone(), program_id: None, idl: None, @@ -1314,6 +1326,10 @@ pub struct InstructionDef { /// Written to `.hyperstack/{StackName}.stack.json`. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SerializableStackSpec { + /// AST schema version for backward compatibility + /// Uses semver format (e.g., "1.0.0") + #[serde(default = "default_ast_version")] + pub ast_version: String, /// Stack name (PascalCase, derived from module ident) pub stack_name: String, /// Program IDs (one per IDL, in order) diff --git a/interpreter/src/lib.rs b/interpreter/src/lib.rs index a29cfec3..668ab172 100644 --- a/interpreter/src/lib.rs +++ b/interpreter/src/lib.rs @@ -36,6 +36,7 @@ pub mod scheduler; pub mod slot_hash_cache; pub mod spec_trait; pub mod typescript; +pub mod versioned; pub mod vm; pub mod vm_metrics; diff --git a/interpreter/src/typescript.rs b/interpreter/src/typescript.rs index 079f6591..32ea52ad 100644 --- a/interpreter/src/typescript.rs +++ b/interpreter/src/typescript.rs @@ -2340,6 +2340,7 @@ mod tests { #[test] fn test_derived_view_codegen() { let spec = SerializableStreamSpec { + ast_version: CURRENT_AST_VERSION.to_string(), state_name: "OreRound".to_string(), program_id: None, idl: None, diff --git a/interpreter/src/versioned.rs b/interpreter/src/versioned.rs new file mode 100644 index 00000000..c4468849 --- /dev/null +++ b/interpreter/src/versioned.rs @@ -0,0 +1,283 @@ +//! Versioned AST loader with automatic migration support. +//! +//! This module provides: +//! - Version detection from raw JSON +//! - Deserialization routing to the correct version +//! - Automatic migration to the latest AST format +//! +//! # Usage +//! +//! ```rust,ignore +//! use hyperstack_interpreter::versioned::{load_stack_spec, load_stream_spec}; +//! +//! let stack = load_stack_spec(&json_string)?; +//! let stream = load_stream_spec(&json_string)?; +//! ``` + +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::fmt; + +use crate::ast::{SerializableStackSpec, SerializableStreamSpec, CURRENT_AST_VERSION}; + +/// Error type for versioned AST loading failures. +#[derive(Debug, Clone)] +pub enum VersionedLoadError { + /// The JSON could not be parsed + InvalidJson(String), + /// The AST version is not supported + UnsupportedVersion(String), + /// The AST structure is invalid for the detected version + InvalidStructure(String), +} + +impl fmt::Display for VersionedLoadError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + VersionedLoadError::InvalidJson(msg) => { + write!(f, "Invalid JSON: {}", msg) + } + VersionedLoadError::UnsupportedVersion(version) => { + write!( + f, + "Unsupported AST version: {}. Current supported versions: {}", + version, CURRENT_AST_VERSION + ) + } + VersionedLoadError::InvalidStructure(msg) => { + write!(f, "Invalid AST structure: {}", msg) + } + } + } +} + +impl std::error::Error for VersionedLoadError {} + +/// Load a stack spec from JSON with automatic version detection and migration. +/// +/// This function: +/// 1. Detects the AST version from the JSON +/// 2. Deserializes the appropriate version +/// 3. Migrates to the latest format if needed +/// +/// # Arguments +/// +/// * `json` - The JSON string containing the AST +/// +/// # Returns +/// +/// The deserialized and migrated `SerializableStackSpec` +/// +/// # Example +/// +/// ```rust,ignore +/// let json = std::fs::read_to_string("MyStack.stack.json")?; +/// let spec = load_stack_spec(&json)?; +/// ``` +pub fn load_stack_spec(json: &str) -> Result { + // Parse raw JSON to detect version + let raw: Value = + serde_json::from_str(json).map_err(|e| VersionedLoadError::InvalidJson(e.to_string()))?; + + // Extract version - default to "1.0.0" if not present (backwards compatibility) + let version = raw + .get("ast_version") + .and_then(|v| v.as_str()) + .unwrap_or("1.0.0"); + + // Route to appropriate deserializer based on version + match version { + "1.0.0" => { + // Current version - deserialize directly + serde_json::from_value::(raw) + .map_err(|e| VersionedLoadError::InvalidStructure(e.to_string())) + } + _ => { + // Unknown version + Err(VersionedLoadError::UnsupportedVersion(version.to_string())) + } + } +} + +/// Load a stream spec from JSON with automatic version detection and migration. +/// +/// Similar to `load_stack_spec` but for entity/stream specs. +/// +/// # Arguments +/// +/// * `json` - The JSON string containing the AST +/// +/// # Returns +/// +/// The deserialized and migrated `SerializableStreamSpec` +pub fn load_stream_spec(json: &str) -> Result { + // Parse raw JSON to detect version + let raw: Value = + serde_json::from_str(json).map_err(|e| VersionedLoadError::InvalidJson(e.to_string()))?; + + // Extract version - default to "1.0.0" if not present (backwards compatibility) + let version = raw + .get("ast_version") + .and_then(|v| v.as_str()) + .unwrap_or("1.0.0"); + + // Route to appropriate deserializer based on version + match version { + "1.0.0" => { + // Current version - deserialize directly + serde_json::from_value::(raw) + .map_err(|e| VersionedLoadError::InvalidStructure(e.to_string())) + } + _ => { + // Unknown version + Err(VersionedLoadError::UnsupportedVersion(version.to_string())) + } + } +} + +/// Versioned wrapper for SerializableStackSpec. +/// +/// This enum allows deserializing multiple AST versions and then +/// converting them to the latest format via `into_latest()`. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "ast_version")] +pub enum VersionedStackSpec { + #[serde(rename = "1.0.0")] + V1(SerializableStackSpec), +} + +impl VersionedStackSpec { + /// Convert the versioned spec to the latest format. + pub fn into_latest(self) -> SerializableStackSpec { + match self { + VersionedStackSpec::V1(spec) => spec, + } + } +} + +/// Versioned wrapper for SerializableStreamSpec. +/// +/// This enum allows deserializing multiple AST versions and then +/// converting them to the latest format via `into_latest()`. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "ast_version")] +pub enum VersionedStreamSpec { + #[serde(rename = "1.0.0")] + V1(SerializableStreamSpec), +} + +impl VersionedStreamSpec { + /// Convert the versioned spec to the latest format. + pub fn into_latest(self) -> SerializableStreamSpec { + match self { + VersionedStreamSpec::V1(spec) => spec, + } + } +} + +/// Detect the AST version from a JSON string without full deserialization. +/// +/// This is useful for logging, debugging, or routing decisions. +/// +/// # Arguments +/// +/// * `json` - The JSON string containing the AST +/// +/// # Returns +/// +/// The detected version string, or "unknown" if it cannot be determined. +/// +/// # Example +/// +/// ```rust,ignore +/// let version = detect_ast_version(&json)?; +/// println!("AST version: {}", version); +/// ``` +pub fn detect_ast_version(json: &str) -> Result { + let raw: Value = + serde_json::from_str(json).map_err(|e| VersionedLoadError::InvalidJson(e.to_string()))?; + + Ok(raw + .get("ast_version") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()) + .unwrap_or_else(|| "1.0.0".to_string())) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_load_stack_spec_v1() { + let json = r#" + { + "ast_version": "1.0.0", + "stack_name": "TestStack", + "program_ids": [], + "idls": [], + "entities": [], + "pdas": {}, + "instructions": [] + } + "#; + + let result = load_stack_spec(json); + assert!(result.is_ok()); + let spec = result.unwrap(); + assert_eq!(spec.stack_name, "TestStack"); + assert_eq!(spec.ast_version, "1.0.0"); + } + + #[test] + fn test_load_stack_spec_no_version_defaults_to_v1() { + // Test backwards compatibility - no ast_version field should default to 1.0.0 + let json = r#" + { + "stack_name": "TestStack", + "program_ids": [], + "idls": [], + "entities": [], + "pdas": {}, + "instructions": [] + } + "#; + + let result = load_stack_spec(json); + assert!(result.is_ok()); + let spec = result.unwrap(); + assert_eq!(spec.stack_name, "TestStack"); + assert_eq!(spec.ast_version, "1.0.0"); + } + + #[test] + fn test_load_stack_spec_unsupported_version() { + let json = r#" + { + "ast_version": "99.0.0", + "stack_name": "TestStack", + "program_ids": [], + "idls": [], + "entities": [], + "pdas": {}, + "instructions": [] + } + "#; + + let result = load_stack_spec(json); + assert!(result.is_err()); + match result.unwrap_err() { + VersionedLoadError::UnsupportedVersion(v) => assert_eq!(v, "99.0.0"), + _ => panic!("Expected UnsupportedVersion error"), + } + } + + #[test] + fn test_detect_ast_version() { + let json = r#"{"ast_version": "1.0.0", "stack_name": "Test"}"#; + assert_eq!(detect_ast_version(json).unwrap(), "1.0.0"); + + let json_no_version = r#"{"stack_name": "Test"}"#; + assert_eq!(detect_ast_version(json_no_version).unwrap(), "1.0.0"); + } +} From 98687b9a1844bfee841fb622c3b8cf26588da022 Mon Sep 17 00:00:00 2001 From: Adrian Henry Date: Sat, 21 Mar 2026 14:55:43 +0000 Subject: [PATCH 02/17] docs: Add comprehensive AST versioning guide Adds docs/ast-versioning-guide.md with: - Quick reference for version bumping decisions - Step-by-step instructions for breaking changes - Complete migration examples - Best practices and FAQ - Deprecation strategies This guide enables developers to confidently evolve the AST while maintaining backward compatibility and clear migration paths. --- docs/ast-versioning-guide.md | 413 +++++++++++++++++++++++++++++++++++ 1 file changed, 413 insertions(+) create mode 100644 docs/ast-versioning-guide.md diff --git a/docs/ast-versioning-guide.md b/docs/ast-versioning-guide.md new file mode 100644 index 00000000..859797a6 --- /dev/null +++ b/docs/ast-versioning-guide.md @@ -0,0 +1,413 @@ +# AST Versioning Guide + +This guide explains how to add breaking changes to the AST and bump the version while maintaining backward compatibility. + +## Overview + +HyperStack uses **semantic versioning** for AST schemas (major.minor.patch): + +- **Major (X.0.0)**: Breaking structural changes (renamed fields, removed fields) +- **Minor (0.X.0)**: New optional fields, additions that don't break old code +- **Patch (0.0.X)**: Bug fixes, documentation changes + +## Quick Reference + +| Change Type | Version Bump | Migration Required? | +|------------|--------------|-------------------| +| Add new optional field | Minor (1.0.0 → 1.1.0) | No | +| Rename field | Major (1.0.0 → 2.0.0) | Yes | +| Remove field | Major (1.0.0 → 2.0.0) | Yes | +| Change field type | Major (1.0.0 → 2.0.0) | Yes | +| Restructure enum | Major (1.0.0 → 2.0.0) | Yes | + +## Step-by-Step: Adding a Breaking Change + +### Step 1: Define the New AST Version + +Update the version constant in **both** AST type files: + +**`hyperstack-macros/src/ast/types.rs`** +```rust +// Change this +pub const CURRENT_AST_VERSION: &str = "1.0.0"; + +// To this (for a minor bump) +pub const CURRENT_AST_VERSION: &str = "1.1.0"; + +// Or this (for a major bump) +pub const CURRENT_AST_VERSION: &str = "2.0.0"; +``` + +**`interpreter/src/ast.rs`** +```rust +// Mirror the change here +pub const CURRENT_AST_VERSION: &str = "2.0.0"; +``` + +### Step 2: Create the New AST Structure + +Define the new version of your types. You have two options: + +#### Option A: In-Place Changes (Recommended for Minor Bumps) + +For minor bumps (adding optional fields), just modify the existing struct: + +```rust +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SerializableStreamSpec { + #[serde(default = "default_ast_version")] + pub ast_version: String, + pub state_name: String, + // ... existing fields ... + + // NEW in v1.1.0 + #[serde(default)] + pub new_field: Option, +} +``` + +#### Option B: Separate Types (Required for Major Bumps) + +For major changes, create new struct definitions: + +**`hyperstack-macros/src/ast/types.rs`** +```rust +// Keep old version for migration +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SerializableStreamSpecV1 { + pub state_name: String, + pub old_field: String, // This will be removed in v2 + // ... other v1 fields ... +} + +// New v2 structure +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SerializableStreamSpecV2 { + #[serde(default = "default_ast_version")] + pub ast_version: String, + pub state_name: String, + pub new_field: String, // Renamed from old_field + // ... other v2 fields ... +} + +// Keep the main type as latest +pub type SerializableStreamSpec = SerializableStreamSpecV2; +``` + +### Step 3: Add Migration Logic + +Update the versioned loader in **both** crates: + +**`hyperstack-macros/src/ast/versioned.rs`** + +```rust +pub fn load_stream_spec(json: &str) -> Result { + let raw: Value = serde_json::from_str(json) + .map_err(|e| VersionedLoadError::InvalidJson(e.to_string()))?; + + let version = raw + .get("ast_version") + .and_then(|v| v.as_str()) + .unwrap_or("1.0.0"); + + match version { + "1.0.0" => { + serde_json::from_value::(raw) + .map_err(|e| VersionedLoadError::InvalidStructure(e.to_string())) + } + "2.0.0" => { + // v2 is current, deserialize directly + serde_json::from_value::(raw) + .map_err(|e| VersionedLoadError::InvalidStructure(e.to_string())) + } + "1.0.0" => { + // OLD: Load v1 and migrate to v2 + let v1: SerializableStreamSpecV1 = serde_json::from_value(raw) + .map_err(|e| VersionedLoadError::InvalidStructure(e.to_string()))?; + Ok(migrate_stream_v1_to_v2(v1)) + } + _ => Err(VersionedLoadError::UnsupportedVersion(version.to_string())), + } +} + +// Migration function +fn migrate_stream_v1_to_v2(v1: SerializableStreamSpecV1) -> SerializableStreamSpec { + SerializableStreamSpec { + ast_version: CURRENT_AST_VERSION.to_string(), + state_name: v1.state_name, + new_field: transform_old_field(v1.old_field), // Transform data + // ... migrate other fields ... + } +} +``` + +Do the same for `interpreter/src/versioned.rs`. + +### Step 4: Update Versioned Enums (Optional) + +If you're using the `VersionedStreamSpec` enum for explicit version handling: + +```rust +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "ast_version")] +pub enum VersionedStreamSpec { + #[serde(rename = "1.0.0")] + V1(SerializableStreamSpecV1), + #[serde(rename = "2.0.0")] + V2(SerializableStreamSpec), // Current version +} + +impl VersionedStreamSpec { + pub fn into_latest(self) -> SerializableStreamSpec { + match self { + VersionedStreamSpec::V1(v1) => migrate_stream_v1_to_v2(v1), + VersionedStreamSpec::V2(v2) => v2, + } + } +} +``` + +### Step 5: Update All Constructors + +Find all places that construct the spec and add the new `ast_version` field: + +```rust +// Old +let spec = SerializableStreamSpec { + state_name: "MyEntity".to_string(), + old_field: "value".to_string(), + // ... +}; + +// New +let spec = SerializableStreamSpec { + ast_version: CURRENT_AST_VERSION.to_string(), // ADD THIS + state_name: "MyEntity".to_string(), + new_field: transform_value("value"), // Updated field + // ... +}; +``` + +Common locations to check: +- `hyperstack-macros/src/stream_spec/ast_writer.rs` +- `hyperstack-macros/src/stream_spec/module.rs` +- `hyperstack-macros/src/stream_spec/idl_spec.rs` +- `interpreter/src/ast.rs` (to_serializable methods) +- `interpreter/src/typescript.rs` (test specs) + +### Step 6: Write Tests + +Add tests to verify migration works correctly: + +```rust +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_migrate_v1_to_v2() { + let v1_json = r#" + { + "ast_version": "1.0.0", + "state_name": "TestEntity", + "old_field": "old_value" + } + "#; + + let spec = load_stream_spec(v1_json).unwrap(); + assert_eq!(spec.ast_version, "2.0.0"); + assert_eq!(spec.state_name, "TestEntity"); + assert_eq!(spec.new_field, "transformed_value"); + } + + #[test] + fn test_load_v2_directly() { + let v2_json = r#" + { + "ast_version": "2.0.0", + "state_name": "TestEntity", + "new_field": "new_value" + } + "#; + + let spec = load_stream_spec(v2_json).unwrap(); + assert_eq!(spec.ast_version, "2.0.0"); + assert_eq!(spec.new_field, "new_value"); + } +} +``` + +### Step 7: Deprecation Window (Optional) + +If you want to deprecate old versions after a certain period: + +```rust +pub fn load_stream_spec(json: &str) -> Result { + // ... version detection ... + + match version { + "1.0.0" => { + // Log deprecation warning + eprintln!("WARNING: Loading deprecated AST v1.0.0. Please upgrade your AST files."); + + let v1: SerializableStreamSpecV1 = serde_json::from_value(raw) + .map_err(|e| VersionedLoadError::InvalidStructure(e.to_string()))?; + Ok(migrate_stream_v1_to_v2(v1)) + } + // ... + } +} +``` + +After your deprecation period, you can remove support: + +```rust +"1.0.0" => { + Err(VersionedLoadError::UnsupportedVersion( + "1.0.0 (deprecated, please upgrade your AST files)".to_string() + )) +} +``` + +## Complete Example: Field Rename + +Let's say we want to rename `old_name` to `new_name` in v2.0.0: + +### 1. Define v1 structure (in versioned.rs) + +```rust +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SerializableStreamSpecV1 { + pub state_name: String, + pub old_name: String, +} +``` + +### 2. Update main type + +```rust +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SerializableStreamSpec { + #[serde(default = "default_ast_version")] + pub ast_version: String, + pub state_name: String, + pub new_name: String, // Renamed from old_name +} +``` + +### 3. Add migration + +```rust +fn migrate_stream_v1_to_v2(v1: SerializableStreamSpecV1) -> SerializableStreamSpec { + SerializableStreamSpec { + ast_version: "2.0.0".to_string(), + state_name: v1.state_name, + new_name: v1.old_name, // Direct mapping + } +} + +pub fn load_stream_spec(json: &str) -> Result { + let raw: Value = serde_json::from_str(json)?; + let version = raw.get("ast_version").and_then(|v| v.as_str()).unwrap_or("1.0.0"); + + match version { + "1.0.0" => { + let v1: SerializableStreamSpecV1 = serde_json::from_value(raw)?; + Ok(migrate_stream_v1_to_v2(v1)) + } + "2.0.0" => { + serde_json::from_value::(raw) + .map_err(|e| VersionedLoadError::InvalidStructure(e.to_string())) + } + _ => Err(VersionedLoadError::UnsupportedVersion(version.to_string())), + } +} +``` + +### 4. Test both directions + +```rust +#[test] +fn test_v1_migration() { + let json = r#"{"ast_version":"1.0.0","state_name":"Test","old_name":"Value"}"#; + let spec = load_stream_spec(json).unwrap(); + assert_eq!(spec.new_name, "Value"); + assert_eq!(spec.ast_version, "2.0.0"); +} + +#[test] +fn test_v2_native() { + let json = r#"{"ast_version":"2.0.0","state_name":"Test","new_name":"Value"}"#; + let spec = load_stream_spec(json).unwrap(); + assert_eq!(spec.new_name, "Value"); +} +``` + +## Best Practices + +1. **Always bump both crates** - Keep versions in sync between `hyperstack-macros` and `interpreter` + +2. **Keep old versions for 6+ months** - Give users time to upgrade their pipelines + +3. **Log migration warnings** - Let users know when their AST is being migrated + +4. **Test edge cases** - Missing fields, null values, malformed data + +5. **Document in CHANGELOG** - Note AST version changes in release notes + +6. **Use serde defaults** - For minor bumps, use `#[serde(default)]` to avoid breaking old ASTs + +7. **Validate after migration** - Ensure migrated ASTs pass validation + +## Migration Checklist + +Before releasing a new AST version: + +- [ ] Updated `CURRENT_AST_VERSION` in both type files +- [ ] Added migration logic in both versioned.rs files +- [ ] Updated all spec constructors +- [ ] Added tests for migration +- [ ] Tested loading old ASTs +- [ ] Tested loading new ASTs +- [ ] Updated documentation +- [ ] Added CHANGELOG entry +- [ ] Considered deprecation timeline + +## FAQ + +**Q: Can I skip versions? (e.g., 1.0.0 → 3.0.0)** + +A: Yes, but you must support all intermediate versions. If someone has v1.0.0 and you skip to v3.0.0, you need: +- 1.0.0 → 2.0.0 migration +- 2.0.0 → 3.0.0 migration + +**Q: What if I need to rollback?** + +A: AST versions are additive. Keep old migration code and tests. Users with new ASTs can't go back, but old ASTs continue working. + +**Q: How do I deprecate a version?** + +A: After your deprecation window, change the migration to return an error: + +```rust +"1.0.0" => Err(VersionedLoadError::UnsupportedVersion( + "1.0.0 deprecated, run: hyperstack migrate-ast".to_string() +)) +``` + +**Q: Can I automate AST upgrades?** + +A: Yes! Create a CLI command that: +1. Loads old AST +2. Migrates to latest +3. Writes back to file + +```rust +pub fn upgrade_ast_file(path: &Path) -> Result<()> { + let json = fs::read_to_string(path)?; + let spec = load_stream_spec(&json)?; // Auto-migrates + let upgraded = serde_json::to_string_pretty(&spec)?; + fs::write(path, upgraded)?; + Ok(()) +} +``` \ No newline at end of file From 55c6993669110f8a452b2dbb9b2e9eda3cb5c177 Mon Sep 17 00:00:00 2001 From: Adrian Henry Date: Sat, 21 Mar 2026 15:10:07 +0000 Subject: [PATCH 03/17] docs: Add version sync warnings and tests - Add warning comments to CURRENT_AST_VERSION in both crates explaining the circular dependency issue and the need to keep versions in sync - Add test_ast_version_sync_* tests in both versioned.rs files that verify the constants match between crates - Update the versioning guide to explain WHY versions must be bumped in 2 places and how the sync test helps catch mismatches --- docs/ast-versioning-guide.md | 17 +++++++++-- hyperstack-macros/src/ast/types.rs | 5 ++++ hyperstack-macros/src/ast/versioned.rs | 39 +++++++++++++++++++++++++ interpreter/src/ast.rs | 5 ++++ interpreter/src/versioned.rs | 40 ++++++++++++++++++++++++++ 5 files changed, 104 insertions(+), 2 deletions(-) diff --git a/docs/ast-versioning-guide.md b/docs/ast-versioning-guide.md index 859797a6..3dd3c85b 100644 --- a/docs/ast-versioning-guide.md +++ b/docs/ast-versioning-guide.md @@ -24,7 +24,9 @@ HyperStack uses **semantic versioning** for AST schemas (major.minor.patch): ### Step 1: Define the New AST Version -Update the version constant in **both** AST type files: +**⚠️ CRITICAL: You must update the version constant in BOTH crates.** + +The AST types are duplicated between `hyperstack-macros` (for compile-time code generation) and `interpreter` (for runtime) due to circular dependency constraints (proc-macro crates cannot depend on their output crates). Both crates must have the same `CURRENT_AST_VERSION` constant. **`hyperstack-macros/src/ast/types.rs`** ```rust @@ -40,10 +42,21 @@ pub const CURRENT_AST_VERSION: &str = "2.0.0"; **`interpreter/src/ast.rs`** ```rust -// Mirror the change here +// Mirror the EXACT same change here pub const CURRENT_AST_VERSION: &str = "2.0.0"; ``` +**Why two places?** The AST types exist in both crates: +- `hyperstack-macros`: Used at compile time when processing `#[hyperstack]` attributes +- `interpreter`: Used at runtime and for CLI tools (SDK generation, etc.) + +**Don't worry about forgetting:** There's a test (`test_ast_version_sync_*`) in both crates that will fail if the constants get out of sync. You'll see an error like: +``` +AST version mismatch! hyperstack-macros has '1.0.0', interpreter has '2.0.0'. +Both crates must have the same CURRENT_AST_VERSION. +Update both files when bumping the version. +``` + ### Step 2: Create the New AST Structure Define the new version of your types. You have two options: diff --git a/hyperstack-macros/src/ast/types.rs b/hyperstack-macros/src/ast/types.rs index 8e9fb481..2e627e51 100644 --- a/hyperstack-macros/src/ast/types.rs +++ b/hyperstack-macros/src/ast/types.rs @@ -305,6 +305,11 @@ pub enum UnaryOp { // ============================================================================ /// Current AST version for SerializableStreamSpec and SerializableStackSpec +/// +/// ⚠️ IMPORTANT: This constant is duplicated in interpreter/src/ast.rs due to +/// circular dependency between proc-macro crates and their output crates. +/// When bumping this version, you MUST also update the constant in the +/// interpreter crate. A test in versioned.rs verifies they stay in sync. pub const CURRENT_AST_VERSION: &str = "1.0.0"; #[derive(Debug, Clone, Serialize, Deserialize)] diff --git a/hyperstack-macros/src/ast/versioned.rs b/hyperstack-macros/src/ast/versioned.rs index a31456b9..68eee05b 100644 --- a/hyperstack-macros/src/ast/versioned.rs +++ b/hyperstack-macros/src/ast/versioned.rs @@ -280,4 +280,43 @@ mod tests { let json_no_version = r#"{"stack_name": "Test"}"#; assert_eq!(detect_ast_version(json_no_version).unwrap(), "1.0.0"); } + + /// Verifies that the AST version constant matches the interpreter crate. + /// This test ensures both crates stay in sync. + #[test] + fn test_ast_version_sync_with_interpreter() { + // Read the interpreter's ast.rs file + let manifest_dir = std::env::var("CARGO_MANIFEST_DIR").unwrap(); + let interpreter_ast_path = std::path::Path::new(&manifest_dir) + .join("..") + .join("..") + .join("interpreter") + .join("src") + .join("ast.rs"); + + if interpreter_ast_path.exists() { + let content = std::fs::read_to_string(&interpreter_ast_path) + .expect("Failed to read interpreter/src/ast.rs"); + + // Parse the CURRENT_AST_VERSION constant + let version_line = content + .lines() + .find(|line| line.contains("pub const CURRENT_AST_VERSION")) + .expect("CURRENT_AST_VERSION not found in interpreter"); + + let version_str = version_line + .split('"') + .nth(1) + .expect("Failed to parse version string"); + + assert_eq!( + version_str, CURRENT_AST_VERSION, + "AST version mismatch! hyperstack-macros has '{}', interpreter has '{}'. \ + Both crates must have the same CURRENT_AST_VERSION. \ + Update both files when bumping the version.", + CURRENT_AST_VERSION, version_str + ); + } + // If file doesn't exist (e.g., in some test environments), skip this test + } } diff --git a/interpreter/src/ast.rs b/interpreter/src/ast.rs index 6caeebbf..d8c8bf9f 100644 --- a/interpreter/src/ast.rs +++ b/interpreter/src/ast.rs @@ -6,6 +6,11 @@ use std::marker::PhantomData; pub use hyperstack_idl::snapshot::*; /// Current AST version for SerializableStreamSpec and SerializableStackSpec +/// +/// ⚠️ IMPORTANT: This constant is duplicated in hyperstack-macros/src/ast/types.rs due to +/// circular dependency between proc-macro crates and their output crates. +/// When bumping this version, you MUST also update the constant in the +/// hyperstack-macros crate. A test in versioned.rs verifies they stay in sync. pub const CURRENT_AST_VERSION: &str = "1.0.0"; fn default_ast_version() -> String { diff --git a/interpreter/src/versioned.rs b/interpreter/src/versioned.rs index c4468849..262ee8ec 100644 --- a/interpreter/src/versioned.rs +++ b/interpreter/src/versioned.rs @@ -280,4 +280,44 @@ mod tests { let json_no_version = r#"{"stack_name": "Test"}"#; assert_eq!(detect_ast_version(json_no_version).unwrap(), "1.0.0"); } + + /// Verifies that the AST version constant matches the hyperstack-macros crate. + /// This test ensures both crates stay in sync. + #[test] + fn test_ast_version_sync_with_macros() { + // Read the hyperstack-macros' types.rs file + let manifest_dir = std::env::var("CARGO_MANIFEST_DIR").unwrap(); + let macros_types_path = std::path::Path::new(&manifest_dir) + .join("..") + .join("..") + .join("hyperstack-macros") + .join("src") + .join("ast") + .join("types.rs"); + + if macros_types_path.exists() { + let content = std::fs::read_to_string(¯os_types_path) + .expect("Failed to read hyperstack-macros/src/ast/types.rs"); + + // Parse the CURRENT_AST_VERSION constant + let version_line = content + .lines() + .find(|line| line.contains("pub const CURRENT_AST_VERSION")) + .expect("CURRENT_AST_VERSION not found in hyperstack-macros"); + + let version_str = version_line + .split('"') + .nth(1) + .expect("Failed to parse version string"); + + assert_eq!( + version_str, CURRENT_AST_VERSION, + "AST version mismatch! interpreter has '{}', hyperstack-macros has '{}'. \ + Both crates must have the same CURRENT_AST_VERSION. \ + Update both files when bumping the version.", + CURRENT_AST_VERSION, version_str + ); + } + // If file doesn't exist (e.g., in some test environments), skip this test + } } From 273ab7ca4f7dafc9699d7b60b24879fbbd0bed59 Mon Sep 17 00:00:00 2001 From: Adrian Henry Date: Sat, 21 Mar 2026 15:12:19 +0000 Subject: [PATCH 04/17] chore: Change default AST version to 0.0.1 Starting versioning from 0.0.1 as the initial version before any breaking changes. This gives us room to iterate before reaching 1.0.0 stability. --- docs/ast-versioning-guide.md | 48 +++++++++++++------------- hyperstack-macros/src/ast/types.rs | 6 ++-- hyperstack-macros/src/ast/versioned.rs | 32 ++++++++--------- interpreter/src/ast.rs | 6 ++-- interpreter/src/versioned.rs | 32 ++++++++--------- 5 files changed, 62 insertions(+), 62 deletions(-) diff --git a/docs/ast-versioning-guide.md b/docs/ast-versioning-guide.md index 3dd3c85b..efd4b89b 100644 --- a/docs/ast-versioning-guide.md +++ b/docs/ast-versioning-guide.md @@ -14,11 +14,11 @@ HyperStack uses **semantic versioning** for AST schemas (major.minor.patch): | Change Type | Version Bump | Migration Required? | |------------|--------------|-------------------| -| Add new optional field | Minor (1.0.0 → 1.1.0) | No | -| Rename field | Major (1.0.0 → 2.0.0) | Yes | -| Remove field | Major (1.0.0 → 2.0.0) | Yes | -| Change field type | Major (1.0.0 → 2.0.0) | Yes | -| Restructure enum | Major (1.0.0 → 2.0.0) | Yes | +| Add new optional field | Minor (0.0.1 → 1.1.0) | No | +| Rename field | Major (0.0.1 → 2.0.0) | Yes | +| Remove field | Major (0.0.1 → 2.0.0) | Yes | +| Change field type | Major (0.0.1 → 2.0.0) | Yes | +| Restructure enum | Major (0.0.1 → 2.0.0) | Yes | ## Step-by-Step: Adding a Breaking Change @@ -31,7 +31,7 @@ The AST types are duplicated between `hyperstack-macros` (for compile-time code **`hyperstack-macros/src/ast/types.rs`** ```rust // Change this -pub const CURRENT_AST_VERSION: &str = "1.0.0"; +pub const CURRENT_AST_VERSION: &str = "0.0.1"; // To this (for a minor bump) pub const CURRENT_AST_VERSION: &str = "1.1.0"; @@ -52,7 +52,7 @@ pub const CURRENT_AST_VERSION: &str = "2.0.0"; **Don't worry about forgetting:** There's a test (`test_ast_version_sync_*`) in both crates that will fail if the constants get out of sync. You'll see an error like: ``` -AST version mismatch! hyperstack-macros has '1.0.0', interpreter has '2.0.0'. +AST version mismatch! hyperstack-macros has '0.0.1', interpreter has '2.0.0'. Both crates must have the same CURRENT_AST_VERSION. Update both files when bumping the version. ``` @@ -121,10 +121,10 @@ pub fn load_stream_spec(json: &str) -> Result { + "0.0.1" => { serde_json::from_value::(raw) .map_err(|e| VersionedLoadError::InvalidStructure(e.to_string())) } @@ -133,7 +133,7 @@ pub fn load_stream_spec(json: &str) -> Result(raw) .map_err(|e| VersionedLoadError::InvalidStructure(e.to_string())) } - "1.0.0" => { + "0.0.1" => { // OLD: Load v1 and migrate to v2 let v1: SerializableStreamSpecV1 = serde_json::from_value(raw) .map_err(|e| VersionedLoadError::InvalidStructure(e.to_string()))?; @@ -164,7 +164,7 @@ If you're using the `VersionedStreamSpec` enum for explicit version handling: #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(tag = "ast_version")] pub enum VersionedStreamSpec { - #[serde(rename = "1.0.0")] + #[serde(rename = "0.0.1")] V1(SerializableStreamSpecV1), #[serde(rename = "2.0.0")] V2(SerializableStreamSpec), // Current version @@ -221,7 +221,7 @@ mod tests { fn test_migrate_v1_to_v2() { let v1_json = r#" { - "ast_version": "1.0.0", + "ast_version": "0.0.1", "state_name": "TestEntity", "old_field": "old_value" } @@ -259,9 +259,9 @@ pub fn load_stream_spec(json: &str) -> Result { + "0.0.1" => { // Log deprecation warning - eprintln!("WARNING: Loading deprecated AST v1.0.0. Please upgrade your AST files."); + eprintln!("WARNING: Loading deprecated AST v0.0.1. Please upgrade your AST files."); let v1: SerializableStreamSpecV1 = serde_json::from_value(raw) .map_err(|e| VersionedLoadError::InvalidStructure(e.to_string()))?; @@ -275,9 +275,9 @@ pub fn load_stream_spec(json: &str) -> Result { +"0.0.1" => { Err(VersionedLoadError::UnsupportedVersion( - "1.0.0 (deprecated, please upgrade your AST files)".to_string() + "0.0.1 (deprecated, please upgrade your AST files)".to_string() )) } ``` @@ -321,10 +321,10 @@ fn migrate_stream_v1_to_v2(v1: SerializableStreamSpecV1) -> SerializableStreamSp pub fn load_stream_spec(json: &str) -> Result { let raw: Value = serde_json::from_str(json)?; - let version = raw.get("ast_version").and_then(|v| v.as_str()).unwrap_or("1.0.0"); + let version = raw.get("ast_version").and_then(|v| v.as_str()).unwrap_or("0.0.1"); match version { - "1.0.0" => { + "0.0.1" => { let v1: SerializableStreamSpecV1 = serde_json::from_value(raw)?; Ok(migrate_stream_v1_to_v2(v1)) } @@ -342,7 +342,7 @@ pub fn load_stream_spec(json: &str) -> Result Err(VersionedLoadError::UnsupportedVersion( - "1.0.0 deprecated, run: hyperstack migrate-ast".to_string() +"0.0.1" => Err(VersionedLoadError::UnsupportedVersion( + "0.0.1 deprecated, run: hyperstack migrate-ast".to_string() )) ``` diff --git a/hyperstack-macros/src/ast/types.rs b/hyperstack-macros/src/ast/types.rs index 2e627e51..0024c11e 100644 --- a/hyperstack-macros/src/ast/types.rs +++ b/hyperstack-macros/src/ast/types.rs @@ -310,12 +310,12 @@ pub enum UnaryOp { /// circular dependency between proc-macro crates and their output crates. /// When bumping this version, you MUST also update the constant in the /// interpreter crate. A test in versioned.rs verifies they stay in sync. -pub const CURRENT_AST_VERSION: &str = "1.0.0"; +pub const CURRENT_AST_VERSION: &str = "0.0.1"; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SerializableStreamSpec { /// AST schema version for backward compatibility - /// Uses semver format (e.g., "1.0.0") + /// Uses semver format (e.g., "0.0.1") #[serde(default = "default_ast_version")] pub ast_version: String, pub state_name: String, @@ -889,7 +889,7 @@ pub struct InstructionDef { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SerializableStackSpec { /// AST schema version for backward compatibility - /// Uses semver format (e.g., "1.0.0") + /// Uses semver format (e.g., "0.0.1") #[serde(default = "default_ast_version")] pub ast_version: String, pub stack_name: String, diff --git a/hyperstack-macros/src/ast/versioned.rs b/hyperstack-macros/src/ast/versioned.rs index 68eee05b..d5bd1bf5 100644 --- a/hyperstack-macros/src/ast/versioned.rs +++ b/hyperstack-macros/src/ast/versioned.rs @@ -79,15 +79,15 @@ pub fn load_stack_spec(json: &str) -> Result { + "0.0.1" => { // Current version - deserialize directly serde_json::from_value::(raw) .map_err(|e| VersionedLoadError::InvalidStructure(e.to_string())) @@ -115,15 +115,15 @@ pub fn load_stream_spec(json: &str) -> Result { + "0.0.1" => { // Current version - deserialize directly serde_json::from_value::(raw) .map_err(|e| VersionedLoadError::InvalidStructure(e.to_string())) @@ -142,7 +142,7 @@ pub fn load_stream_spec(json: &str) -> Result Result { .get("ast_version") .and_then(|v| v.as_str()) .map(|s| s.to_string()) - .unwrap_or_else(|| "1.0.0".to_string())) + .unwrap_or_else(|| "0.0.1".to_string())) } #[cfg(test)] @@ -212,7 +212,7 @@ mod tests { fn test_load_stack_spec_v1() { let json = r#" { - "ast_version": "1.0.0", + "ast_version": "0.0.1", "stack_name": "TestStack", "program_ids": [], "idls": [], @@ -226,12 +226,12 @@ mod tests { assert!(result.is_ok()); let spec = result.unwrap(); assert_eq!(spec.stack_name, "TestStack"); - assert_eq!(spec.ast_version, "1.0.0"); + assert_eq!(spec.ast_version, "0.0.1"); } #[test] fn test_load_stack_spec_no_version_defaults_to_v1() { - // Test backwards compatibility - no ast_version field should default to 1.0.0 + // Test backwards compatibility - no ast_version field should default to 0.0.1 let json = r#" { "stack_name": "TestStack", @@ -247,7 +247,7 @@ mod tests { assert!(result.is_ok()); let spec = result.unwrap(); assert_eq!(spec.stack_name, "TestStack"); - assert_eq!(spec.ast_version, "1.0.0"); + assert_eq!(spec.ast_version, "0.0.1"); } #[test] @@ -274,11 +274,11 @@ mod tests { #[test] fn test_detect_ast_version() { - let json = r#"{"ast_version": "1.0.0", "stack_name": "Test"}"#; - assert_eq!(detect_ast_version(json).unwrap(), "1.0.0"); + let json = r#"{"ast_version": "0.0.1", "stack_name": "Test"}"#; + assert_eq!(detect_ast_version(json).unwrap(), "0.0.1"); let json_no_version = r#"{"stack_name": "Test"}"#; - assert_eq!(detect_ast_version(json_no_version).unwrap(), "1.0.0"); + assert_eq!(detect_ast_version(json_no_version).unwrap(), "0.0.1"); } /// Verifies that the AST version constant matches the interpreter crate. diff --git a/interpreter/src/ast.rs b/interpreter/src/ast.rs index d8c8bf9f..5d800dd0 100644 --- a/interpreter/src/ast.rs +++ b/interpreter/src/ast.rs @@ -11,7 +11,7 @@ pub use hyperstack_idl::snapshot::*; /// circular dependency between proc-macro crates and their output crates. /// When bumping this version, you MUST also update the constant in the /// hyperstack-macros crate. A test in versioned.rs verifies they stay in sync. -pub const CURRENT_AST_VERSION: &str = "1.0.0"; +pub const CURRENT_AST_VERSION: &str = "0.0.1"; fn default_ast_version() -> String { CURRENT_AST_VERSION.to_string() @@ -390,7 +390,7 @@ pub enum UnaryOp { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SerializableStreamSpec { /// AST schema version for backward compatibility - /// Uses semver format (e.g., "1.0.0") + /// Uses semver format (e.g., "0.0.1") #[serde(default = "default_ast_version")] pub ast_version: String, pub state_name: String, @@ -1332,7 +1332,7 @@ pub struct InstructionDef { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SerializableStackSpec { /// AST schema version for backward compatibility - /// Uses semver format (e.g., "1.0.0") + /// Uses semver format (e.g., "0.0.1") #[serde(default = "default_ast_version")] pub ast_version: String, /// Stack name (PascalCase, derived from module ident) diff --git a/interpreter/src/versioned.rs b/interpreter/src/versioned.rs index 262ee8ec..6645ae3e 100644 --- a/interpreter/src/versioned.rs +++ b/interpreter/src/versioned.rs @@ -79,15 +79,15 @@ pub fn load_stack_spec(json: &str) -> Result { + "0.0.1" => { // Current version - deserialize directly serde_json::from_value::(raw) .map_err(|e| VersionedLoadError::InvalidStructure(e.to_string())) @@ -115,15 +115,15 @@ pub fn load_stream_spec(json: &str) -> Result { + "0.0.1" => { // Current version - deserialize directly serde_json::from_value::(raw) .map_err(|e| VersionedLoadError::InvalidStructure(e.to_string())) @@ -142,7 +142,7 @@ pub fn load_stream_spec(json: &str) -> Result Result { .get("ast_version") .and_then(|v| v.as_str()) .map(|s| s.to_string()) - .unwrap_or_else(|| "1.0.0".to_string())) + .unwrap_or_else(|| "0.0.1".to_string())) } #[cfg(test)] @@ -212,7 +212,7 @@ mod tests { fn test_load_stack_spec_v1() { let json = r#" { - "ast_version": "1.0.0", + "ast_version": "0.0.1", "stack_name": "TestStack", "program_ids": [], "idls": [], @@ -226,12 +226,12 @@ mod tests { assert!(result.is_ok()); let spec = result.unwrap(); assert_eq!(spec.stack_name, "TestStack"); - assert_eq!(spec.ast_version, "1.0.0"); + assert_eq!(spec.ast_version, "0.0.1"); } #[test] fn test_load_stack_spec_no_version_defaults_to_v1() { - // Test backwards compatibility - no ast_version field should default to 1.0.0 + // Test backwards compatibility - no ast_version field should default to 0.0.1 let json = r#" { "stack_name": "TestStack", @@ -247,7 +247,7 @@ mod tests { assert!(result.is_ok()); let spec = result.unwrap(); assert_eq!(spec.stack_name, "TestStack"); - assert_eq!(spec.ast_version, "1.0.0"); + assert_eq!(spec.ast_version, "0.0.1"); } #[test] @@ -274,11 +274,11 @@ mod tests { #[test] fn test_detect_ast_version() { - let json = r#"{"ast_version": "1.0.0", "stack_name": "Test"}"#; - assert_eq!(detect_ast_version(json).unwrap(), "1.0.0"); + let json = r#"{"ast_version": "0.0.1", "stack_name": "Test"}"#; + assert_eq!(detect_ast_version(json).unwrap(), "0.0.1"); let json_no_version = r#"{"stack_name": "Test"}"#; - assert_eq!(detect_ast_version(json_no_version).unwrap(), "1.0.0"); + assert_eq!(detect_ast_version(json_no_version).unwrap(), "0.0.1"); } /// Verifies that the AST version constant matches the hyperstack-macros crate. From 1d6540fd71a66e76a211de35e81a89cfe584dc66 Mon Sep 17 00:00:00 2001 From: Adrian Henry Date: Sat, 21 Mar 2026 15:24:55 +0000 Subject: [PATCH 05/17] fix: Address clippy warnings in AST versioning module - Remove unused re-exports from ast/mod.rs - Add #![allow(dead_code)] to versioned.rs module with explanation that these are public API items for future use --- hyperstack-macros/src/ast/mod.rs | 3 --- hyperstack-macros/src/ast/versioned.rs | 6 ++++++ 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/hyperstack-macros/src/ast/mod.rs b/hyperstack-macros/src/ast/mod.rs index 456bc9c1..24a5f950 100644 --- a/hyperstack-macros/src/ast/mod.rs +++ b/hyperstack-macros/src/ast/mod.rs @@ -54,6 +54,3 @@ pub(crate) mod writer; // Re-export all types for easy access pub use types::*; - -// Re-export versioned loader for convenience -pub use versioned::{detect_ast_version, load_stack_spec, load_stream_spec}; diff --git a/hyperstack-macros/src/ast/versioned.rs b/hyperstack-macros/src/ast/versioned.rs index d5bd1bf5..4e7f5437 100644 --- a/hyperstack-macros/src/ast/versioned.rs +++ b/hyperstack-macros/src/ast/versioned.rs @@ -14,6 +14,12 @@ //! let stream = load_stream_spec(&json_string)?; //! ``` +// This module provides a public API for loading versioned ASTs. +// While not all items are currently used within this crate, they are +// part of the public API available for external use and future features +// like the `#[ast_spec]` macro or CLI tooling. +#![allow(dead_code)] + use serde::{Deserialize, Serialize}; use serde_json::Value; use std::fmt; From 5df9efe883d5880f6c440ae8e0577004041d53ca Mon Sep 17 00:00:00 2001 From: Adrian Henry Date: Sat, 21 Mar 2026 15:57:57 +0000 Subject: [PATCH 06/17] fix: Use CURRENT_AST_VERSION constant instead of hardcoded version Changed version routing in load_stack_spec and load_stream_spec to use CURRENT_AST_VERSION constant instead of hardcoded "0.0.1". This ensures newly written ASTs with bumped versions are recognized as current format instead of falling through to UnsupportedVersion error. --- hyperstack-macros/src/ast/versioned.rs | 8 ++++++-- interpreter/src/versioned.rs | 8 ++++++-- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/hyperstack-macros/src/ast/versioned.rs b/hyperstack-macros/src/ast/versioned.rs index 4e7f5437..aafeb380 100644 --- a/hyperstack-macros/src/ast/versioned.rs +++ b/hyperstack-macros/src/ast/versioned.rs @@ -93,11 +93,13 @@ pub fn load_stack_spec(json: &str) -> Result { + v if v == CURRENT_AST_VERSION => { // Current version - deserialize directly serde_json::from_value::(raw) .map_err(|e| VersionedLoadError::InvalidStructure(e.to_string())) } + // Add migration arms for old versions here, e.g.: + // "0.0.1" => { migrate_v1_to_latest(raw) } _ => { // Unknown version Err(VersionedLoadError::UnsupportedVersion(version.to_string())) @@ -129,11 +131,13 @@ pub fn load_stream_spec(json: &str) -> Result { + v if v == CURRENT_AST_VERSION => { // Current version - deserialize directly serde_json::from_value::(raw) .map_err(|e| VersionedLoadError::InvalidStructure(e.to_string())) } + // Add migration arms for old versions here, e.g.: + // "0.0.1" => { migrate_v1_to_latest(raw) } _ => { // Unknown version Err(VersionedLoadError::UnsupportedVersion(version.to_string())) diff --git a/interpreter/src/versioned.rs b/interpreter/src/versioned.rs index 6645ae3e..5f0dcb4c 100644 --- a/interpreter/src/versioned.rs +++ b/interpreter/src/versioned.rs @@ -87,11 +87,13 @@ pub fn load_stack_spec(json: &str) -> Result { + v if v == CURRENT_AST_VERSION => { // Current version - deserialize directly serde_json::from_value::(raw) .map_err(|e| VersionedLoadError::InvalidStructure(e.to_string())) } + // Add migration arms for old versions here, e.g.: + // "0.0.1" => { migrate_v1_to_latest(raw) } _ => { // Unknown version Err(VersionedLoadError::UnsupportedVersion(version.to_string())) @@ -123,11 +125,13 @@ pub fn load_stream_spec(json: &str) -> Result { + v if v == CURRENT_AST_VERSION => { // Current version - deserialize directly serde_json::from_value::(raw) .map_err(|e| VersionedLoadError::InvalidStructure(e.to_string())) } + // Add migration arms for old versions here, e.g.: + // "0.0.1" => { migrate_v1_to_latest(raw) } _ => { // Unknown version Err(VersionedLoadError::UnsupportedVersion(version.to_string())) From c824e3dc74e102d57eb96485d9dad0c925d0bfc3 Mon Sep 17 00:00:00 2001 From: Adrian Henry Date: Sat, 21 Mar 2026 16:05:03 +0000 Subject: [PATCH 07/17] fix: Make sync tests fail explicitly when source file not found Changed test_ast_version_sync_* tests to fail with a clear assertion instead of silently passing when the referenced source file doesn't exist. Also fixed the path construction to use only one '..' component since crates are siblings in the workspace. This ensures the tests provide actual safety in CI and don't give false confidence when run in environments where the source tree layout differs. --- hyperstack-macros/src/ast/versioned.rs | 56 ++++++++++++++------------ interpreter/src/versioned.rs | 56 ++++++++++++++------------ 2 files changed, 60 insertions(+), 52 deletions(-) diff --git a/hyperstack-macros/src/ast/versioned.rs b/hyperstack-macros/src/ast/versioned.rs index aafeb380..0a523c09 100644 --- a/hyperstack-macros/src/ast/versioned.rs +++ b/hyperstack-macros/src/ast/versioned.rs @@ -298,35 +298,39 @@ mod tests { // Read the interpreter's ast.rs file let manifest_dir = std::env::var("CARGO_MANIFEST_DIR").unwrap(); let interpreter_ast_path = std::path::Path::new(&manifest_dir) - .join("..") - .join("..") + .join("..") // Go up to workspace root .join("interpreter") .join("src") .join("ast.rs"); - if interpreter_ast_path.exists() { - let content = std::fs::read_to_string(&interpreter_ast_path) - .expect("Failed to read interpreter/src/ast.rs"); - - // Parse the CURRENT_AST_VERSION constant - let version_line = content - .lines() - .find(|line| line.contains("pub const CURRENT_AST_VERSION")) - .expect("CURRENT_AST_VERSION not found in interpreter"); - - let version_str = version_line - .split('"') - .nth(1) - .expect("Failed to parse version string"); - - assert_eq!( - version_str, CURRENT_AST_VERSION, - "AST version mismatch! hyperstack-macros has '{}', interpreter has '{}'. \ - Both crates must have the same CURRENT_AST_VERSION. \ - Update both files when bumping the version.", - CURRENT_AST_VERSION, version_str - ); - } - // If file doesn't exist (e.g., in some test environments), skip this test + // Verify the file exists before attempting to read + assert!( + interpreter_ast_path.exists(), + "Cannot find interpreter source file at {:?}. \ + This test requires the source tree to be available.", + interpreter_ast_path + ); + + let content = std::fs::read_to_string(&interpreter_ast_path) + .expect("Failed to read interpreter/src/ast.rs"); + + // Parse the CURRENT_AST_VERSION constant + let version_line = content + .lines() + .find(|line| line.contains("pub const CURRENT_AST_VERSION")) + .expect("CURRENT_AST_VERSION not found in interpreter"); + + let version_str = version_line + .split('"') + .nth(1) + .expect("Failed to parse version string"); + + assert_eq!( + version_str, CURRENT_AST_VERSION, + "AST version mismatch! hyperstack-macros has '{}', interpreter has '{}'. \ + Both crates must have the same CURRENT_AST_VERSION. \ + Update both files when bumping the version.", + CURRENT_AST_VERSION, version_str + ); } } diff --git a/interpreter/src/versioned.rs b/interpreter/src/versioned.rs index 5f0dcb4c..51b412b7 100644 --- a/interpreter/src/versioned.rs +++ b/interpreter/src/versioned.rs @@ -292,36 +292,40 @@ mod tests { // Read the hyperstack-macros' types.rs file let manifest_dir = std::env::var("CARGO_MANIFEST_DIR").unwrap(); let macros_types_path = std::path::Path::new(&manifest_dir) - .join("..") - .join("..") + .join("..") // Go up to workspace root .join("hyperstack-macros") .join("src") .join("ast") .join("types.rs"); - if macros_types_path.exists() { - let content = std::fs::read_to_string(¯os_types_path) - .expect("Failed to read hyperstack-macros/src/ast/types.rs"); - - // Parse the CURRENT_AST_VERSION constant - let version_line = content - .lines() - .find(|line| line.contains("pub const CURRENT_AST_VERSION")) - .expect("CURRENT_AST_VERSION not found in hyperstack-macros"); - - let version_str = version_line - .split('"') - .nth(1) - .expect("Failed to parse version string"); - - assert_eq!( - version_str, CURRENT_AST_VERSION, - "AST version mismatch! interpreter has '{}', hyperstack-macros has '{}'. \ - Both crates must have the same CURRENT_AST_VERSION. \ - Update both files when bumping the version.", - CURRENT_AST_VERSION, version_str - ); - } - // If file doesn't exist (e.g., in some test environments), skip this test + // Verify the file exists before attempting to read + assert!( + macros_types_path.exists(), + "Cannot find hyperstack-macros source file at {:?}. \ + This test requires the source tree to be available.", + macros_types_path + ); + + let content = std::fs::read_to_string(¯os_types_path) + .expect("Failed to read hyperstack-macros/src/ast/types.rs"); + + // Parse the CURRENT_AST_VERSION constant + let version_line = content + .lines() + .find(|line| line.contains("pub const CURRENT_AST_VERSION")) + .expect("CURRENT_AST_VERSION not found in hyperstack-macros"); + + let version_str = version_line + .split('"') + .nth(1) + .expect("Failed to parse version string"); + + assert_eq!( + version_str, CURRENT_AST_VERSION, + "AST version mismatch! interpreter has '{}', hyperstack-macros has '{}'. \ + Both crates must have the same CURRENT_AST_VERSION. \ + Update both files when bumping the version.", + CURRENT_AST_VERSION, version_str + ); } } From 3061508d494b66b225ace3a0627c97c733cbbe93 Mon Sep 17 00:00:00 2001 From: Adrian Henry Date: Sat, 21 Mar 2026 16:06:40 +0000 Subject: [PATCH 08/17] docs: Fix duplicate match arm in versioning guide example Remove duplicate unreachable '0.0.1' pattern in the migration example. The example now shows proper progression from oldest to newest: - '2.0.0' = current version (deserialize directly) - '1.0.0' = old v1 (migrate to v2) - '0.0.1' = old v0 (migrate to v2) --- docs/ast-versioning-guide.md | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/docs/ast-versioning-guide.md b/docs/ast-versioning-guide.md index efd4b89b..8b359e28 100644 --- a/docs/ast-versioning-guide.md +++ b/docs/ast-versioning-guide.md @@ -124,21 +124,23 @@ pub fn load_stream_spec(json: &str) -> Result { - serde_json::from_value::(raw) - .map_err(|e| VersionedLoadError::InvalidStructure(e.to_string())) - } "2.0.0" => { - // v2 is current, deserialize directly + // Current version - deserialize directly serde_json::from_value::(raw) .map_err(|e| VersionedLoadError::InvalidStructure(e.to_string())) } - "0.0.1" => { + "1.0.0" => { // OLD: Load v1 and migrate to v2 let v1: SerializableStreamSpecV1 = serde_json::from_value(raw) .map_err(|e| VersionedLoadError::InvalidStructure(e.to_string()))?; Ok(migrate_stream_v1_to_v2(v1)) } + "0.0.1" => { + // OLD: Load v0.0.1 and migrate to v2 + let v0: SerializableStreamSpecV0 = serde_json::from_value(raw) + .map_err(|e| VersionedLoadError::InvalidStructure(e.to_string()))?; + Ok(migrate_v0_to_v2(v0)) + } _ => Err(VersionedLoadError::UnsupportedVersion(version.to_string())), } } From cf4ea2667d97885d714b28bff6803ee2506ab4f7 Mon Sep 17 00:00:00 2001 From: Adrian Henry Date: Sat, 21 Mar 2026 16:11:29 +0000 Subject: [PATCH 09/17] docs: Fix incorrect semver bump examples in versioning guide MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixed version progression examples to follow proper semver: - Minor bumps: 0.0.1 → 0.1.0 (not 1.1.0) - Major bumps: 0.0.1 → 1.0.0 (not 2.0.0) Also fixed the FAQ example about skipping versions to show correct intermediate version progression. --- docs/ast-versioning-guide.md | 23 ++++++++++++----------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/docs/ast-versioning-guide.md b/docs/ast-versioning-guide.md index 8b359e28..e963501c 100644 --- a/docs/ast-versioning-guide.md +++ b/docs/ast-versioning-guide.md @@ -14,11 +14,11 @@ HyperStack uses **semantic versioning** for AST schemas (major.minor.patch): | Change Type | Version Bump | Migration Required? | |------------|--------------|-------------------| -| Add new optional field | Minor (0.0.1 → 1.1.0) | No | -| Rename field | Major (0.0.1 → 2.0.0) | Yes | -| Remove field | Major (0.0.1 → 2.0.0) | Yes | -| Change field type | Major (0.0.1 → 2.0.0) | Yes | -| Restructure enum | Major (0.0.1 → 2.0.0) | Yes | +| Add new optional field | Minor (0.0.1 → 0.1.0) | No | +| Rename field | Major (0.0.1 → 1.0.0) | Yes | +| Remove field | Major (0.0.1 → 1.0.0) | Yes | +| Change field type | Major (0.0.1 → 1.0.0) | Yes | +| Restructure enum | Major (0.0.1 → 1.0.0) | Yes | ## Step-by-Step: Adding a Breaking Change @@ -34,16 +34,16 @@ The AST types are duplicated between `hyperstack-macros` (for compile-time code pub const CURRENT_AST_VERSION: &str = "0.0.1"; // To this (for a minor bump) -pub const CURRENT_AST_VERSION: &str = "1.1.0"; +pub const CURRENT_AST_VERSION: &str = "0.1.0"; // Or this (for a major bump) -pub const CURRENT_AST_VERSION: &str = "2.0.0"; +pub const CURRENT_AST_VERSION: &str = "1.0.0"; ``` **`interpreter/src/ast.rs`** ```rust // Mirror the EXACT same change here -pub const CURRENT_AST_VERSION: &str = "2.0.0"; +pub const CURRENT_AST_VERSION: &str = "1.0.0"; ``` **Why two places?** The AST types exist in both crates: @@ -52,7 +52,7 @@ pub const CURRENT_AST_VERSION: &str = "2.0.0"; **Don't worry about forgetting:** There's a test (`test_ast_version_sync_*`) in both crates that will fail if the constants get out of sync. You'll see an error like: ``` -AST version mismatch! hyperstack-macros has '0.0.1', interpreter has '2.0.0'. +AST version mismatch! hyperstack-macros has '0.0.1', interpreter has '1.0.0'. Both crates must have the same CURRENT_AST_VERSION. Update both files when bumping the version. ``` @@ -73,7 +73,7 @@ pub struct SerializableStreamSpec { pub state_name: String, // ... existing fields ... - // NEW in v1.1.0 + // NEW in v0.1.0 #[serde(default)] pub new_field: Option, } @@ -393,7 +393,8 @@ Before releasing a new AST version: **Q: Can I skip versions? (e.g., 0.0.1 → 3.0.0)** A: Yes, but you must support all intermediate versions. If someone has v0.0.1 and you skip to v3.0.0, you need: -- 0.0.1 → 2.0.0 migration +- 0.0.1 → 1.0.0 migration +- 1.0.0 → 2.0.0 migration - 2.0.0 → 3.0.0 migration **Q: What if I need to rollback?** From 1791986fa0e37f5762a57c166ecbcf6be26bcb0b Mon Sep 17 00:00:00 2001 From: Adrian Henry Date: Sat, 21 Mar 2026 16:23:26 +0000 Subject: [PATCH 10/17] fix: Address code review feedback on AST versioning - Add comprehensive tests for load_stream_spec in both crates (happy path, no-version backward compat, unsupported version) - Fix FAQ about version-skipping: only need migrations for released versions, not every intermediate version number - Replace module-level #![allow(dead_code)] with targeted attributes on specific public API items that aren't yet used within the crate --- docs/ast-versioning-guide.md | 8 +-- hyperstack-macros/src/ast/versioned.rs | 96 +++++++++++++++++++++++++- interpreter/src/versioned.rs | 79 +++++++++++++++++++++ 3 files changed, 178 insertions(+), 5 deletions(-) diff --git a/docs/ast-versioning-guide.md b/docs/ast-versioning-guide.md index e963501c..c752e364 100644 --- a/docs/ast-versioning-guide.md +++ b/docs/ast-versioning-guide.md @@ -392,10 +392,10 @@ Before releasing a new AST version: **Q: Can I skip versions? (e.g., 0.0.1 → 3.0.0)** -A: Yes, but you must support all intermediate versions. If someone has v0.0.1 and you skip to v3.0.0, you need: -- 0.0.1 → 1.0.0 migration -- 1.0.0 → 2.0.0 migration -- 2.0.0 → 3.0.0 migration +A: Yes. You only need migration arms for versions that were actually released, not every intermediate version number. If someone has v0.0.1 and you jump to v3.0.0, you only need: +- 0.0.1 → 3.0.0 migration + +The numeric distance doesn't matter—only which versions actually exist in the wild. **Q: What if I need to rollback?** diff --git a/hyperstack-macros/src/ast/versioned.rs b/hyperstack-macros/src/ast/versioned.rs index 0a523c09..cea6afe8 100644 --- a/hyperstack-macros/src/ast/versioned.rs +++ b/hyperstack-macros/src/ast/versioned.rs @@ -18,7 +18,6 @@ // While not all items are currently used within this crate, they are // part of the public API available for external use and future features // like the `#[ast_spec]` macro or CLI tooling. -#![allow(dead_code)] use serde::{Deserialize, Serialize}; use serde_json::Value; @@ -27,6 +26,8 @@ use std::fmt; use super::types::{SerializableStackSpec, SerializableStreamSpec, CURRENT_AST_VERSION}; /// Error type for versioned AST loading failures. +// Not yet used within this crate, but part of public API for future use +#[allow(dead_code)] #[derive(Debug, Clone)] pub enum VersionedLoadError { /// The JSON could not be parsed @@ -80,6 +81,8 @@ impl std::error::Error for VersionedLoadError {} /// let json = std::fs::read_to_string("MyStack.stack.json")?; /// let spec = load_stack_spec(&json)?; /// ``` +// Not yet used within this crate, but part of public API for future use +#[allow(dead_code)] pub fn load_stack_spec(json: &str) -> Result { // Parse raw JSON to detect version let raw: Value = @@ -118,6 +121,8 @@ pub fn load_stack_spec(json: &str) -> Result Result { // Parse raw JSON to detect version let raw: Value = @@ -149,6 +154,8 @@ pub fn load_stream_spec(json: &str) -> Result SerializableStackSpec { match self { VersionedStackSpec::V1(spec) => spec, @@ -169,6 +178,8 @@ impl VersionedStackSpec { /// /// This enum allows deserializing multiple AST versions and then /// converting them to the latest format via `into_latest()`. +// Not yet used within this crate, but part of public API for future use +#[allow(dead_code)] #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(tag = "ast_version")] pub enum VersionedStreamSpec { @@ -178,6 +189,8 @@ pub enum VersionedStreamSpec { impl VersionedStreamSpec { /// Convert the versioned spec to the latest format. + // Not yet used within this crate, but part of public API for future use + #[allow(dead_code)] pub fn into_latest(self) -> SerializableStreamSpec { match self { VersionedStreamSpec::V1(spec) => spec, @@ -203,6 +216,8 @@ impl VersionedStreamSpec { /// let version = detect_ast_version(&json)?; /// println!("AST version: {}", version); /// ``` +// Not yet used within this crate, but part of public API for future use +#[allow(dead_code)] pub fn detect_ast_version(json: &str) -> Result { let raw: Value = serde_json::from_str(json).map_err(|e| VersionedLoadError::InvalidJson(e.to_string()))?; @@ -282,6 +297,85 @@ mod tests { } } + #[test] + fn test_load_stream_spec_v1() { + let json = r#" + { + "ast_version": "0.0.1", + "state_name": "TestEntity", + "identity": {"primary_keys": ["id"], "lookup_indexes": []}, + "handlers": [], + "sections": [], + "field_mappings": {}, + "resolver_hooks": [], + "instruction_hooks": [], + "resolver_specs": [], + "computed_fields": [], + "computed_field_specs": [], + "views": [] + } + "#; + + let result = load_stream_spec(json); + assert!(result.is_ok()); + let spec = result.unwrap(); + assert_eq!(spec.state_name, "TestEntity"); + assert_eq!(spec.ast_version, "0.0.1"); + } + + #[test] + fn test_load_stream_spec_no_version_defaults_to_v1() { + // Test backwards compatibility - no ast_version field should default to 0.0.1 + let json = r#" + { + "state_name": "TestEntity", + "identity": {"primary_keys": ["id"], "lookup_indexes": []}, + "handlers": [], + "sections": [], + "field_mappings": {}, + "resolver_hooks": [], + "instruction_hooks": [], + "resolver_specs": [], + "computed_fields": [], + "computed_field_specs": [], + "views": [] + } + "#; + + let result = load_stream_spec(json); + assert!(result.is_ok()); + let spec = result.unwrap(); + assert_eq!(spec.state_name, "TestEntity"); + assert_eq!(spec.ast_version, "0.0.1"); + } + + #[test] + fn test_load_stream_spec_unsupported_version() { + let json = r#" + { + "ast_version": "99.0.0", + "state_name": "TestEntity", + "identity": {"primary_keys": ["id"], "lookup_indexes": []}, + "handlers": [], + "sections": [], + "field_mappings": {}, + "resolver_hooks": [], + "instruction_hooks": [], + "resolver_specs": [], + "computed_fields": [], + "computed_field_specs": [], + "views": [] + } + "#; + + let result = load_stream_spec(json); + assert!(result.is_err()); + match result.unwrap_err() { + VersionedLoadError::UnsupportedVersion(v) => assert_eq!(v, "99.0.0"), + _ => panic!("Expected UnsupportedVersion error"), + } + } + #[test] fn test_detect_ast_version() { let json = r#"{"ast_version": "0.0.1", "stack_name": "Test"}"#; diff --git a/interpreter/src/versioned.rs b/interpreter/src/versioned.rs index 51b412b7..06283eff 100644 --- a/interpreter/src/versioned.rs +++ b/interpreter/src/versioned.rs @@ -276,6 +276,85 @@ mod tests { } } + #[test] + fn test_load_stream_spec_v1() { + let json = r#" + { + "ast_version": "0.0.1", + "state_name": "TestEntity", + "identity": {"primary_keys": ["id"], "lookup_indexes": []}, + "handlers": [], + "sections": [], + "field_mappings": {}, + "resolver_hooks": [], + "instruction_hooks": [], + "resolver_specs": [], + "computed_fields": [], + "computed_field_specs": [], + "views": [] + } + "#; + + let result = load_stream_spec(json); + assert!(result.is_ok()); + let spec = result.unwrap(); + assert_eq!(spec.state_name, "TestEntity"); + assert_eq!(spec.ast_version, "0.0.1"); + } + + #[test] + fn test_load_stream_spec_no_version_defaults_to_v1() { + // Test backwards compatibility - no ast_version field should default to 0.0.1 + let json = r#" + { + "state_name": "TestEntity", + "identity": {"primary_keys": ["id"], "lookup_indexes": []}, + "handlers": [], + "sections": [], + "field_mappings": {}, + "resolver_hooks": [], + "instruction_hooks": [], + "resolver_specs": [], + "computed_fields": [], + "computed_field_specs": [], + "views": [] + } + "#; + + let result = load_stream_spec(json); + assert!(result.is_ok()); + let spec = result.unwrap(); + assert_eq!(spec.state_name, "TestEntity"); + assert_eq!(spec.ast_version, "0.0.1"); + } + + #[test] + fn test_load_stream_spec_unsupported_version() { + let json = r#" + { + "ast_version": "99.0.0", + "state_name": "TestEntity", + "identity": {"primary_keys": ["id"], "lookup_indexes": []}, + "handlers": [], + "sections": [], + "field_mappings": {}, + "resolver_hooks": [], + "instruction_hooks": [], + "resolver_specs": [], + "computed_fields": [], + "computed_field_specs": [], + "views": [] + } + "#; + + let result = load_stream_spec(json); + assert!(result.is_err()); + match result.unwrap_err() { + VersionedLoadError::UnsupportedVersion(v) => assert_eq!(v, "99.0.0"), + _ => panic!("Expected UnsupportedVersion error"), + } + } + #[test] fn test_detect_ast_version() { let json = r#"{"ast_version": "0.0.1", "stack_name": "Test"}"#; From 262be585c0af291dbcec6a41bed14f3184b5d2b8 Mon Sep 17 00:00:00 2001 From: Adrian Henry Date: Sat, 21 Mar 2026 16:32:39 +0000 Subject: [PATCH 11/17] fix: Address code review feedback on error messages and examples - Fix misleading context message in CLI that blamed 'version detection' for all load errors; now uses generic 'Failed to load' message - Fix example in versioning guide to use .map_err() with ? instead of bare ? which won't compile (VersionedLoadError doesn't implement From) - Fix migration example to use CURRENT_AST_VERSION constant instead of hardcoded version string --- cli/src/commands/sdk.rs | 9 ++------- docs/ast-versioning-guide.md | 10 ++++++---- 2 files changed, 8 insertions(+), 11 deletions(-) diff --git a/cli/src/commands/sdk.rs b/cli/src/commands/sdk.rs index 74756122..1eb0abd0 100644 --- a/cli/src/commands/sdk.rs +++ b/cli/src/commands/sdk.rs @@ -247,13 +247,8 @@ fn load_stack_spec( .with_context(|| format!("Failed to read stack file: {}", ast.path.display()))?; // Use versioned loader for automatic version detection and migration - let stack_spec = - hyperstack_interpreter::versioned::load_stack_spec(&ast_json).with_context(|| { - format!( - "Failed to load stack AST from {} (version detection failed)", - ast.path.display() - ) - })?; + let stack_spec = hyperstack_interpreter::versioned::load_stack_spec(&ast_json) + .with_context(|| format!("Failed to load stack AST from {}", ast.path.display()))?; if stack_spec.entities.is_empty() { return Err(anyhow::anyhow!( diff --git a/docs/ast-versioning-guide.md b/docs/ast-versioning-guide.md index c752e364..fbaabf53 100644 --- a/docs/ast-versioning-guide.md +++ b/docs/ast-versioning-guide.md @@ -315,19 +315,21 @@ pub struct SerializableStreamSpec { ```rust fn migrate_stream_v1_to_v2(v1: SerializableStreamSpecV1) -> SerializableStreamSpec { SerializableStreamSpec { - ast_version: "2.0.0".to_string(), + ast_version: CURRENT_AST_VERSION.to_string(), state_name: v1.state_name, new_name: v1.old_name, // Direct mapping } } pub fn load_stream_spec(json: &str) -> Result { - let raw: Value = serde_json::from_str(json)?; + let raw: Value = serde_json::from_str(json) + .map_err(|e| VersionedLoadError::InvalidJson(e.to_string()))?; let version = raw.get("ast_version").and_then(|v| v.as_str()).unwrap_or("0.0.1"); match version { "0.0.1" => { - let v1: SerializableStreamSpecV1 = serde_json::from_value(raw)?; + let v1: SerializableStreamSpecV1 = serde_json::from_value(raw) + .map_err(|e| VersionedLoadError::InvalidStructure(e.to_string()))?; Ok(migrate_stream_v1_to_v2(v1)) } "2.0.0" => { @@ -347,7 +349,7 @@ fn test_v1_migration() { let json = r#"{"ast_version":"0.0.1","state_name":"Test","old_name":"Value"}"#; let spec = load_stream_spec(json).unwrap(); assert_eq!(spec.new_name, "Value"); - assert_eq!(spec.ast_version, "2.0.0"); + assert_eq!(spec.ast_version, CURRENT_AST_VERSION); } #[test] From 73ad7b4b8519a30b7dcfca66badd05daf1eee085 Mon Sep 17 00:00:00 2001 From: Adrian Henry Date: Sat, 21 Mar 2026 16:48:21 +0000 Subject: [PATCH 12/17] fix: Remove Serialize derive from Versioned*Spec enums to prevent duplicate keys The VersionedStackSpec and VersionedStreamSpec enums used #[serde(tag = ast_version)] which conflicted with the ast_version field in the inner struct types, causing duplicate keys in serialized JSON (invalid per RFC 8259). Fixed by removing the Serialize derive from these enums since they're only used for deserialization and conversion via into_latest(), not for serialization. The struct types themselves handle serialization with their ast_version field. Also removed now-unused Serialize imports. --- hyperstack-macros/src/ast/versioned.rs | 14 +++++++++----- interpreter/src/versioned.rs | 10 +++++++--- 2 files changed, 16 insertions(+), 8 deletions(-) diff --git a/hyperstack-macros/src/ast/versioned.rs b/hyperstack-macros/src/ast/versioned.rs index cea6afe8..81cd5c18 100644 --- a/hyperstack-macros/src/ast/versioned.rs +++ b/hyperstack-macros/src/ast/versioned.rs @@ -19,7 +19,7 @@ // part of the public API available for external use and future features // like the `#[ast_spec]` macro or CLI tooling. -use serde::{Deserialize, Serialize}; +use serde::Deserialize; use serde_json::Value; use std::fmt; @@ -154,9 +154,11 @@ pub fn load_stream_spec(json: &str) -> Result Result Date: Sat, 21 Mar 2026 16:51:02 +0000 Subject: [PATCH 13/17] fix: Clarify UnsupportedVersion error message to mention migration support Changed error message from 'Current supported versions' to 'Latest supported version' and added note that older versions are supported via automatic migration. This prevents confusion when old versions are supported through migration arms but the error made it seem like only the latest version works. --- hyperstack-macros/src/ast/versioned.rs | 3 ++- interpreter/src/versioned.rs | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/hyperstack-macros/src/ast/versioned.rs b/hyperstack-macros/src/ast/versioned.rs index 81cd5c18..5d6615f6 100644 --- a/hyperstack-macros/src/ast/versioned.rs +++ b/hyperstack-macros/src/ast/versioned.rs @@ -47,7 +47,8 @@ impl fmt::Display for VersionedLoadError { VersionedLoadError::UnsupportedVersion(version) => { write!( f, - "Unsupported AST version: {}. Current supported versions: {}", + "Unsupported AST version: {}. Latest supported version: {}. \ + Older versions are supported via automatic migration.", version, CURRENT_AST_VERSION ) } diff --git a/interpreter/src/versioned.rs b/interpreter/src/versioned.rs index 3b59a43d..df9571fb 100644 --- a/interpreter/src/versioned.rs +++ b/interpreter/src/versioned.rs @@ -40,7 +40,8 @@ impl fmt::Display for VersionedLoadError { VersionedLoadError::UnsupportedVersion(version) => { write!( f, - "Unsupported AST version: {}. Current supported versions: {}", + "Unsupported AST version: {}. Latest supported version: {}. \ + Older versions are supported via automatic migration.", version, CURRENT_AST_VERSION ) } From 32d742736bc0aefc799f52b48badcf465408a000 Mon Sep 17 00:00:00 2001 From: Adrian Henry Date: Sat, 21 Mar 2026 16:58:13 +0000 Subject: [PATCH 14/17] docs: Fix guide to use CURRENT_AST_VERSION constant in match guard Changed examples to use the guard pattern instead of hardcoded version strings. This ensures the current version arm stays correct when the constant is bumped, and teaches the idiomatic pattern used in the production code. --- docs/ast-versioning-guide.md | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/docs/ast-versioning-guide.md b/docs/ast-versioning-guide.md index fbaabf53..7fc53063 100644 --- a/docs/ast-versioning-guide.md +++ b/docs/ast-versioning-guide.md @@ -124,19 +124,19 @@ pub fn load_stream_spec(json: &str) -> Result { + v if v == CURRENT_AST_VERSION => { // Current version - deserialize directly serde_json::from_value::(raw) .map_err(|e| VersionedLoadError::InvalidStructure(e.to_string())) } "1.0.0" => { - // OLD: Load v1 and migrate to v2 + // OLD: Load v1 and migrate to current let v1: SerializableStreamSpecV1 = serde_json::from_value(raw) .map_err(|e| VersionedLoadError::InvalidStructure(e.to_string()))?; Ok(migrate_stream_v1_to_v2(v1)) } "0.0.1" => { - // OLD: Load v0.0.1 and migrate to v2 + // OLD: Load v0.0.1 and migrate to current let v0: SerializableStreamSpecV0 = serde_json::from_value(raw) .map_err(|e| VersionedLoadError::InvalidStructure(e.to_string()))?; Ok(migrate_v0_to_v2(v0)) @@ -327,15 +327,17 @@ pub fn load_stream_spec(json: &str) -> Result { + // Current version - deserialize directly + serde_json::from_value::(raw) + .map_err(|e| VersionedLoadError::InvalidStructure(e.to_string())) + } "0.0.1" => { + // OLD: Load v0.0.1 and migrate to current let v1: SerializableStreamSpecV1 = serde_json::from_value(raw) .map_err(|e| VersionedLoadError::InvalidStructure(e.to_string()))?; Ok(migrate_stream_v1_to_v2(v1)) } - "2.0.0" => { - serde_json::from_value::(raw) - .map_err(|e| VersionedLoadError::InvalidStructure(e.to_string())) - } _ => Err(VersionedLoadError::UnsupportedVersion(version.to_string())), } } From 6809f1a632f464e2c1c6b175458fd51a24712acd Mon Sep 17 00:00:00 2001 From: Adrian Henry Date: Sat, 21 Mar 2026 17:05:27 +0000 Subject: [PATCH 15/17] fix: Use CURRENT_AST_VERSION in test assertions instead of hardcoded string Changed test assertions from hardcoded 0.0.1 to CURRENT_AST_VERSION constant. This ensures tests won't fail when a migration arm is added and specs are upgraded to the current version during loading. --- hyperstack-macros/src/ast/versioned.rs | 8 ++++---- interpreter/src/versioned.rs | 8 ++++---- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/hyperstack-macros/src/ast/versioned.rs b/hyperstack-macros/src/ast/versioned.rs index 5d6615f6..80620a0d 100644 --- a/hyperstack-macros/src/ast/versioned.rs +++ b/hyperstack-macros/src/ast/versioned.rs @@ -256,7 +256,7 @@ mod tests { assert!(result.is_ok()); let spec = result.unwrap(); assert_eq!(spec.stack_name, "TestStack"); - assert_eq!(spec.ast_version, "0.0.1"); + assert_eq!(spec.ast_version, CURRENT_AST_VERSION); } #[test] @@ -277,7 +277,7 @@ mod tests { assert!(result.is_ok()); let spec = result.unwrap(); assert_eq!(spec.stack_name, "TestStack"); - assert_eq!(spec.ast_version, "0.0.1"); + assert_eq!(spec.ast_version, CURRENT_AST_VERSION); } #[test] @@ -325,7 +325,7 @@ mod tests { assert!(result.is_ok()); let spec = result.unwrap(); assert_eq!(spec.state_name, "TestEntity"); - assert_eq!(spec.ast_version, "0.0.1"); + assert_eq!(spec.ast_version, CURRENT_AST_VERSION); } #[test] @@ -351,7 +351,7 @@ mod tests { assert!(result.is_ok()); let spec = result.unwrap(); assert_eq!(spec.state_name, "TestEntity"); - assert_eq!(spec.ast_version, "0.0.1"); + assert_eq!(spec.ast_version, CURRENT_AST_VERSION); } #[test] diff --git a/interpreter/src/versioned.rs b/interpreter/src/versioned.rs index df9571fb..555c9061 100644 --- a/interpreter/src/versioned.rs +++ b/interpreter/src/versioned.rs @@ -235,7 +235,7 @@ mod tests { assert!(result.is_ok()); let spec = result.unwrap(); assert_eq!(spec.stack_name, "TestStack"); - assert_eq!(spec.ast_version, "0.0.1"); + assert_eq!(spec.ast_version, CURRENT_AST_VERSION); } #[test] @@ -256,7 +256,7 @@ mod tests { assert!(result.is_ok()); let spec = result.unwrap(); assert_eq!(spec.stack_name, "TestStack"); - assert_eq!(spec.ast_version, "0.0.1"); + assert_eq!(spec.ast_version, CURRENT_AST_VERSION); } #[test] @@ -304,7 +304,7 @@ mod tests { assert!(result.is_ok()); let spec = result.unwrap(); assert_eq!(spec.state_name, "TestEntity"); - assert_eq!(spec.ast_version, "0.0.1"); + assert_eq!(spec.ast_version, CURRENT_AST_VERSION); } #[test] @@ -330,7 +330,7 @@ mod tests { assert!(result.is_ok()); let spec = result.unwrap(); assert_eq!(spec.state_name, "TestEntity"); - assert_eq!(spec.ast_version, "0.0.1"); + assert_eq!(spec.ast_version, CURRENT_AST_VERSION); } #[test] From da49bf9358362d27c307c8cc958a60b872ae2a2f Mon Sep 17 00:00:00 2001 From: Adrian Henry Date: Sat, 21 Mar 2026 17:22:01 +0000 Subject: [PATCH 16/17] fix: Address code review feedback on into_latest, test assertions, and parsing - Add warning doc comments to all into_latest() methods explaining that the returned spec's ast_version field is not updated to CURRENT_AST_VERSION and that load_*_spec functions should be used for round-trip safety - Fix hardcoded version strings in guide test assertions to use CURRENT_AST_VERSION constant - Make sync test parsing more robust by splitting on '=' first, then extracting the quoted value from the right-hand side only --- docs/ast-versioning-guide.md | 4 ++-- hyperstack-macros/src/ast/versioned.rs | 11 ++++++++++- interpreter/src/versioned.rs | 11 ++++++++++- 3 files changed, 22 insertions(+), 4 deletions(-) diff --git a/docs/ast-versioning-guide.md b/docs/ast-versioning-guide.md index 7fc53063..1d00d24b 100644 --- a/docs/ast-versioning-guide.md +++ b/docs/ast-versioning-guide.md @@ -230,7 +230,7 @@ mod tests { "#; let spec = load_stream_spec(v1_json).unwrap(); - assert_eq!(spec.ast_version, "2.0.0"); + assert_eq!(spec.ast_version, CURRENT_AST_VERSION); assert_eq!(spec.state_name, "TestEntity"); assert_eq!(spec.new_field, "transformed_value"); } @@ -246,7 +246,7 @@ mod tests { "#; let spec = load_stream_spec(v2_json).unwrap(); - assert_eq!(spec.ast_version, "2.0.0"); + assert_eq!(spec.ast_version, CURRENT_AST_VERSION); assert_eq!(spec.new_field, "new_value"); } } diff --git a/hyperstack-macros/src/ast/versioned.rs b/hyperstack-macros/src/ast/versioned.rs index 80620a0d..6dfb3f21 100644 --- a/hyperstack-macros/src/ast/versioned.rs +++ b/hyperstack-macros/src/ast/versioned.rs @@ -168,6 +168,10 @@ pub enum VersionedStackSpec { impl VersionedStackSpec { /// Convert the versioned spec to the latest format. + /// + /// ⚠️ WARNING: This returns the spec with its original `ast_version` field unchanged. + /// If you need round-trip safety (e.g., serialize then deserialize), use `load_stack_spec` + /// instead, which properly sets `ast_version` to `CURRENT_AST_VERSION`. // Not yet used within this crate, but part of public API for future use #[allow(dead_code)] pub fn into_latest(self) -> SerializableStackSpec { @@ -194,6 +198,10 @@ pub enum VersionedStreamSpec { impl VersionedStreamSpec { /// Convert the versioned spec to the latest format. + /// + /// ⚠️ WARNING: This returns the spec with its original `ast_version` field unchanged. + /// If you need round-trip safety (e.g., serialize then deserialize), use `load_stream_spec` + /// instead, which properly sets `ast_version` to `CURRENT_AST_VERSION`. // Not yet used within this crate, but part of public API for future use #[allow(dead_code)] pub fn into_latest(self) -> SerializableStreamSpec { @@ -420,8 +428,9 @@ mod tests { .expect("CURRENT_AST_VERSION not found in interpreter"); let version_str = version_line - .split('"') + .split('=') .nth(1) + .and_then(|rhs| rhs.split('"').nth(1)) .expect("Failed to parse version string"); assert_eq!( diff --git a/interpreter/src/versioned.rs b/interpreter/src/versioned.rs index 555c9061..3e0f0ff6 100644 --- a/interpreter/src/versioned.rs +++ b/interpreter/src/versioned.rs @@ -155,6 +155,10 @@ pub enum VersionedStackSpec { impl VersionedStackSpec { /// Convert the versioned spec to the latest format. + /// + /// ⚠️ WARNING: This returns the spec with its original `ast_version` field unchanged. + /// If you need round-trip safety (e.g., serialize then deserialize), use `load_stack_spec` + /// instead, which properly sets `ast_version` to `CURRENT_AST_VERSION`. pub fn into_latest(self) -> SerializableStackSpec { match self { VersionedStackSpec::V1(spec) => spec, @@ -177,6 +181,10 @@ pub enum VersionedStreamSpec { impl VersionedStreamSpec { /// Convert the versioned spec to the latest format. + /// + /// ⚠️ WARNING: This returns the spec with its original `ast_version` field unchanged. + /// If you need round-trip safety (e.g., serialize then deserialize), use `load_stream_spec` + /// instead, which properly sets `ast_version` to `CURRENT_AST_VERSION`. pub fn into_latest(self) -> SerializableStreamSpec { match self { VersionedStreamSpec::V1(spec) => spec, @@ -400,8 +408,9 @@ mod tests { .expect("CURRENT_AST_VERSION not found in hyperstack-macros"); let version_str = version_line - .split('"') + .split('=') .nth(1) + .and_then(|rhs| rhs.split('"').nth(1)) .expect("Failed to parse version string"); assert_eq!( From 02018697ee4c41728427804fad0936d8a73200cc Mon Sep 17 00:00:00 2001 From: Adrian Henry Date: Sat, 21 Mar 2026 17:32:24 +0000 Subject: [PATCH 17/17] docs: Add warnings about Versioned*Spec limitations and fix detect_ast_version docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add ⚠️ IMPORTANT warnings to VersionedStackSpec and VersionedStreamSpec explaining they require ast_version field and don't handle version-less legacy JSON; recommend using load_*_spec functions instead - Fix detect_ast_version doc to correctly state it returns 0.0.1 as the default, not unknown Fixes in both interpreter and hyperstack-macros crates. --- hyperstack-macros/src/ast/versioned.rs | 12 +++++++++++- interpreter/src/versioned.rs | 12 +++++++++++- 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/hyperstack-macros/src/ast/versioned.rs b/hyperstack-macros/src/ast/versioned.rs index 6dfb3f21..5fee68ed 100644 --- a/hyperstack-macros/src/ast/versioned.rs +++ b/hyperstack-macros/src/ast/versioned.rs @@ -155,6 +155,11 @@ pub fn load_stream_spec(json: &str) -> Result Result