← AI Chaos Guide/failure patterns
circular-dependencies

Circular Dependencies in AI-Generated Code: Detection and Remediation

Circular dependencies are a failure pattern in AI-generated codebases where module A imports from module B and module B imports from module A — directly or through a transitive chain. The consequence: isolation becomes impossible, every change has an unpredictable blast radius, and the codebase cannot be safely tested, refactored, or scaled.

The structural mechanism: prompt-driven development resolves imports at the file level without graph-level awareness. Each session asks "what does this file need to import?" — not "does this import create a cycle in the full dependency graph?" Circular dependencies form silently, one import at a time, across sessions that have no visibility into each other's dependency decisions.

This page explains how to detect circular dependencies with precision, how to interpret the findings, and what the remediation path looks like.


What We Observe

Circular dependencies in AI-generated codebases present with specific structural signatures:

  • Unpredictable blast radius — a change in module A breaks module C, which has no apparent relationship to A; the connection is through a circular dependency chain
  • Slow or non-deterministic builds — the bundler must resolve cycles, which adds overhead and can produce different output depending on module initialization order
  • Test initialization failures — a unit test for module A fails to initialize because loading A requires loading B, which requires loading C, which requires loading A — a circular initialization deadlock
  • "Cannot find module" errors at runtime — circular dependencies can cause modules to be undefined at the point of first use, depending on the JavaScript module resolution order
  • Refactoring paralysis — the team cannot safely move a function from module A to module B because the move would either create a new cycle or break an existing one

These are not symptoms of a single bug. They are symptoms of a dependency graph that has lost its directed acyclic structure — the structural property that makes isolation and independent testing possible.


Detection

TypeScript / JavaScript: madge

# Install madge if not present
npm install -g madge 2>/dev/null || npx madge --version

# Detect all circular dependencies
echo "=== Circular dependency detection ==="
npx madge --circular --extensions ts,tsx,js src/ 2>/dev/null

# Count circular chains
CYCLES=$(npx madge --circular --extensions ts,tsx src/ 2>/dev/null | \
  grep -c "→" || echo "0")
echo "Total circular chains: $CYCLES"

# Export full dependency graph for visualization
npx madge --extensions ts,tsx src/ --json 2>/dev/null > /tmp/dep-graph.json
echo "Full graph exported to /tmp/dep-graph.json"

# Find the most connected modules (hub nodes — highest cycle risk)
echo ""
echo "=== Most imported modules (hub nodes) ==="
find src -name "*.ts" -o -name "*.tsx" 2>/dev/null | while read f; do
  count=$(grep -rl "from.*$(basename $f .ts)\|from.*$(basename $f .tsx)" \
    src/ 2>/dev/null | wc -l)
  echo "$count $f"
done | sort -rn | head -10

Python: pydeps + manual check

# Install pydeps
pip install pydeps --quiet 2>/dev/null

# Detect circular imports
echo "=== Circular import detection (Python) ==="
pydeps . --max-bacon=3 --show-cycles 2>/dev/null || \
  echo "pydeps not available — using manual check below"

# Manual circular import check using AST
python3 << 'EOF'
import ast
import os
from pathlib import Path
from collections import defaultdict, deque

def get_local_imports(filepath, base_dir):
    try:
        tree = ast.parse(Path(filepath).read_text())
        imports = []
        for node in ast.walk(tree):
            if isinstance(node, ast.ImportFrom) and node.module:
                module_path = node.module.replace('.', '/')
                candidate = os.path.join(base_dir, module_path + '.py')
                if os.path.exists(candidate):
                    imports.append(candidate)
        return imports
    except:
        return []

base = '.'
graph = defaultdict(list)
py_files = list(Path(base).rglob('*.py'))
for f in py_files:
    for imp in get_local_imports(str(f), base):
        graph[str(f)].append(imp)

# Simple cycle detection
def has_cycle(graph):
    visited = set()
    rec_stack = set()
    cycles = []
    def dfs(node, path):
        visited.add(node)
        rec_stack.add(node)
        for neighbor in graph.get(node, []):
            if neighbor not in visited:
                dfs(neighbor, path + [neighbor])
            elif neighbor in rec_stack:
                cycle_start = path.index(neighbor) if neighbor in path else 0
                cycles.append(path[cycle_start:] + [neighbor])
        rec_stack.discard(node)
    for node in graph:
        if node not in visited:
            dfs(node, [node])
    return cycles

cycles = has_cycle(graph)
print(f"Circular dependency chains found: {len(cycles)}")
for i, cycle in enumerate(cycles[:5], 1):
    print(f"  Cycle {i}: {' → '.join(os.path.basename(f) for f in cycle)}")
EOF

Quick Severity Check

echo "=== RC02 quick severity assessment ==="
CYCLES=$(npx madge --circular --extensions ts,tsx src/ 2>/dev/null | \
  grep -c "→" 2>/dev/null || echo "unknown")
