# Native Sync Implementation Plan

> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.

**Goal:** Generate native sync code (boto3) from async source (aioboto3) using AST transformation, eliminating the event loop wrapper.

**Architecture:** Write async code as source of truth → AST transformer generates sync equivalents → Generated code committed to git → CI verifies sync code is up-to-date.

**Tech Stack:** Python AST, Hatch build hooks, pre-commit, boto3, aioboto3

---

## Task 1: Create AST Transformer Script

**Files:**
- Create: `scripts/generate_sync.py`

**Step 1: Create the transformation script with configuration**

```python
#!/usr/bin/env python3
"""Generate sync versions of async modules using AST transformation.

This script transforms async code (aioboto3) to sync code (boto3) by:
- Removing async/await keywords
- Renaming classes (RateLimiter -> SyncRateLimiter)
- Rewriting imports (aioboto3 -> boto3)

Generated files are committed to git and verified by CI.
"""

import ast
import re
import sys
from pathlib import Path

# Project root
ROOT = Path(__file__).parent.parent
SRC = ROOT / "src" / "zae_limiter"

# Files to transform: (source, target)
SOURCE_TRANSFORMS = [
    ("repository.py", "sync_repository.py"),
    ("limiter.py", "sync_limiter.py"),
    ("lease.py", "sync_lease.py"),
    ("config_cache.py", "sync_config_cache.py"),
    ("infra/stack_manager.py", "infra/sync_stack_manager.py"),
    ("infra/discovery.py", "infra/sync_discovery.py"),
]

# Class renames
CLASS_RENAMES = {
    "RateLimiter": "SyncRateLimiter",
    "Repository": "SyncRepository",
    "Lease": "SyncLease",
    "ConfigCache": "SyncConfigCache",
    "StackManager": "SyncStackManager",
    "InfrastructureDiscovery": "SyncInfrastructureDiscovery",
}

# Import module rewrites
IMPORT_MODULE_REWRITES = {
    "aioboto3": "boto3",
}

# Import path rewrites (for relative imports)
IMPORT_PATH_REWRITES = {
    ".repository": ".sync_repository",
    ".lease": ".sync_lease",
    ".config_cache": ".sync_config_cache",
    ".infra.stack_manager": ".infra.sync_stack_manager",
    ".infra.discovery": ".infra.sync_discovery",
}

# Names to rewrite in from imports
IMPORT_NAME_REWRITES = {
    "Repository": "SyncRepository",
    "Lease": "SyncLease",
    "ConfigCache": "SyncConfigCache",
    "StackManager": "SyncStackManager",
    "InfrastructureDiscovery": "SyncInfrastructureDiscovery",
}

# Type annotation rewrites
TYPE_REWRITES = {
    "AsyncIterator": "Iterator",
    "AsyncContextManager": "ContextManager",
    "AsyncGenerator": "Generator",
}

GENERATED_HEADER = '''"""AUTO-GENERATED by scripts/generate_sync.py - DO NOT EDIT.

Source: {source_file}

This module provides synchronous versions of the async classes.
Changes should be made to the source file, then regenerated.
"""

'''


class AsyncToSyncTransformer(ast.NodeTransformer):
    """Transform async Python code to sync."""

    def __init__(self, source_file: str):
        self.source_file = source_file
        super().__init__()

    def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef) -> ast.FunctionDef:
        """Convert async def to def."""
        # Transform the body first
        new_body = [self.visit(stmt) for stmt in node.body]

        # Handle __aenter__ -> __enter__, __aexit__ -> __exit__
        new_name = node.name
        if new_name == "__aenter__":
            new_name = "__enter__"
        elif new_name == "__aexit__":
            new_name = "__exit__"

        # Create sync function
        new_node = ast.FunctionDef(
            name=new_name,
            args=node.args,
            body=new_body,
            decorator_list=[self.visit(d) for d in node.decorator_list],
            returns=self._transform_annotation(node.returns) if node.returns else None,
            type_comment=node.type_comment,
        )
        return ast.copy_location(new_node, node)

    def visit_Await(self, node: ast.Await) -> ast.AST:
        """Remove await, keep the expression."""
        return self.visit(node.value)

    def visit_AsyncWith(self, node: ast.AsyncWith) -> ast.With:
        """Convert async with to with."""
        new_node = ast.With(
            items=[self.visit(item) for item in node.items],
            body=[self.visit(stmt) for stmt in node.body],
        )
        return ast.copy_location(new_node, node)

    def visit_AsyncFor(self, node: ast.AsyncFor) -> ast.For:
        """Convert async for to for."""
        new_node = ast.For(
            target=self.visit(node.target),
            iter=self.visit(node.iter),
            body=[self.visit(stmt) for stmt in node.body],
            orelse=[self.visit(stmt) for stmt in node.orelse],
        )
        return ast.copy_location(new_node, node)

    def visit_ClassDef(self, node: ast.ClassDef) -> ast.ClassDef:
        """Rename classes according to CLASS_RENAMES."""
        if node.name in CLASS_RENAMES:
            node.name = CLASS_RENAMES[node.name]

        # Transform base classes (handle string annotations in bases)
        new_bases = []
        for base in node.bases:
            new_bases.append(self._transform_base_class(base))
        node.bases = new_bases

        # Continue visiting children
        self.generic_visit(node)
        return node

    def _transform_base_class(self, node: ast.AST) -> ast.AST:
        """Transform base class references."""
        if isinstance(node, ast.Name) and node.id in CLASS_RENAMES:
            node.id = CLASS_RENAMES[node.id]
        elif isinstance(node, ast.Subscript):
            # Handle Generic[T] style bases
            node.value = self._transform_base_class(node.value)
        return node

    def visit_Import(self, node: ast.Import) -> ast.Import:
        """Rewrite import statements."""
        for alias in node.names:
            if alias.name in IMPORT_MODULE_REWRITES:
                alias.name = IMPORT_MODULE_REWRITES[alias.name]
        return node

    def visit_ImportFrom(self, node: ast.ImportFrom) -> ast.ImportFrom:
        """Rewrite from ... import statements."""
        # Rewrite module path
        if node.module:
            # Check for exact module rewrites
            if node.module in IMPORT_MODULE_REWRITES:
                node.module = IMPORT_MODULE_REWRITES[node.module]

            # Check for relative import path rewrites
            module_key = f".{node.module}" if node.level == 0 else "." * node.level + (node.module or "")
            for old_path, new_path in IMPORT_PATH_REWRITES.items():
                if module_key.endswith(old_path.lstrip(".")):
                    # Replace the suffix
                    prefix = module_key[:-len(old_path.lstrip("."))]
                    new_module = prefix + new_path.lstrip(".")
                    node.module = new_module.lstrip(".")
                    break

        # Rewrite imported names
        for alias in node.names:
            if alias.name in IMPORT_NAME_REWRITES:
                old_name = alias.name
                alias.name = IMPORT_NAME_REWRITES[alias.name]
                # Also update asname if it was the same as name
                if alias.asname == old_name:
                    alias.asname = alias.name
            # Rewrite type names
            if alias.name in TYPE_REWRITES:
                alias.name = TYPE_REWRITES[alias.name]

        return node

    def visit_Name(self, node: ast.Name) -> ast.Name:
        """Rewrite class name references."""
        if node.id in CLASS_RENAMES:
            node.id = CLASS_RENAMES[node.id]
        if node.id in TYPE_REWRITES:
            node.id = TYPE_REWRITES[node.id]
        return node

    def visit_Attribute(self, node: ast.Attribute) -> ast.Attribute:
        """Rewrite attribute access for renamed modules."""
        self.generic_visit(node)
        return node

    def visit_Constant(self, node: ast.Constant) -> ast.Constant:
        """Rewrite string annotations."""
        if isinstance(node.value, str):
            value = node.value
            # Rewrite class names in string annotations
            for old_name, new_name in CLASS_RENAMES.items():
                value = re.sub(rf'\b{old_name}\b', new_name, value)
            for old_name, new_name in TYPE_REWRITES.items():
                value = re.sub(rf'\b{old_name}\b', new_name, value)
            node.value = value
        return node

    def _transform_annotation(self, node: ast.AST | None) -> ast.AST | None:
        """Transform type annotations."""
        if node is None:
            return None
        return self.visit(node)

    def visit_FunctionDef(self, node: ast.FunctionDef) -> ast.FunctionDef:
        """Transform sync function definitions (for annotations)."""
        # Transform return annotation
        if node.returns:
            node.returns = self._transform_annotation(node.returns)

        # Transform argument annotations
        for arg in node.args.args:
            if arg.annotation:
                arg.annotation = self._transform_annotation(arg.annotation)

        self.generic_visit(node)
        return node

    def visit_AnnAssign(self, node: ast.AnnAssign) -> ast.AnnAssign:
        """Transform annotated assignments."""
        node.annotation = self._transform_annotation(node.annotation)
        self.generic_visit(node)
        return node


def transform_file(source_path: Path, target_path: Path) -> bool:
    """Transform a single file from async to sync.

    Returns True if file was changed, False otherwise.
    """
    source_code = source_path.read_text()

    # Parse AST
    tree = ast.parse(source_code)

    # Transform
    transformer = AsyncToSyncTransformer(source_path.name)
    new_tree = transformer.visit(tree)

    # Fix missing locations
    ast.fix_missing_locations(new_tree)

    # Generate code
    new_code = ast.unparse(new_tree)

    # Add header
    relative_source = source_path.relative_to(SRC)
    header = GENERATED_HEADER.format(source_file=relative_source)
    final_code = header + new_code + "\n"

    # Check if changed
    if target_path.exists():
        existing = target_path.read_text()
        if existing == final_code:
            return False

    # Write
    target_path.parent.mkdir(parents=True, exist_ok=True)
    target_path.write_text(final_code)
    return True


def main() -> int:
    """Run all transformations."""
    changed = []

    for source_name, target_name in SOURCE_TRANSFORMS:
        source_path = SRC / source_name
        target_path = SRC / target_name

        if not source_path.exists():
            print(f"WARNING: Source file not found: {source_path}")
            continue

        if transform_file(source_path, target_path):
            changed.append(target_name)
            print(f"Generated: {target_name}")
        else:
            print(f"Unchanged: {target_name}")

    if changed:
        print(f"\n{len(changed)} file(s) updated")
    else:
        print("\nAll files up to date")

    return 0


if __name__ == "__main__":
    sys.exit(main())
```

