Skip to content

feat: Add AST versioning system with automatic migration support#72

Merged
adiman9 merged 17 commits intomainfrom
feature/ast-versioning
Mar 21, 2026
Merged

feat: Add AST versioning system with automatic migration support#72
adiman9 merged 17 commits intomainfrom
feature/ast-versioning

Conversation

@adiman9
Copy link
Contributor

@adiman9 adiman9 commented Mar 21, 2026

AST Versioning System

Summary

This PR introduces a comprehensive AST versioning system that enables the codebase to evolve while maintaining backward compatibility with older AST formats. This allows us to make breaking changes to the AST structure while providing clear migration paths for existing users.

Motivation

Currently, any change to the AST structure breaks all previously generated AST files. This makes it difficult to:

  • Evolve the AST format as the product matures
  • Support users with existing AST files across different versions
  • Deprecate old features gracefully with clear timelines

Solution

Core Changes

  1. Version Fields: Added ast_version field to both SerializableStreamSpec and SerializableStackSpec

    • Default version: 0.0.1 (semver format)
    • Uses serde(default) for backwards compatibility with existing ASTs
  2. Versioned Loader Module: Created versioned.rs in both crates with:

    • load_stack_spec() - Auto-detects version and migrates to latest
    • load_stream_spec() - Same for entity specs
    • detect_ast_version() - Utility to peek at version without full parse
    • VersionedStackSpec / VersionedStreamSpec enums for explicit handling
  3. Sync Verification: Added tests that verify CURRENT_AST_VERSION constants match between hyperstack-macros and interpreter crates (they must be kept in sync due to circular dependency constraints)

Backwards Compatibility

  • Old ASTs without ast_version field default to 0.0.1
  • All existing code paths continue to work without changes
  • Migration functions automatically upgrade old formats

Usage

// Load with automatic version detection and migration
let spec = load_stack_spec(&json)?;

// Or detect version first for debugging
let version = detect_ast_version(&json)?;
println!("AST version: {}", version);

Future Breaking Changes

When AST changes are needed:

  1. Bump CURRENT_AST_VERSION in both hyperstack-macros/src/ast/types.rs and interpreter/src/ast.rs
  2. Add migration logic in both versioned.rs files
  3. Add new variant to VersionedStackSpec/VersionedStreamSpec enums
  4. Old versions remain supported until explicit deprecation

See the AST Versioning Guide for detailed instructions on adding breaking changes.

Testing

  • Unit tests for version detection
  • Tests for loading v0.0.1 specs
  • Tests for unsupported version errors
  • Tests for backwards compatibility (no version field)
  • Cross-crate version sync verification tests
  • All existing tests continue to pass

Files Changed

New Files

  • hyperstack-macros/src/ast/versioned.rs (322 lines)
  • interpreter/src/versioned.rs (322 lines)
  • docs/ast-versioning-guide.md (413 lines)

Modified Files

  • hyperstack-macros/src/ast/types.rs - Add ast_version field and version constant
  • hyperstack-macros/src/ast/mod.rs - Export versioned module
  • hyperstack-macros/src/stream_spec/ast_writer.rs - Set version when creating specs
  • hyperstack-macros/src/stream_spec/idl_spec.rs - Set version when creating specs
  • hyperstack-macros/src/stream_spec/module.rs - Set version when creating specs
  • interpreter/src/ast.rs - Add ast_version field and version constant
  • interpreter/src/lib.rs - Export versioned module
  • interpreter/src/typescript.rs - Set version in test specs
  • cli/src/commands/sdk.rs - Use versioned loader

Migration Path for Users

No action required for existing users. The system is fully backwards compatible:

  • Old AST files continue to work
  • They are automatically treated as version 0.0.1
  • Future versions will auto-migrate them when loaded

Checklist

  • Added version fields to AST types
  • Created versioned loader with migration support
  • Added comprehensive documentation
  • Added sync verification tests
  • Updated all spec constructors
  • Fixed clippy warnings
  • All tests passing
  • Backwards compatible with existing ASTs

Related

This PR establishes the foundation for future AST evolution. See the versioning guide for how to add breaking changes in subsequent PRs.

adiman9 added 5 commits March 21, 2026 14:51
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)
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.
- 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
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.
- 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
@vercel
Copy link

