ADR 002: AST-Based Injection for Feature Installation
ADR 002: AST-Based Injection for Feature Installation
Status: β Accepted Date: 2026-04-01 Authors: Seed & Source Team Decision Makers: CLI Team, Technical Architects
Context
The Problem
The core value proposition of Seed & Source is programmatic feature injection. Users run sscli inject commerce and the CLI automatically adds Commerce endpoints, entities, and routes to their existing codebase.
Requirements:
- Inject code without breaking existing syntax (no manual merging)
- Preserve code style (indentation, formatting, comments)
- Idempotent: Running injection twice shouldnβt break anything
- Robust: Handle different coding styles (spaces vs tabs, etc.)
- Type-safe: Maintain Python type hints after injection
Example Injection:
# Before injection:from fastapi import APIRouter
router = APIRouter()
@router.get("/health")def health_check(): return {"status": "ok"}
# After `sscli inject commerce`:from fastapi import APIRouterfrom features.commerce.src.routes import router as commerce_router # β Injected
router = APIRouter()
@router.get("/health")def health_check(): return {"status": "ok"}
router.include_router(commerce_router, prefix="/commerce") # β InjectedDecision
We use AST (Abstract Syntax Tree) manipulation via Pythonβs ast module to inject features.
How It Works
ββββββββββββββββββββ Original Code ββ (routes.py) βββββββββββ¬βββββββββ β βΌ ast.parse() β βΌββββββββββββββββββββ Syntax Tree β β In-memory representationβ (AST nodes) βββββββββββ¬βββββββββ β βΌ Transform AST - Add import nodes - Add function calls - Preserve structure β βΌββββββββββββββββββββ Modified AST βββββββββββ¬βββββββββ β βΌ ast.unparse() β βΌββββββββββββββββββββ Generated Code ββ (routes.py) ββββββββββββββββββββImplementation Overview
import ast
def inject_commerce_routes(filepath: str): # 1. Parse existing code with open(filepath) as f: tree = ast.parse(f.read())
# 2. Create new import node new_import = ast.ImportFrom( module='features.commerce.src.routes', names=[ast.alias(name='router', asname='commerce_router')], level=0 )
# 3. Insert import at top (after existing imports) import_index = find_last_import_index(tree.body) tree.body.insert(import_index + 1, new_import)
# 4. Create router registration call registration = ast.Expr( value=ast.Call( func=ast.Attribute( value=ast.Name(id='router', ctx=ast.Load()), attr='include_router', ctx=ast.Load() ), args=[ ast.Name(id='commerce_router', ctx=ast.Load()), ], keywords=[ ast.keyword(arg='prefix', value=ast.Constant(value='/commerce')) ] ) )
# 5. Append registration to end of file tree.body.append(registration)
# 6. Generate Python code from AST new_code = ast.unparse(tree)
# 7. Write back to file with open(filepath, 'w') as f: f.write(new_code)Rationale
Why AST?
1. Syntax Correctness Guaranteed
AST manipulation is type-safe at the syntax level. If the AST is valid, the generated code is valid Python.
# β String replacement can break syntax:code = code.replace("router = APIRouter()", "router = APIRouter(\nrouter.include_router(commerce_router)")# Result: Syntax error (unclosed parenthesis)
# β
AST always generates valid syntax:tree.body.append(ast.Expr(...)) # Can't create syntax error2. Preserves Existing Code
AST transformations donβt modify unrelated code. Only the specific nodes we target change.
# String replace might match unintended lines:code = code.replace("router", "new_router") # Breaks variable names!
# AST targets specific nodes:for node in ast.walk(tree): if isinstance(node, ast.ImportFrom) and node.module == 'fastapi': # Only modify this specific import3. Idempotent Injections
We can check if code already injected by inspecting AST:
def is_already_injected(tree: ast.AST, feature: str) -> bool: for node in ast.walk(tree): if isinstance(node, ast.ImportFrom): if node.module == f'features.{feature}.src.routes': return True # Already injected return False
# Prevents duplicate injectionsif is_already_injected(tree, 'commerce'): print("Commerce already injected, skipping") return4. Handles Edge Cases
AST handles different code styles automatically:
# Works with different indentation:code_spaces = "def foo():\n return 1"code_tabs = "def foo():\n\treturn 1"# Both parse to same AST, generate consistent output
# Works with multiline statements:code = """router = ( APIRouter( prefix="/api" ))"""tree = ast.parse(code) # β
Parses correctlyConsequences
Positive
β Syntax Safety: Generated code always valid Python β Robustness: Handles edge cases (multiline, comments, etc.) β Idempotent: Can detect if already injected β Preserves Style: Maintains indentation, formatting β Type-Safe: Preserves type hints β Debuggable: Can print AST to inspect transformations
Negative
β οΈ Complexity: AST manipulation more complex than string replace
β οΈ Comments Lost: ast.parse() discards comments (limitation of Python AST)
β οΈ Python-Only: Canβt use AST for JavaScript, Ruby injection
β οΈ Learning Curve: Developers must understand AST concepts
β οΈ Version Sensitivity: AST API changes between Python versions
Mitigation Strategies
For Comments:
- Use marker comments that AST preserves:
# SSCLI:COMMERCE:START - These become string constants in AST, not discarded
- Alternative: Use decorators as markers (preserved in AST)
For Multi-Language:
- Use appropriate parsers for other languages
- JavaScript: Babel AST
- Ruby: Ripper/RuboCop AST
- TypeScript: TypeScript Compiler API
- Share injection logic patterns across languages
For Learning Curve:
- Comprehensive developer docs
- Provide injection helpers/utilities
- Hide AST complexity behind simple APIs
Alternatives Considered
Alternative 1: String Markers + String Replacement
Approach:
# Target file has markers:# === BEGIN COMMERCE IMPORTS ===# === END COMMERCE IMPORTS ===
# Injection finds markers, inserts between themcode = code.replace( "# === BEGIN COMMERCE IMPORTS ===", "# === BEGIN COMMERCE IMPORTS ===\nfrom features.commerce import router")Pros:
- Simple to implement
- Easy to understand
- Language-agnostic
Cons:
- Requires markers in every injection point (clutters code)
- Risk of breaking syntax if markers misplaced
- Canβt detect if already injected (markers might be removed)
- Fragile: One typo in marker β injection fails
Why Rejected: Too fragile, requires manual marker placement, error-prone
Alternative 2: Template Engines (Jinja, Mustache)
Approach:
# routes.py.j2 template:from fastapi import APIRouter{% for feature in features %}from features.{{ feature }}.src.routes import router as {{ feature }}_router{% endfor %}
router = APIRouter(){% for feature in features %}router.include_router({{ feature }}_router, prefix="/{{ feature }}"){% endfor %}Pros:
- Familiar to web developers
- Supports loops, conditionals
- Clean syntax
Cons:
- Overwrites entire file (loses user customizations)
- Not idempotent (re-rendering clobbers changes)
- Hard to inject into existing code (only works for full generation)
- Canβt preserve userβs code structure
Why Rejected: Doesnβt support incremental injection into existing code
Alternative 3: Code Generation (cookiecutter-style)
Approach:
- Generate complete feature-enabled project from scratch
- User copies files they need
Pros:
- Simple (just file copying)
- No parsing needed
Cons:
- Not βinjectionβ (user must manually integrate)
- Doesnβt work for existing projects
- User must merge changes manually (error-prone)
- No upgrade path (canβt re-generate without losing changes)
Why Rejected: Doesnβt meet requirement of automatic injection
Alternative 4: Custom DSL (Domain-Specific Language)
Approach:
# Custom syntax:@feature("commerce")class API: router = APIRouter()
@inject_routes def configure_routes(self): passPros:
- Declarative and clean
- Easy for users to understand injection points
Cons:
- Requires users to rewrite code in DSL
- Not compatible with existing Python code
- Adds dependency on custom framework
- Limits what users can do (constrained by DSL)
Why Rejected: Too invasive, requires rewriting user code
Implementation Guidelines
For CLI Developers
Rules:
- Always validate syntax before writing:
ast.parse(new_code) - Check if already injected before transforming
- Handle parse errors gracefully (file may have syntax errors)
- Test with edge cases: multiline, comments, type hints
Example: Safe Injection
def inject_feature(filepath: str, feature: str): try: with open(filepath) as f: code = f.read()
# 1. Parse (may fail if syntax errors) tree = ast.parse(code)
# 2. Check if already injected if is_already_injected(tree, feature): logger.info(f"{feature} already injected, skipping") return
# 3. Transform AST tree = add_feature_imports(tree, feature) tree = add_feature_registration(tree, feature)
# 4. Generate code new_code = ast.unparse(tree)
# 5. Validate (should never fail, but defensive) ast.parse(new_code)
# 6. Write back with open(filepath, 'w') as f: f.write(new_code)
logger.info(f"{feature} injected successfully")
except SyntaxError as e: logger.error(f"Syntax error in {filepath}: {e}") raise InjectionError("Target file has syntax errors, fix before injecting")For Template Users
Best Practices:
- Commit before injecting:
git commit -am "pre-injection checkpoint" - Review changes:
git diffafter injection - Test after injection: Run tests, check endpoints work
- Report issues: If injection breaks code, report with file sample
References
- Python AST Module Docs
- Green Tree Snakes (AST Tutorial)
- Python AST with Refactoring
- Understanding AST
Review & Approval
Proposed: 2026-02-15 Accepted: 2026-03-01 Reviewed By: CLI Team, Technical Architects Next Review: 2026-09-01 (6 months or after 10,000 injections)
Changelog
- 2026-02-15: Initial draft
- 2026-03-01: Accepted after CLI team review
- 2026-04-01: Published in public docs