Fast circular dependency detection for JavaScript/TypeScript projects.
A drop-in replacement for madge --circular with 50-100x performance improvement.
Circular dependencies in JavaScript/TypeScript projects cause:
- Runtime errors from undefined imports
- Difficult-to-debug initialization order issues
- Webpack/bundler warnings and failed builds
- Increased cognitive load when understanding code flow
Tools like madge detect these cycles but become painfully slow on large monorepos. jscycles uses Rust, parallel processing, and Tarjan's strongly connected components algorithm to analyze thousands of files in milliseconds.
| Project Size | madge --circular | jscycles |
|---|---|---|
| 100 files | ~2s | ~20ms |
| 1,000 files | ~15s | ~100ms |
| 10,000 files | ~3min | ~2s |
cargo install --path .# Check current directory for cycles
jscycles
# Check specific directory
jscycles src/
# Output as JSON for CI integration
jscycles --json
# Only check specific packages in a monorepo
jscycles --only "@myorg/core-*"
# Show only inter-package cycles (outer cycles)
jscycles --outer
# Show only file-level cycles (inner cycles)
jscycles --innerjscycles [OPTIONS] [PATHS]...
Arguments:
[PATHS]... Paths to check (defaults to current directory)
Options:
--only <PATTERN> Only check packages matching glob (repeatable)
--exclude <PATTERN> Exclude packages matching glob (repeatable)
--extensions <EXT> File extensions to analyze [default: ts,tsx,js,jsx]
--tsconfig <PATH> Path to tsconfig.json
--no-tsconfig Skip tsconfig.json auto-detection
--json Output as JSON
--stdin Read paths from stdin
--inner Show only file-level (inner) cycles
--outer Show only package-level (outer) cycles
-c, --config <PATH> Config file path [default: jscycles.yaml]
-q, --quiet Only output if cycles found
-h, --help Print help
-V, --version Print version
# Check a single package
jscycles packages/core
# Check multiple directories
jscycles packages/core packages/utils packages/ui# Only check packages matching a pattern
jscycles --only "packages/feature-*"
# Exclude test packages
jscycles --exclude "*-test" --exclude "*-e2e"
# Combine filters
jscycles --only "@myorg/*" --exclude "@myorg/legacy-*"jscycles detects two types of cycles:
- Inner cycles: File-level circular dependencies within a package
- Outer cycles: Package-level circular dependencies between workspace packages
# Show both cycle types (default)
jscycles
# Show only file-level cycles
jscycles --inner
# Show only package-level cycles
jscycles --outerWorkspace packages are automatically detected from:
- npm/yarn/bun:
package.jsonworkspaces field - pnpm:
pnpm-workspace.yaml - TypeScript:
tsconfig.jsonproject references
# JSON output for parsing
jscycles --json > cycles.json
# Quiet mode - only output if cycles exist (for CI gates)
jscycles --quiet || echo "Circular dependencies detected!"
# Check specific packages from a file list
cat changed-packages.txt | jscycles --stdin# Only TypeScript files
jscycles --extensions ts,tsx
# Include additional extensions
jscycles --extensions ts,tsx,js,jsx,mjs,cjsCreate a jscycles.yaml in your project root for persistent configuration:
# Package discovery settings
scan:
# Glob patterns for directories containing packages
include:
- "packages/*"
- "apps/*"
- "libs/*"
# Directories to skip (defaults shown)
exclude:
- "**/node_modules"
- "**/dist"
- "**/build"
# Default settings for all packages
defaults:
# File extensions to analyze
extensions:
- ts
- tsx
- js
- jsx
# Package-specific overrides (keyed by glob pattern)
packages:
# Legacy packages might use different extensions
"@myorg/legacy-*":
extensions:
- js
ignore:
- "**/*.test.js"
# Ignore test files in all feature packages
"@myorg/feature-*":
ignore:
- "**/*.test.ts"
- "**/*.spec.ts"
- "**/__tests__/**"
- "**/__mocks__/**"- CLI arguments (highest priority)
- Package-specific config in
jscycles.yaml - Default config in
jscycles.yaml - Built-in defaults (lowest priority)
jscycles automatically discovers and uses tsconfig.json for path alias resolution:
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["src/*"],
"@components/*": ["src/components/*"],
"@utils": ["src/utils/index.ts"]
}
}
}With this config, imports like @/services/api resolve to src/services/api.ts.
# Use a specific tsconfig
jscycles --tsconfig tsconfig.build.json
# Disable tsconfig discovery (treat path aliases as external)
jscycles --no-tsconfig- Auto-discovery: Searches upward from each package for
tsconfig.json - Extension resolution: Tries
.ts,.tsx,.js,.jsxin order - Index files: Resolves
@/utilstosrc/utils/index.tsif directory - Graceful fallback: Unresolvable aliases are treated as external modules
=== File-level cycles ===
β @myorg/utils: no cycles
β @myorg/core: 2 cycles
components/Button.tsx β hooks/useTheme.ts β components/Button.tsx
services/api.ts β services/auth.ts β services/api.ts
β @myorg/ui: no cycles
=== Package-level cycles ===
β @myorg/core β @myorg/utils β @myorg/core
Summary: 3 packages, 2 file cycles, 1 package cycles
With color support in TTY:
- Green checkmark for clean packages
- Red X for packages with cycles
jscycles --json{
"has_cycles": true,
"file_cycles": {
"packages": [
{
"name": "@myorg/utils",
"path": "packages/utils",
"cycles": []
},
{
"name": "@myorg/core",
"path": "packages/core",
"cycles": [
["components/Button.tsx", "hooks/useTheme.ts", "components/Button.tsx"],
["services/api.ts", "services/auth.ts", "services/api.ts"]
]
}
]
},
"package_cycles": [
["@myorg/core", "@myorg/utils", "@myorg/core"]
]
}jscycles uses Tarjan's strongly connected components (SCC) algorithm:
- Parse: Extract imports from all files using ast-grep
- Build graph: Create a directed graph of file dependencies
- Find SCCs: Run Tarjan's algorithm to find strongly connected components
- Extract cycles: SCCs with >1 node contain cycles; single nodes are checked for self-loops
- Normalize: Cycles are normalized to start from the lexicographically smallest file
Supported import syntaxes:
import x from './module'import { x } from './module'import * as x from './module'import './module'(side-effect imports)export { x } from './module'export * from './module'- Dynamic
import('./module') require('./module')(CommonJS)
| Import | Classification |
|---|---|
./relative/path |
Resolved to file path |
../parent/path |
Resolved to file path |
@/aliased/path |
Resolved via tsconfig paths |
lodash |
External (node_modules) |
@myorg/utils |
Workspace package (if in workspace) |
@scope/package |
External (node_modules) |
File paths participate in file-level (inner) cycle detection. Workspace package imports participate in package-level (outer) cycle detection. External node_modules are ignored.
| Code | Meaning |
|---|---|
| 0 | No circular dependencies found |
| 1 | Circular dependencies detected |
| 2 | Error occurred (invalid config, parse error, etc.) |
jscycles can be used as a Rust library:
use jscycles::{
Config, CycleDetector, DependencyGraph, ImportExtractor,
PackageDiscovery, TsConfig, Workspace,
};
use jscycles::cycles::PackageCycleDetector;
use jscycles::graph::PackageDependencyGraph;
// Load configuration
let config = Config::load_or_default(Path::new("jscycles.yaml"));
// Discover workspace for inter-package cycles
let workspace = Workspace::discover(&PathBuf::from("."))?;
// Discover packages
let discovery = PackageDiscovery::new(&config, &[], &[])?;
let packages = discovery.discover(&PathBuf::from("."))?;
// Collect imports from all packages
let mut imports_by_package = HashMap::new();
for package in &packages {
let tsconfig = TsConfig::discover(&package.path);
let extractor = ImportExtractor::new(
vec!["ts".into(), "tsx".into()],
tsconfig,
).with_workspace(workspace.clone());
let imports = extractor.extract(&package.path)?;
// File-level cycles
let graph = DependencyGraph::from_imports(&imports);
let cycles = CycleDetector::detect(&graph);
println!("{}: {} file cycles", package.name, cycles.len());
imports_by_package.insert(package.name.clone(), imports);
}
// Package-level cycles
if workspace.is_some() {
let pkg_graph = PackageDependencyGraph::from_imports(&imports_by_package);
let pkg_cycles = PackageCycleDetector::detect(&pkg_graph);
println!("{} inter-package cycles", pkg_cycles.len());
}| Feature | madge | jscycles |
|---|---|---|
| Performance | Slow on large repos | 50-100x faster |
| Language | JavaScript | Rust |
| Parallel processing | No | Yes (rayon) |
| tsconfig paths | Yes | Yes |
| JSON output | Yes | Yes |
| Webpack config | Yes | No |
| Image output | Yes | No |
| Monorepo support | Limited | First-class |
| Inter-package cycles | No | Yes |
| Workspace detection | No | npm/pnpm/TypeScript |
- madge - Popular JavaScript dependency graph and circular dependency detector (see comparison above)
- NX - Monorepo build system with module boundary enforcement and circular dependency detection via ESLint rules
- eslint-plugin-import - ESLint plugin with
no-cyclerule for detecting circular dependencies inline during development - dpdm - JavaScript/TypeScript circular dependency detector with tree output
- circular-dependency-plugin - Webpack plugin that detects cycles during bundling
- Ensure you're running from a directory with
package.jsonfiles - Check your
scan.includepatterns injscycles.yaml - Use
--onlyto explicitly specify package patterns
- Ensure
tsconfig.jsonexists and hascompilerOptions.paths - Check that
baseUrlis set correctly - Use
--tsconfigto specify an explicit path - Verify the alias pattern matches (e.g.,
@/*vs@/)
- Add patterns to
ignorein package config - Use
--excludeto skip problematic packages - Check for
export * fromre-exports that create apparent cycles
# Run all checks (format, clippy, test, deny, doc)
cargo xtask ciSee CLAUDE.md for detailed development guidelines.
MIT OR Apache-2.0