vercel bot commented Mar 21, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
hyperstack-docs Ready Ready Preview, Comment Mar 21, 2026 5:32pm

Request Review

@greptile-apps
Copy link

greptile-apps bot commented Mar 21, 2026

Greptile Summary

This PR introduces a comprehensive AST versioning system across hyperstack-macros and interpreter crates, adding ast_version fields to SerializableStreamSpec/SerializableStackSpec, a versioned loader module with automatic migration routing, and a developer guide. The core architecture is sound — the load_stack_spec / load_stream_spec functions correctly use CURRENT_AST_VERSION as a guard in the match arm and handle legacy (version-less) JSON by defaulting to "0.0.1".

Key observations:

  • Core loader logic is correct: Both interpreter/src/versioned.rs and hyperstack-macros/src/ast/versioned.rs properly route using v if v == CURRENT_AST_VERSION and will not break when the version is bumped.
  • Enum API breaks backward compat for legacy files: VersionedStackSpec and VersionedStreamSpec use #[serde(tag = "ast_version")] which requires the tag field to be present. Old JSON files without an ast_version field — the primary backward-compat case — will fail deserialization with an opaque serde error when using these enums, unlike the load_* functions which safely default to "0.0.1". Both interpreter/src/versioned.rs and hyperstack-macros/src/ast/versioned.rs are affected.
  • detect_ast_version doc comment mismatch: The doc says it returns "unknown" on missing field, but it actually returns "0.0.1" — can mislead callers who branch on "unknown".
  • Docs guide accuracy: The docs/ast-versioning-guide.md contains several inaccuracies that were flagged in prior review rounds (unreachable match arms, non-compiling ? usage, hardcoded version strings in examples). These should be corrected before the guide is used as a reference by contributors.

Confidence Score: 3/5

  • Safe to merge for the runtime path (CLI uses load_stack_spec correctly), but the public enum API has a backward-compat gap and the developer guide contains misleading examples.
  • The production code path used by the CLI (load_stack_spec / load_stream_spec) is correct and handles all version cases properly. However, the public VersionedStackSpec/VersionedStreamSpec enums fail for the exact use-case the PR promises to support (legacy files without ast_version), and the docs/ast-versioning-guide.md — which future contributors will follow when making breaking changes — contains several code examples that would not compile or would produce incorrect behavior. These issues reduce confidence that the system will be extended correctly in the future.
  • interpreter/src/versioned.rs and hyperstack-macros/src/ast/versioned.rs (enum backward-compat gap); docs/ast-versioning-guide.md (multiple inaccurate code examples from prior review rounds still unresolved).

Important Files Changed

Filename Overview
interpreter/src/versioned.rs New versioned loader module. Core load_* functions are correct (use CURRENT_AST_VERSION guard). However, the VersionedStackSpec/VersionedStreamSpec public enums silently fail for version-less legacy JSON files, and detect_ast_version's doc comment incorrectly states it returns "unknown" when the field is missing.
hyperstack-macros/src/ast/versioned.rs Mirrors the interpreter versioned module. Same enum backward-compat gap exists. The sync test now correctly uses assert! (fixed from the silent-pass issue). Core load_* logic is correct. Items are marked #[allow(dead_code)] as they cannot be exported from a proc-macro crate to external consumers.
interpreter/src/ast.rs Adds CURRENT_AST_VERSION constant and ast_version field (with serde(default)) to SerializableStreamSpec and SerializableStackSpec. Changes are clean and backward-compatible.
cli/src/commands/sdk.rs Switches from raw serde_json::from_str to versioned::load_stack_spec for automatic version detection. Clean change; VersionedLoadError implements std::error::Error so anyhow's with_context works correctly.
docs/ast-versioning-guide.md New developer guide for AST versioning. Contains several issues already flagged in prior review threads: duplicate unreachable match arms, incorrect semver progression examples, ? without map_err in code examples, and hardcoded version strings in migration and test examples.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[JSON input] --> B[load_stack_spec / load_stream_spec]
    B --> C[serde_json::from_str → raw Value]
    C --> D{ast_version field present?}
    D -- No --> E[default: '0.0.1']
    D -- Yes --> F[read version string]
    E --> G{version == CURRENT_AST_VERSION?}
    F --> G
    G -- Yes --> H[Deserialize directly as SerializableStackSpec]
    G -- No --> I{Migration arm exists?}
    I -- Yes --> J[Deserialize as old type → migrate to latest]
    I -- No --> K[Err: UnsupportedVersion]
    H --> L[Ok: SerializableStackSpec]
    J --> L

    subgraph "Enum API (VersionedStackSpec)"
        M[JSON input] --> N[serde_json::from_str]
        N --> O{ast_version field present?}
        O -- No --> P[❌ Serde error: missing tag]
        O -- 0.0.1 --> Q[V1 variant → into_latest]
        O -- Other --> R[❌ Serde error: unknown variant]
        Q --> S[SerializableStackSpec with original ast_version]
    end