**Step 2: Make script executable**

Run: `chmod +x scripts/generate_sync.py`

**Step 3: Test the script runs without error**

Run: `python scripts/generate_sync.py`
Expected: Script runs (may show warnings about missing source patterns initially)

**Step 4: Commit**

```bash
git add scripts/generate_sync.py
git commit -m "$(cat <<'EOF'
🔨 build(scripts): add AST transformer for sync code generation

Add generate_sync.py that transforms async code to sync:
- Converts async def -> def, await -> (removed)
- Renames classes (RateLimiter -> SyncRateLimiter)
- Rewrites imports (aioboto3 -> boto3)
- Adds generated file header

Part of native sync implementation (#refactor/native-sync).

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
EOF
)"
```

---

## Task 2: Add Pre-commit Hook for Sync Verification

**Files:**
- Modify: `.pre-commit-config.yaml`

**Step 1: Add the sync verification hook**

Add after the `check-aws-partition` hook in `.pre-commit-config.yaml`:

```yaml
      - id: verify-sync-generated
        name: Verify generated sync code is up-to-date
        entry: bash -c 'python scripts/generate_sync.py && git diff --exit-code src/zae_limiter/sync_*.py src/zae_limiter/infra/sync_*.py 2>/dev/null || (echo "Generated sync code is out of date. Run: python scripts/generate_sync.py" && exit 1)'
        language: system
        files: ^src/zae_limiter/(repository|limiter|lease|config_cache|infra/(stack_manager|discovery))\.py$
        pass_filenames: false
        stages: [pre-commit]
```

