Fast circular dependency detection for JavaScript and TypeScript projects.
Inspired by madge, but built in Rust for speed and comprehensive cycle output.
Download the latest release for your platform from Releases.
Or build from source:
cargo install --path .cdd [OPTIONS] <DIR># Scan a directory (auto-detects workspace and tsconfig)
cdd ./src
# Exclude directories
cdd --exclude node_modules --exclude dist ./src
# Ignore type-only imports (recommended for TypeScript)
cdd --ignore-type-imports ./src
# CI mode: fail if any new cycles are found
cdd -n 0 ./src
# Initialize config with current cycles as baseline
cdd --init ./src
# Watch mode: re-run on file changes
cdd --watch ./src
# JSON output for tooling integration
cdd --json ./src
# CI with hash validation (detects when cycles change)
cdd --expected-hash abc123def456 ./srcArguments:
<DIR> The root directory to analyze
Options:
-e, --exclude <EXCLUDE> Directories to exclude (can be used multiple times)
-t, --ignore-type-imports Ignore type-only imports (import type { Foo })
-d, --debug Enable debug logging
-n, --numberOfCycles <N> Expected number of cycles [default: 0]
-s, --silent Suppress all output
-w, --watch Watch mode: re-run analysis on file changes
--tsconfig <PATH> Path to tsconfig.json (auto-detected by default)
--no-tsconfig Disable tsconfig auto-detection
--no-workspace Disable workspace auto-detection
--json Output results as JSON
--expected-hash <HASH> Fail if cycle hash doesn't match (for CI)
--allowlist <PATH> Path to file listing allowed cycles
--update-hash Update expected_hash in config file
--init Initialize config with current cycles as baseline
-h, --help Print help
-V, --version Print version
| Extension | Syntax |
|---|---|
.ts |
TypeScript |
.tsx |
TypeScript + JSX |
.js, .mjs |
ES Modules |
.jsx |
ES Modules + JSX |
.cjs |
CommonJS |
- ES Module imports:
import { foo } from './foo' - Type-only imports:
import type { Foo } from './foo'(skipped with-t) - Dynamic imports:
const mod = await import('./foo') - CommonJS:
const foo = require('./foo') - Re-exports:
export * from './foo'
X Found 2 circular dependencies!
1) Circular dependency [b19d1af3c370]:
src/services/orderService.ts:3
| import { UserService } from './userService';
v
src/services/userService.ts:3
| import { OrderService } from './orderService';
^-- (cycle)
2) Circular dependency [44ff849f72f4]:
src/components/Button.tsx:3
| import { Modal } from './Modal';
v
src/components/Modal.tsx:3
| import { Form } from './Form';
v
src/components/Form.tsx:3
| import { Button } from './Button';
^-- (cycle)
Each cycle shows:
- A unique hash for identification
- The exact file and line number of each import
- The import statement causing the dependency
CDD supports configuration files to avoid repeating options. Create .cddrc.json or cdd.config.json in your project root:
{
"exclude": ["node_modules", "dist", "__tests__"],
"ignore_type_imports": true,
"expected_cycles": 0,
"expected_hash": "abc123def456",
"allowed_cycles": [
{
"files": ["src/a.ts", "src/b.ts"],
"reason": "Known issue, tracked in JIRA-123"
}
]
}The easiest way to set up a config is to use --init:
cdd --init ./srcThis creates .cddrc.json with all current cycles in the allowlist. After init:
- Existing cycles are allowed (won't cause CI failures)
- New cycles will cause failures
- You can gradually fix cycles and remove them from the allowlist
CDD searches for config files starting from the target directory and walking up. CLI arguments take precedence over config file values.
cdd -n 0 ./srcUse --expected-hash to detect when cycles change, even if the count stays the same:
# First, get the current hash
cdd ./src
# Output: Cycles hash: abc123def456
# Then use it in CI
cdd --expected-hash abc123def456 ./srcUpdate the hash when cycles change intentionally:
cdd --update-hash ./srcUse --json for integration with other tools:
cdd --json ./src{
"total_files": 150,
"total_cycles": 2,
"cycles_hash": "abc123def456",
"cycles": [
{
"hash": "b19d1af3c370",
"edges": [
{
"from_file": "src/a.ts",
"to_file": "src/b.ts",
"line": 3,
"import_text": "import { b } from './b';"
}
]
}
]
}Note: Watch mode is only available when building from source with the
watchfeature (enabled by default). Pre-built release binaries do not include watch mode to ensure cross-platform compatibility.
Use --watch to continuously monitor for changes and re-run analysis:
cdd --watch ./srcThe terminal clears between runs, showing fresh results each time. Press Ctrl+C to stop.
CDD automatically detects and uses tsconfig.json in your project root for path alias resolution. You can also specify a custom path:
cdd --tsconfig ./packages/app/tsconfig.json ./srcUse --no-tsconfig to disable auto-detection.
Supports:
compilerOptions.pathsmappings (e.g.,@/*→src/*)compilerOptions.baseUrlfor non-relative importsextendschains (inherits from parent configs)
Example tsconfig.json:
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["src/*"],
"@components/*": ["src/components/*"]
}
}
}CDD automatically detects monorepo workspaces and resolves bare package imports like @acme/ui to their actual source files:
cdd ./packagesThis enables detection of cross-package cycles:
1) Circular dependency [d1bd5c39d882]:
packages/core/src/index.ts:2
| import { UI_VERSION } from "@acme/ui";
v
packages/ui/src/index.ts:2
| import { formatDate } from "@acme/utils";
v
packages/utils/src/index.ts:2
| import { getConfig } from "@acme/core";
^-- (cycle)
Use --no-workspace to disable auto-detection if needed.
| Format | Config File | Field |
|---|---|---|
| npm/yarn | package.json |
workspaces |
| pnpm | pnpm-workspace.yaml |
packages |
CDD resolves package imports in this order:
exportsfield - Conditional exports (import/require/default), subpath exports, wildcardsmodulefield - ES module entry pointmainfield - CommonJS entry point- Convention -
src/index.ts,index.ts,index.js
Deep imports into packages are resolved via the exports field:
// Resolves @acme/ui/button to packages/ui/src/components/button.ts
import { Button } from "@acme/ui/button";With package.json:
{
"name": "@acme/ui",
"exports": {
".": "./src/index.ts",
"./button": "./src/components/button.ts",
"./*": "./src/*.ts"
}
}TypeScript's import type statements are erased at compile time and don't cause runtime circular dependencies. Use --ignore-type-imports to skip these:
# Source has 5 cycles, but only 4 are runtime cycles
cdd ./src # Reports 5 cycles
cdd --ignore-type-imports ./src # Reports 4 cyclesYou can scan compiled JavaScript to see actual runtime dependencies:
# Build your project first
npm run build
# Scan source vs built output
cdd --exclude dist ./src # TypeScript source (includes type imports)
cdd --exclude src ./dist # Built JavaScript (type imports erased)Built output often has fewer cycles because TypeScript erases:
import typestatements- Imports used only for type annotations
- Recursively find all JS/TS files in the directory
- Parse each file and extract imports using SWC
- Build a dependency graph
- Find strongly connected components using Kosaraju's algorithm
- Report unique cycles
Exit codes:
0- Success (cycles match expected count, or no cycles if-nnot specified)1- Failure (cycles found, or count doesn't match-n)
Unlike madge, CDD outputs one comprehensive chain per cycle instead of multiple overlapping fragments:
Multiple smaller cycles (madge style):
a.ts > b.ts > a.ts
a.ts > c/index.ts > c/b.ts > a.ts
Single comprehensive cycle (CDD):
a.ts > b.ts > c/index.ts > c/b.ts
This is easier to understand and debug.
- Extract shared code - Move common functionality to a separate module
- Use interfaces - Depend on abstractions instead of concrete implementations
- Introduce a coordinator - Create a module that coordinates interactions
- Lazy loading - Use dynamic imports for non-critical dependencies
# Run tests
cargo test
# Build release
cargo build --release
# Run against test fixture
./target/release/cdd ./fixtures/example-monorepo/packages --exclude dist# Bump version
./scripts/bump-version.sh 0.4.0
# Create and push release (runs tests, creates tag, pushes)
./scripts/release.shInstall Homebrew tools for cross-compilation:
brew tap messense/macos-cross-toolchains
brew install x86_64-unknown-linux-gnuAdd the toolchain to your environment:
export CC_x86_64_unknown_linux_gnu=x86_64-unknown-linux-gnu-gcc
export CXX_x86_64_unknown_linux_gnu=x86_64-unknown-linux-gnu-g++Install the Rust target and build:
rustup target add x86_64-unknown-linux-gnu
cargo build --release --target x86_64-unknown-linux-gnuMIT