Loading

Comments Outside Diff (3)

  1. interpreter/src/versioned.rs, line 1238-1243 (link)

    P2 VersionedStackSpec/VersionedStreamSpec silently breaks backward compat for version-less JSON

    The VersionedStackSpec and VersionedStreamSpec enums use #[serde(tag = "ast_version")] for internally-tagged deserialization. This requires the ast_version field to be present in the JSON. When called against old JSON files that lack this field entirely (the primary backward-compat case the PR promises to support), serde will return an opaque error like "missing field 'ast_version'" rather than gracefully defaulting to "0.0.1".

    This directly contradicts the PR's guarantee that "Old AST files continue to work — they are automatically treated as version 0.0.1". The load_stack_spec / load_stream_spec functions correctly handle missing fields via unwrap_or("0.0.1"), but callers using these public enums directly do not get that safety net. The same applies to VersionedStreamSpec.

    Since both enum types are exported as pub, developers may reach for them as a "strongly-typed" alternative to the load_* functions and be caught off-guard by this failure mode.

    Consider adding explicit doc comments on these types warning that they do NOT handle version-less (legacy) JSON and that load_stack_spec / load_stream_spec should be preferred for all real-world loading.

    Prompt To Fix With AI
    This is a comment left during a code review.
    Path: interpreter/src/versioned.rs
    Line: 1238-1243
    
    Comment:
    **`VersionedStackSpec`/`VersionedStreamSpec` silently breaks backward compat for version-less JSON**
    
    The `VersionedStackSpec` and `VersionedStreamSpec` enums use `#[serde(tag = "ast_version")]` for internally-tagged deserialization. This requires the `ast_version` field to be present in the JSON. When called against old JSON files that lack this field entirely (the primary backward-compat case the PR promises to support), serde will return an opaque error like `"missing field 'ast_version'"` rather than gracefully defaulting to `"0.0.1"`.
    
    This directly contradicts the PR's guarantee that *"Old AST files continue to work — they are automatically treated as version 0.0.1"*. The `load_stack_spec` / `load_stream_spec` functions correctly handle missing fields via `unwrap_or("0.0.1")`, but callers using these public enums directly do not get that safety net. The same applies to `VersionedStreamSpec`.
    
    Since both enum types are exported as `pub`, developers may reach for them as a "strongly-typed" alternative to the `load_*` functions and be caught off-guard by this failure mode.
    
    Consider adding explicit doc comments on these types warning that they do NOT handle version-less (legacy) JSON and that `load_stack_spec` / `load_stream_spec` should be preferred for all real-world loading.
    
    How can I resolve this? If you propose a fix, please make it concise.

    Fix in Claude Code

  2. hyperstack-macros/src/ast/versioned.rs, line 687-693 (link)

    P2 Same backward-compat gap in macros-crate enum API

    The VersionedStackSpec and VersionedStreamSpec enums in this crate have the same issue as in interpreter/src/versioned.rs: #[serde(tag = "ast_version")] requires the tag to be present in the JSON. Version-less legacy files will fail deserialization with an opaque serde error rather than defaulting to "0.0.1". See the corresponding comment in interpreter/src/versioned.rs for full details.

    Prompt To Fix With AI
    This is a comment left during a code review.
    Path: hyperstack-macros/src/ast/versioned.rs
    Line: 687-693
    
    Comment:
    **Same backward-compat gap in macros-crate enum API**
    
    The `VersionedStackSpec` and `VersionedStreamSpec` enums in this crate have the same issue as in `interpreter/src/versioned.rs`: `#[serde(tag = "ast_version")]` requires the tag to be present in the JSON. Version-less legacy files will fail deserialization with an opaque serde error rather than defaulting to `"0.0.1"`. See the corresponding comment in `interpreter/src/versioned.rs` for full details.
    
    How can I resolve this? If you propose a fix, please make it concise.

    Fix in Claude Code

  3. interpreter/src/versioned.rs, line 1294 (link)

    P2 detect_ast_version doc says "unknown" but returns "0.0.1"

    The doc comment states "or 'unknown' if it cannot be determined", but the implementation returns "0.0.1" as the default when ast_version is missing. Any caller that checks for the string "unknown" based on this doc would write logic that silently never triggers.

    Prompt To Fix With AI
    This is a comment left during a code review.
    Path: interpreter/src/versioned.rs
    Line: 1294
    
    Comment:
    **`detect_ast_version` doc says "unknown" but returns `"0.0.1"`**
    
    The doc comment states `"or 'unknown' if it cannot be determined"`, but the implementation returns `"0.0.1"` as the default when `ast_version` is missing. Any caller that checks for the string `"unknown"` based on this doc would write logic that silently never triggers.
    
    
    
    How can I resolve this? If you propose a fix, please make it concise.

    Fix in Claude Code