echo "Circular chains: $CYCLES"

if [ "$CYCLES" = "0" ]; then
  echo "Severity: LOW — no circular dependencies detected"
elif [ "$CYCLES" -le 2 ]; then
  echo "Severity: MEDIUM — 1-2 chains, RC02 base score: 4"
elif [ "$CYCLES" -le 5 ]; then
  echo "Severity: HIGH — 3-5 chains, RC02 base score: 6"
else
  echo "Severity: CRITICAL — >5 chains, RC02 base score: 8"
fi

Interpretation Table

Circular chains Severity Consequence
0 None Dependency graph is clean — no action required
1–2 Medium Isolated blast radius expansion — monitor and plan remediation
3–5 High Multiple modules with unpredictable blast radius — remediation required
>5 Critical Dependency graph has lost DAG structure — structural intervention required

What circular dependencies are not:

  • Self-referential type imports in TypeScript (import type { Foo } from './foo' in foo.ts) — type-only imports do not create runtime cycles
  • Barrel file re-exports (index.ts that re-exports from multiple modules) — these create apparent cycles in some tools but are not runtime cycles if there are no value imports

Remediation Path

Step 1: Map the Cycle and Identify the Violation Direction

For each circular chain, identify which direction of the dependency is architecturally legitimate and which is a violation:

Cycle: auth/login ↔ user/profile

Analysis:
  auth/login imports user/profile → to get user data after login ✅ legitimate
  user/profile imports auth/login → to check if user is authenticated ❌ violation

Reason: auth is a lower-level domain than user profile.
        Profile should not depend on auth internals.

Intervention: remove the auth/login import from user/profile.

Step 2: Break the Cycle

Three patterns for breaking circular dependencies:

Pattern A: Parameter injection — pass the dependency as a parameter instead of importing it

// ❌ Before: user/profile imports auth/login (creates cycle)
import { checkAuthStatus } from '../auth/login/service'

export function getUserProfile(userId: string) {
  const isAuthenticated = checkAuthStatus(userId)  // cycle
  if (!isAuthenticated) throw new Error('Unauthorized')
  return profileRepo.get(userId)
}

// ✅ After: auth status passed as parameter — no import needed
export function getUserProfile(userId: string, isAuthenticated: boolean) {
  if (!isAuthenticated) throw new Error('Unauthorized')
  return profileRepo.get(userId)
}
// Caller (route handler) checks auth and passes the result

Pattern B: Shared interface — both modules depend on a shared interface, not on each other

// shared/interfaces/auth.ts — no domain logic, just the contract
export interface AuthContext {
  userId: string
  isAuthenticated: boolean
  roles: string[]
}

// auth/login/service.ts — produces AuthContext
// user/profile/service.ts — consumes AuthContext (no auth import)

Pattern C: Event-based communication — replace direct import with an event/callback

# ❌ Before: notifications imports billing (creates cycle)
from domains.billing.calculate_overage import CalculateOverageService

# ✅ After: billing emits an event; notifications subscribes
# billing/calculate_overage/service.py
def execute(self, request):
    result = self._calculate(request)
    self.event_bus.emit('overage_calculated', result)  # no notifications import
    return result

# notifications/send_overage_alert/handler.py
def on_overage_calculated(event):  # subscribes to event — no billing import
    self.send_alert(event.user_id, event.amount)

Step 3: Enforce with CI/CD

# Add to CI/CD pipeline — fail if circular dependencies detected
npx madge --circular --extensions ts,tsx src/ --exit-code
# Exit code 1 if any circular dependency found
# .github/workflows/ci.yml
- name: Check circular dependencies
  run: npx madge --circular --extensions ts,tsx src/ --exit-code
  # Fails the build if any circular dependency is introduced

How FP006 Interacts with Other Failure Patterns

Circular dependencies do not occur in isolation. They interact with other failure patterns in a compounding pattern:

  • FP002 (Business Logic in Wrong Layer) — layer violations create the conditions for circular dependencies; when the UI layer imports from the service layer and the service layer imports shared types from the UI layer, a cycle forms
  • FP001 (Oversized Files) — large files with multi-domain logic are more likely to create circular imports because they contain logic from multiple domains that reference each other
  • FP014 (Low Test Coverage) — circular dependencies make unit testing structurally impossible; a test for module A must load module B (which loads module A), making true isolation unachievable
  • FP008 (Shared Utils Overuse) — a shared utils file imported by many modules is a common source of transitive cycles; module A imports utils, utils imports module B, module B imports module A

Addressing FP006 — breaking circular chains — directly enables FP014 remediation: once modules can be loaded in isolation, unit tests become structurally possible.


Is This Happening in Your Codebase?

Get a structural assessment with your AI Chaos Index score — delivered in 24 hours.