**Step 2: Test hook configuration is valid**

Run: `pre-commit run --all-files`
Expected: All hooks pass (sync files don't exist yet, so verify hook may skip)

**Step 3: Commit**

```bash
git add .pre-commit-config.yaml
git commit -m "$(cat <<'EOF'
👷 ci(pre-commit): add hook to verify generated sync code

Pre-commit hook runs generate_sync.py and checks for uncommitted
changes to sync_*.py files. Ensures generated code stays in sync
with async source.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
EOF
)"
```

---

## Task 3: Add Hatch Build Hook

**Files:**
- Create: `hatch_build.py`
- Modify: `pyproject.toml`

**Step 1: Create hatch build hook**

```python
"""Hatch build hook to generate sync code before building."""

import subprocess
import sys
from pathlib import Path

from hatchling.builders.hooks.plugin.interface import BuildHookInterface


class SyncGeneratorHook(BuildHookInterface):
    """Generate sync code before building wheel/sdist."""

    PLUGIN_NAME = "sync-generator"

    def initialize(self, version: str, build_data: dict) -> None:
        """Run sync code generation."""
        root = Path(self.root)
        script = root / "scripts" / "generate_sync.py"

        if script.exists():
            result = subprocess.run(
                [sys.executable, str(script)],
                cwd=root,
                capture_output=True,
                text=True,
            )
            if result.returncode != 0:
                raise RuntimeError(f"Sync generation failed:\n{result.stderr}")
```

**Step 2: Update pyproject.toml with build hook and scripts**

Add after `[tool.hatch.build.targets.wheel]`:

```toml
[tool.hatch.build.hooks.custom]
path = "hatch_build.py"

[tool.hatch.envs.default.scripts]
generate-sync = "python scripts/generate_sync.py"
```

**Step 3: Test hatch recognizes the hook**

Run: `hatch run generate-sync`
Expected: Script runs, shows "Source file not found" warnings (expected at this stage)

**Step 4: Commit**

```bash
git add hatch_build.py pyproject.toml
git commit -m "$(cat <<'EOF'
🔨 build(hatch): add build hook for sync code generation

- hatch_build.py: Runs generate_sync.py before wheel/sdist build
- pyproject.toml: Add custom build hook and generate-sync script

Developer workflow: `hatch run generate-sync` after changing async code.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
EOF
)"
```

---

## Task 4: Remove Existing SyncRateLimiter and SyncLease Wrappers

**Files:**
- Modify: `src/zae_limiter/limiter.py`
- Modify: `src/zae_limiter/lease.py`
- Modify: `src/zae_limiter/__init__.py`

**Step 1: Read current limiter.py to identify SyncRateLimiter location**

The `SyncRateLimiter` class starts around line 1668. Remove the entire class (lines 1668-2239).

**Step 2: Remove SyncRateLimiter class from limiter.py**

Delete the `SyncRateLimiter` class entirely (from `class SyncRateLimiter:` to end of file).

**Step 3: Remove SyncLease class from lease.py**

Delete the `SyncLease` class (lines 346-368 approximately).

**Step 4: Temporarily comment out sync imports in __init__.py**

Comment out or remove:
```python
# from .limiter import SyncRateLimiter  # Will be from sync_limiter
# from .lease import SyncLease  # Will be from sync_lease
```

And in `__all__`:
```python
# "SyncRateLimiter",  # Will be re-added
# "SyncLease",  # Will be re-added
```

**Step 5: Run tests to verify async code still works**

Run: `uv run pytest tests/unit/test_limiter.py -v -k "not Sync"`
Expected: Async tests pass

**Step 6: Commit**

```bash
git add src/zae_limiter/limiter.py src/zae_limiter/lease.py src/zae_limiter/__init__.py
git commit -m "$(cat <<'EOF'
🔥 remove(limiter): delete SyncRateLimiter and SyncLease wrappers

Remove the event loop wrapper implementations. These will be replaced
by native sync code generated from async source via AST transformation.

BREAKING: SyncRateLimiter and SyncLease temporarily unavailable.
They will be restored in the next commit as generated code.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
EOF
)"
```

---

## Task 5: Generate Initial Sync Code

**Files:**
- Generate: `src/zae_limiter/sync_repository.py`
- Generate: `src/zae_limiter/sync_limiter.py`
- Generate: `src/zae_limiter/sync_lease.py`
- Generate: `src/zae_limiter/sync_config_cache.py`
- Generate: `src/zae_limiter/infra/sync_stack_manager.py`
- Generate: `src/zae_limiter/infra/sync_discovery.py`

**Step 1: Run the generator**

Run: `python scripts/generate_sync.py`
Expected: All 6 files generated

**Step 2: Verify generated files are valid Python**

Run: `python -m py_compile src/zae_limiter/sync_repository.py src/zae_limiter/sync_limiter.py src/zae_limiter/sync_lease.py`
Expected: No syntax errors

**Step 3: Run ruff to fix any style issues**

Run: `uv run ruff check --fix src/zae_limiter/sync_*.py src/zae_limiter/infra/sync_*.py`
Run: `uv run ruff format src/zae_limiter/sync_*.py src/zae_limiter/infra/sync_*.py`

**Step 4: Commit generated files**

```bash
git add src/zae_limiter/sync_*.py src/zae_limiter/infra/sync_*.py
git commit -m "$(cat <<'EOF'
✨ feat(sync): generate native sync code from async source

Generated via scripts/generate_sync.py:
- sync_repository.py: SyncRepository with native boto3
- sync_limiter.py: SyncRateLimiter without event loop wrapper
- sync_lease.py: SyncLease with sync methods
- sync_config_cache.py: SyncConfigCache
- infra/sync_stack_manager.py: SyncStackManager
- infra/sync_discovery.py: SyncInfrastructureDiscovery

These files are auto-generated. Edit the async source, not these files.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
EOF
)"
```

---

## Task 6: Update Package Exports

**Files:**
- Modify: `src/zae_limiter/__init__.py`

**Step 1: Add sync imports from generated modules**

Add the sync imports:

```python
# Sync (generated from async via scripts/generate_sync.py)
from .sync_limiter import SyncRateLimiter
from .sync_repository import SyncRepository
from .sync_lease import SyncLease
from .sync_config_cache import SyncConfigCache
```

**Step 2: Update __all__ to include sync classes**

Ensure `__all__` includes:
```python
"SyncRateLimiter",
"SyncRepository",
"SyncLease",
"SyncConfigCache",
```

**Step 3: Verify imports work**

Run: `python -c "from zae_limiter import SyncRateLimiter, SyncRepository, SyncLease; print('OK')"`
Expected: "OK"

**Step 4: Commit**

```bash
git add src/zae_limiter/__init__.py
git commit -m "$(cat <<'EOF'
✨ feat(exports): export sync classes from generated modules

- SyncRateLimiter from sync_limiter.py
- SyncRepository from sync_repository.py
- SyncLease from sync_lease.py
- SyncConfigCache from sync_config_cache.py

Public API unchanged: `from zae_limiter import SyncRateLimiter`

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
EOF
)"
```

---

## Task 7: Fix Generated Code Issues

The AST transformer may produce code that needs manual fixes. This task handles common issues.

**Files:**
- Modify: `scripts/generate_sync.py` (if transformer needs improvement)
- Modify: Generated files (if manual fixes needed)

**Step 1: Run type checker on generated code**

Run: `uv run mypy src/zae_limiter/sync_repository.py src/zae_limiter/sync_limiter.py --ignore-missing-imports`
Expected: Note any type errors

**Step 2: Fix transformer if systematic issues found**

Common issues and fixes in `generate_sync.py`:
- Missing import rewrites → add to `IMPORT_PATH_REWRITES`
- Incorrect class references → add to `CLASS_RENAMES`
- asyncio imports not removed → add removal logic

**Step 3: Regenerate and verify**

Run: `python scripts/generate_sync.py`
Run: `uv run mypy src/zae_limiter/sync_*.py --ignore-missing-imports`

**Step 4: Commit any transformer fixes**

```bash
git add scripts/generate_sync.py src/zae_limiter/sync_*.py
git commit -m "$(cat <<'EOF'
🐛 fix(generate-sync): fix transformer issues

[Describe specific fixes made]

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
EOF
)"
```

---

## Task 8: Update Unit Test Fixtures for Native Sync

**Files:**
- Modify: `tests/unit/conftest.py`

**Step 1: Update sync_limiter fixture to use native SyncRepository**

Replace the existing `sync_limiter` fixture:

```python
@pytest.fixture
def sync_limiter(mock_dynamodb):
    """Create a SyncRateLimiter with mocked DynamoDB and native sync."""
    from zae_limiter import SyncRateLimiter
    from zae_limiter.sync_repository import SyncRepository

    # Create native sync repository
    repo = SyncRepository(
        name="test-rate-limits",
        region="us-east-1",
    )
    # Create table directly
    repo.create_table()

    # Create limiter with native sync repository
    limiter = SyncRateLimiter(repository=repo)

    with limiter:
        yield limiter
```

**Step 2: Add sync_repository fixture**

```python
@pytest.fixture
def sync_repository(mock_dynamodb):
    """Create a SyncRepository with mocked DynamoDB."""
    from zae_limiter.sync_repository import SyncRepository

    repo = SyncRepository(
        name="test-rate-limits",
        region="us-east-1",
    )
    repo.create_table()
    yield repo
    repo.close()
```

**Step 3: Run sync tests to verify fixture works**

Run: `uv run pytest tests/unit/test_sync_limiter.py -v -k "test_create_entity"`
Expected: Test passes with native sync

**Step 4: Commit**

```bash
git add tests/unit/conftest.py
git commit -m "$(cat <<'EOF'
✅ test(fixtures): update sync fixtures for native implementation

- sync_limiter: Uses SyncRepository directly (no event loop)
- sync_repository: New fixture for repository-level tests

Fixtures no longer use async wrapper pattern.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
EOF
)"
```

---

## Task 9: Fix Sync Tests for Native Implementation

**Files:**
- Modify: `tests/unit/test_sync_limiter.py`

**Step 1: Remove references to internal async implementation**

Find and fix tests that reference `sync_limiter._limiter` or `sync_limiter._run`:
- These internal attributes no longer exist in native sync
- Replace with direct sync calls or remove tests that were testing wrapper behavior

**Step 2: Update monkeypatch targets**

Tests using `monkeypatch.setattr(sync_limiter._limiter._repository, ...)` need updating:
- Change to `monkeypatch.setattr(sync_limiter._repository, ...)`
- Mock sync methods instead of async methods

**Step 3: Run all sync tests**

Run: `uv run pytest tests/unit/test_sync_limiter.py -v`
Expected: All tests pass

**Step 4: Commit**

```bash
git add tests/unit/test_sync_limiter.py
git commit -m "$(cat <<'EOF'
✅ test(sync): update tests for native sync implementation

- Remove references to _limiter wrapper attribute
- Update monkeypatch targets for sync repository
- Fix mock functions to be sync instead of async

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
EOF
)"
```

---

## Task 10: Add Test Transformation to Generator

**Files:**
- Modify: `scripts/generate_sync.py`

**Step 1: Add test file transformations**

Add to `SOURCE_TRANSFORMS` in `generate_sync.py`:

```python
# Test files to transform
TEST_ROOT = ROOT / "tests"

TEST_TRANSFORMS = [
    ("unit/test_limiter.py", "unit/test_sync_limiter.py"),
    ("unit/test_repository.py", "unit/test_sync_repository.py"),
    ("unit/test_lease.py", "unit/test_sync_lease.py"),
    ("integration/test_repository.py", "integration/test_sync_repository.py"),
    ("e2e/test_localstack.py", "e2e/test_sync_localstack.py"),
]
```

**Step 2: Add pytest marker removal**

Add to transformer class:

```python
def visit_Decorator(self, node: ast.AST) -> ast.AST | None:
    """Remove @pytest.mark.asyncio decorators."""
    if isinstance(node, ast.Attribute):
        if (isinstance(node.value, ast.Attribute) and
            isinstance(node.value.value, ast.Name) and
            node.value.value.id == "pytest" and
            node.value.attr == "mark" and
            node.attr == "asyncio"):
            return None  # Remove decorator
    return node
```

**Step 3: Add fixture name rewrites**

```python
FIXTURE_RENAMES = {
    "limiter": "sync_limiter",
    "repository": "sync_repository",
}
```

**Step 4: Test generator with test files**

Run: `python scripts/generate_sync.py`
Expected: Test files generated

**Step 5: Commit**

```bash
git add scripts/generate_sync.py
git commit -m "$(cat <<'EOF'
✨ feat(generate-sync): add test file transformation

- Transform async tests to sync tests
- Remove @pytest.mark.asyncio decorators
- Rename fixture references (limiter -> sync_limiter)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
EOF
)"
```

---

## Task 11: Add Gevent/Eventlet Compatibility Tests

**Files:**
- Create: `tests/unit/test_sync_compatibility.py`

**Step 1: Create compatibility test file**

```python
"""Tests for sync implementation compatibility with Gevent/Eventlet.

These tests verify that the native sync implementation works correctly
in environments that monkey-patch the standard library for cooperative
concurrency (Gevent, Eventlet).
"""

import pytest


class TestGeventCompatibility:
    """Tests for Gevent compatibility."""

    @pytest.fixture
    def gevent_patched(self):
        """Apply Gevent monkey-patching."""
        pytest.importorskip("gevent")
        from gevent import monkey

        # Patch all (socket, ssl, etc.)
        monkey.patch_all()
        yield

    def test_sync_limiter_with_gevent(self, gevent_patched, mock_dynamodb):
        """SyncRateLimiter works with Gevent monkey-patching."""
        from zae_limiter import Limit, SyncRateLimiter
        from zae_limiter.sync_repository import SyncRepository

        repo = SyncRepository(name="gevent-test", region="us-east-1")
        repo.create_table()

        limiter = SyncRateLimiter(repository=repo)

        with limiter.acquire(
            entity_id="test",
            resource="api",
            limits=[Limit.per_minute("rpm", 100)],
            consume={"rpm": 1},
        ) as lease:
            assert lease.consumed == {"rpm": 1}

        limiter.close()

    def test_concurrent_greenlets(self, gevent_patched, mock_dynamodb):
        """Multiple greenlets can use SyncRateLimiter concurrently."""
        pytest.importorskip("gevent")
        import gevent

        from zae_limiter import Limit, SyncRateLimiter
        from zae_limiter.sync_repository import SyncRepository

        repo = SyncRepository(name="gevent-concurrent", region="us-east-1")
        repo.create_table()
        limiter = SyncRateLimiter(repository=repo)

        results = []

        def acquire_limit(entity_id: str):
            with limiter.acquire(
                entity_id=entity_id,
                resource="api",
                limits=[Limit.per_minute("rpm", 100)],
                consume={"rpm": 1},
            ):
                results.append(entity_id)

        # Spawn multiple greenlets
        greenlets = [
            gevent.spawn(acquire_limit, f"entity-{i}")
            for i in range(5)
        ]
        gevent.joinall(greenlets)

        assert len(results) == 5
        limiter.close()


class TestEventletCompatibility:
    """Tests for Eventlet compatibility."""

    @pytest.fixture
    def eventlet_patched(self):
        """Apply Eventlet monkey-patching."""
        pytest.importorskip("eventlet")
        import eventlet

        eventlet.monkey_patch()
        yield

    def test_sync_limiter_with_eventlet(self, eventlet_patched, mock_dynamodb):
        """SyncRateLimiter works with Eventlet monkey-patching."""
        from zae_limiter import Limit, SyncRateLimiter
        from zae_limiter.sync_repository import SyncRepository

        repo = SyncRepository(name="eventlet-test", region="us-east-1")
        repo.create_table()

        limiter = SyncRateLimiter(repository=repo)

        with limiter.acquire(
            entity_id="test",
            resource="api",
            limits=[Limit.per_minute("rpm", 100)],
            consume={"rpm": 1},
        ) as lease:
            assert lease.consumed == {"rpm": 1}

        limiter.close()
```

**Step 2: Add optional test dependencies**

Add to `pyproject.toml` under `[project.optional-dependencies]`:

```toml
compat = [
    "gevent>=24.0.0",
    "eventlet>=0.36.0",
]
```

**Step 3: Run compatibility tests (if dependencies available)**

Run: `uv run pytest tests/unit/test_sync_compatibility.py -v`
Expected: Tests skip if gevent/eventlet not installed, pass if available

**Step 4: Commit**

```bash
git add tests/unit/test_sync_compatibility.py pyproject.toml
git commit -m "$(cat <<'EOF'
✅ test(compat): add Gevent/Eventlet compatibility tests

Verify native sync implementation works with:
- Gevent monkey-patching
- Eventlet monkey-patching
- Concurrent greenlets

Tests skip if gevent/eventlet not installed.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
EOF
)"
```

---

## Task 12: Update CI Workflow

**Files:**
- Modify: `.github/workflows/ci.yml` (or equivalent)

**Step 1: Add sync verification job**

Add a job that verifies generated code is up-to-date:

```yaml
  verify-sync:
    name: Verify Generated Sync Code
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: astral-sh/setup-uv@v4
      - name: Generate sync code
        run: python scripts/generate_sync.py
      - name: Check for uncommitted changes
        run: |
          git diff --exit-code src/zae_limiter/sync_*.py src/zae_limiter/infra/sync_*.py || \
          (echo "::error::Generated sync code is out of date. Run: python scripts/generate_sync.py" && exit 1)
```

**Step 2: Add compatibility test job (optional)**

```yaml
  test-compat:
    name: Compatibility Tests
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: astral-sh/setup-uv@v4
      - name: Install with compat extras
        run: uv sync --extra dev --extra compat
      - name: Run compatibility tests
        run: uv run pytest tests/unit/test_sync_compatibility.py -v
```

**Step 3: Commit**

```bash
git add .github/workflows/ci.yml
git commit -m "$(cat <<'EOF'
👷 ci: add sync code verification and compatibility tests

- verify-sync: Ensures generated code matches async source
- test-compat: Runs Gevent/Eventlet compatibility tests

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
EOF
)"
```

---

## Task 13: Create ADR for Unasync Architecture

**Files:**
- Create: `docs/adr/120-native-sync-unasync.md`

**Step 1: Create ADR**

```markdown
# ADR-120: Native Sync Implementation via Unasync Pattern

## Status

Accepted

## Context

The `SyncRateLimiter` class wrapped the async `RateLimiter` using
`asyncio.run_until_complete()`. This approach failed in environments
with their own async runtime (Gevent, Eventlet) because:

- Gevent/Eventlet use greenlet-based cooperative concurrency
- asyncio's event loop conflicts with monkey-patched I/O
- The wrapper blocked entire greenlet pools or failed outright

## Decision

Adopt the **unasync pattern** used by httpx, httpcore, and urllib3:

1. Async code (aioboto3) is the **source of truth**
2. AST transformation generates sync code (boto3) at build time
3. Generated code is **committed to git** for documentation and debugging
4. Pre-commit and CI verify generated code stays in sync

### Transformation Rules

| Async | Sync |
|-------|------|
| `async def` | `def` |
| `await expr` | `expr` |
| `async with` | `with` |
| `aioboto3` | `boto3` |
| `RateLimiter` | `SyncRateLimiter` |

### File Mapping

| Source | Generated |
|--------|-----------|
| repository.py | sync_repository.py |
| limiter.py | sync_limiter.py |
| lease.py | sync_lease.py |

## Consequences

### Positive

- **Single source of truth**: Maintain async code only
- **Native performance**: No event loop overhead
- **Gevent/Eventlet compatible**: boto3 blocking I/O becomes cooperative
- **Debuggable**: Generated code is committed, has line numbers

### Negative

- **Build complexity**: Requires AST transformer and verification hooks
- **PR noise**: Generated code changes appear in diffs
- **Two test suites**: Both async and sync tests must pass

## References

- Design doc: `docs/plans/2026-02-02-native-sync-unasync-design.md`
- httpx unasync: https://github.com/encode/httpx/tree/master/scripts
```

**Step 2: Commit**

```bash
git add docs/adr/120-native-sync-unasync.md
git commit -m "$(cat <<'EOF'
📝 docs(adr): ADR-120 native sync via unasync pattern

Document architectural decision to generate native sync code from
async source using AST transformation, replacing event loop wrapper.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
EOF
)"
```

---

## Task 14: Update CLAUDE.md

**Files:**
- Modify: `CLAUDE.md`

**Step 1: Add sync generation section**

Add under "Build & Development":

```markdown
### Sync Code Generation

Native sync code is generated from async source via AST transformation:

```bash
# Generate sync code after modifying async source
hatch run generate-sync

# Or directly
python scripts/generate_sync.py
```

**Generated files (DO NOT EDIT):**
- `sync_repository.py` ← `repository.py`
- `sync_limiter.py` ← `limiter.py`
- `sync_lease.py` ← `lease.py`
- `sync_config_cache.py` ← `config_cache.py`
- `infra/sync_stack_manager.py` ← `infra/stack_manager.py`
- `infra/sync_discovery.py` ← `infra/discovery.py`

Pre-commit hook verifies generated code is up-to-date.
```

**Step 2: Update Project Structure**

Add the new files to the structure section.

**Step 3: Commit**

```bash
git add CLAUDE.md
git commit -m "$(cat <<'EOF'
📝 docs(claude): document sync code generation workflow

Add section explaining:
- How to regenerate sync code
- Which files are generated (don't edit)
- Pre-commit verification

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
EOF
)"
```

---

## Task 15: Run Full Test Suite

**Step 1: Run all unit tests**

Run: `uv run pytest tests/unit/ -v`
Expected: All tests pass

**Step 2: Run integration tests (with LocalStack)**

Run: `zae-limiter local up && uv run pytest tests/integration/ -v`
Expected: All tests pass

**Step 3: Run sync compatibility tests**

Run: `uv run pytest tests/unit/test_sync_compatibility.py -v`
Expected: Tests pass or skip (depending on gevent/eventlet availability)

**Step 4: Verify pre-commit passes**

Run: `pre-commit run --all-files`
Expected: All hooks pass

**Step 5: Final commit if any fixes needed**

```bash
git add -A
git commit -m "$(cat <<'EOF'
🐛 fix: address test failures from native sync migration

[Describe fixes]

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
EOF
)"
```

---

## Summary

After completing all tasks:

1. ✅ AST transformer generates native sync code from async source
2. ✅ Pre-commit hook verifies generated code is up-to-date
3. ✅ Hatch build hook runs generation before packaging
4. ✅ SyncRateLimiter is now native boto3 (no event loop wrapper)
5. ✅ Tests pass for both async and sync implementations
6. ✅ Gevent/Eventlet compatibility verified
7. ✅ CI verifies generated code matches source
8. ✅ ADR documents the architecture decision
9. ✅ CLAUDE.md updated with generation workflow