Fix All in Claude Code

Prompt To Fix All With AI
This is a comment left during a code review.
Path: interpreter/src/versioned.rs
Line: 1238-1243

Comment:
**`VersionedStackSpec`/`VersionedStreamSpec` silently breaks backward compat for version-less JSON**

The `VersionedStackSpec` and `VersionedStreamSpec` enums use `#[serde(tag = "ast_version")]` for internally-tagged deserialization. This requires the `ast_version` field to be present in the JSON. When called against old JSON files that lack this field entirely (the primary backward-compat case the PR promises to support), serde will return an opaque error like `"missing field 'ast_version'"` rather than gracefully defaulting to `"0.0.1"`.

This directly contradicts the PR's guarantee that *"Old AST files continue to work — they are automatically treated as version 0.0.1"*. The `load_stack_spec` / `load_stream_spec` functions correctly handle missing fields via `unwrap_or("0.0.1")`, but callers using these public enums directly do not get that safety net. The same applies to `VersionedStreamSpec`.

Since both enum types are exported as `pub`, developers may reach for them as a "strongly-typed" alternative to the `load_*` functions and be caught off-guard by this failure mode.

Consider adding explicit doc comments on these types warning that they do NOT handle version-less (legacy) JSON and that `load_stack_spec` / `load_stream_spec` should be preferred for all real-world loading.

How can I resolve this? If you propose a fix, please make it concise.

---

This is a comment left during a code review.
Path: hyperstack-macros/src/ast/versioned.rs
Line: 687-693

Comment:
**Same backward-compat gap in macros-crate enum API**

The `VersionedStackSpec` and `VersionedStreamSpec` enums in this crate have the same issue as in `interpreter/src/versioned.rs`: `#[serde(tag = "ast_version")]` requires the tag to be present in the JSON. Version-less legacy files will fail deserialization with an opaque serde error rather than defaulting to `"0.0.1"`. See the corresponding comment in `interpreter/src/versioned.rs` for full details.

How can I resolve this? If you propose a fix, please make it concise.

---

This is a comment left during a code review.
Path: interpreter/src/versioned.rs
Line: 1294

Comment:
**`detect_ast_version` doc says "unknown" but returns `"0.0.1"`**

The doc comment states `"or 'unknown' if it cannot be determined"`, but the implementation returns `"0.0.1"` as the default when `ast_version` is missing. Any caller that checks for the string `"unknown"` based on this doc would write logic that silently never triggers.

```suggestion
/// The detected version string, or `"0.0.1"` if the field is absent (backwards compatibility default).
///
/// # Example
```

How can I resolve this? If you propose a fix, please make it concise.

Last reviewed commit: "fix: Address code re..."

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.
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.
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)
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.
- 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
- 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<serde_json::Error>)
- Fix migration example to use CURRENT_AST_VERSION constant instead of
  hardcoded version string
…licate 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.
…pport

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.
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.
…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.
…d 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
…t_version docs

- 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.
@adiman9 adiman9 merged commit 997706b into main Mar 21, 2026
9 checks passed
@adiman9 adiman9 deleted the feature/ast-versioning branch March 21, 2026 17:57
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant