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'infoo.ts) — type-only imports do not create runtime cycles - Barrel file re-exports (
index.tsthat 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.