Skip to content

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:

  1. Inject code without breaking existing syntax (no manual merging)
  2. Preserve code style (indentation, formatting, comments)
  3. Idempotent: Running injection twice shouldn’t break anything
  4. Robust: Handle different coding styles (spaces vs tabs, etc.)
  5. 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 APIRouter
from 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") # ← Injected

Decision

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 error

2. 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 import

3. 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 injections
if is_already_injected(tree, 'commerce'):
print("Commerce already injected, skipping")
return

4. 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 correctly

Consequences

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 them
code = 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):
pass

Pros:

  • 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:

  1. Always validate syntax before writing: ast.parse(new_code)
  2. Check if already injected before transforming
  3. Handle parse errors gracefully (file may have syntax errors)
  4. 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:

  1. Commit before injecting: git commit -am "pre-injection checkpoint"
  2. Review changes: git diff after injection
  3. Test after injection: Run tests, check endpoints work
  4. Report issues: If injection breaks code, report with file sample

References


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