Madge vs dependency-cruiser: Cycles, Rules and CI on Excalidraw

Raghav Chamadiya11 min read
Compared in this post: repowise and
  • Madge
  • dependency-cruiser
  • Knip
On this page

Use madge if you want a list of circular imports; use dependency-cruiser if you want a rule that fails CI. Madge gives you the list of circular imports and a graph image from one command, with almost nothing to configure. dependency-cruiser gives you rules ("no cycles", "packages/common never imports the UI package") that fail CI, plus a baseline so you can adopt it on a codebase that already breaks them.

On one commit of the Excalidraw monorepo, madge printed 839 cycles and dependency-cruiser 1,721 cycle warnings, yet both graphs reduce to the same three tangles of 361, 4 and 4 files. The sections below show where the counts come from, what each tool does to a CI job, and the one rule that explains most of the 361-file tangle. We build repowise, which appears near the end.

We build repowise, so weigh our entry accordingly. The measurements we publish about it, with their methods, are on the benchmarks page.

At a glance

Madgedependency-cruiser
Best forA quick answer and a pictureRules enforced in CI
Latest version (Oct 29, 2026)8.0.0, released August 202418.7.0 (npm entry updated October 23, 2026)
LicenseMITMIT
Config to get startedNone (flags)depcruise --init writes a rules file
Circular dependencies on Excalidraw839 listed (127 without type-only imports)1,721 warnings, one per edge in a cycle (260 runtime-only errors after tuning)
Warm run time on Excalidraw6.7 to 7.2 s3.4 to 3.7 s
Fails CI on a cycle by defaultYes, exits 1No, the generated rule is warn
Custom import rulesNoYes: path, dependency type, orphans, dev deps, reachability
Baseline of known violationsNoYes, depcruise-baseline plus --ignore-known
Graph outputGraphviz image, DOT, JSONDOT, archi, Mermaid, D2, HTML, JSON and more
LanguagesJavaScript, TypeScript, Sass and other CSS preprocessorsJavaScript, TypeScript and the transpiled languages its README lists

Scroll the table sideways to see every column.

What we ran

We ran each tool against excalidraw/excalidraw at commit e7b5346 (October 28, 2026). It is a Yarn workspace with the app in excalidraw-app/ and the library split into packages/common, math, element, excalidraw and utils: 675 TypeScript files, wired together with tsconfig paths aliases such as @excalidraw/element. Both tools resolved those aliases from the tsconfig; madge needs --ts-config to do it.

Versions were madge 8.0.0, dependency-cruiser 18.7.0 and TypeScript 5.9.3 on Node 22.19, on a Windows 11 laptop with a Ryzen 9 8940HX. Dependencies were installed first, so both tools could resolve real packages. Timings are wall clock, with the first (cold) run reported separately from three warm runs. The full command log is in How we measured.

Circular dependencies: same tangles, very different counts

Both tools found the same circular dependencies, and the headline counts still differ by a factor of two, because they count different things. Madge lists cycle paths it walks into; dependency-cruiser flags every import edge that sits on a cycle.

bash
npx madge --circular --extensions ts,tsx --ts-config tsconfig.json packages excalidraw-app
text
Processed 989 files (12.9s) (28 warnings)

✖ Found 839 circular dependencies!

1) packages/common/src/index.ts > packages/common/src/appEventBus.ts > packages/common/src/emitter.ts > packages/excalidraw/types.ts
...
4) packages/element/src/index.ts > packages/element/src/Scene.ts

dependency-cruiser with the config that depcruise --init generated reported 1,721 no-circular warnings, among 2,040 violations in total.

Neither number tells you how much trouble you are in. So we exported each tool's graph as JSON and grouped it into strongly connected components (sets of files that can all reach each other), with a short script. Both graphs gave the same answer:

GraphMadge saysdependency-cruiser saysStrongly connected components
All imports839 cycles1,721 warnings3: one of 361 files, two of 4
Runtime imports only127 cycles260 errors7: 106, 45, 12, 8, 4, 2, 2 files

Scroll the table sideways to see every column.

839 circular dependencies are three tangles. The big one exists because barrel files (packages/common/src/index.ts, packages/element/src/index.ts) re-export everything, and a few lower-level files import types back from the UI package, for example packages/common/src/emitter.ts doing import type { UnsubscribeCallback } from "@excalidraw/excalidraw/types". Every barrel turns one back-import into dozens of listed paths.

Type-only imports change the picture. import type is erased at compile time, so it cannot cause a half-initialised module at runtime. Dropping those edges shrinks the 361-file knot into seven smaller ones; the largest, 106 files, is entirely inside packages/excalidraw around App.tsx. In madge that takes a .madgerc:

json
{ "detectiveOptions": { "ts": { "skipTypeImports": true }, "tsx": { "skipTypeImports": true } } }

In dependency-cruiser it is one line in the rule, viaOnly: { dependencyTypesNot: ['type-only'] }. Our circular dependency guide covers both settings on a small fixture.

Speed

dependency-cruiser was about twice as fast as madge on a warm run: 3.4 to 3.7 seconds against 6.7 to 7.2. The cold first runs took 11.8 seconds for dependency-cruiser and 14.2 for madge; we did not profile what the first run spends its time on.

RunMadge 8.0.0dependency-cruiser 18.7.0
First run (cold)14.2 s11.8 s
Warm runs6.7, 7.0, 7.2 s3.6, 3.6, 3.6 s
Warm, cache offn/a3.4, 3.7 s
Files in the graph9891,070 modules
Edges4,0704,820 dependencies

Scroll the table sideways to see every column.

Madge's graph also holds files dependency-cruiser leaves out: its 989 nodes include 80 SCSS files and 230 font and image assets, and we did not test how much time they cost. Its 28 "skipped" warnings are SCSS keyframe names such as Toast-fade-in that its Sass detective read as imports. dependency-cruiser also has --cache, which we left off because the generated config does not turn it on; with the cache on, unchanged files are skipped on the next run.

At 3.6 and 7 seconds warm, either fits in a pull request check; a fresh CI runner pays the cold figure (11.8 and 14.2 seconds).

Defaults and CI behaviour

Madge fails the build on a cycle out of the box; dependency-cruiser's generated config only warns. madge --circular exited with status 1 on Excalidraw. The no-circular rule that depcruise --init writes has severity: 'warn', so cycles print and the job passes until you change it to error.

The generated dependency-cruiser config also assumes one package.json. In this workspace it raised 97 no-non-package-json errors, such as excalidraw-app/App.tsx importing clsx, which is declared in packages/excalidraw/package.json and only reachable through hoisting. Setting combinedDependencies: true (look up the tree for every package.json) cut that rule from 97 errors to 25, so 72 came from the single-package assumption and 25 are undeclared dependencies worth a look.

After three edits (cycles as errors, type-only edges ignored, combined dependencies) the run reported:

text
x 529 dependency violations (318 errors, 211 warnings). 1070 modules, 4820 dependencies cruised.

The shell then reported exit status 62. dependency-cruiser sets its exit code to the number of errors, and on POSIX shells exit statuses are 8 bits, so 318 wraps to 62; exactly 256 errors would exit 0. Read the summary line, not only the exit code.

The baseline is what makes dependency-cruiser adoptable on an existing codebase. You record today's violations, then fail only on new ones:

bash
npx depcruise-baseline packages excalidraw-app --config .dependency-cruiser.js
npx depcruise packages excalidraw-app --config .dependency-cruiser.js --ignore-known
text
✔ no dependency violations found (1070 modules, 4820 dependencies cruised)
‼ 529 known violations ignored. Run with --no-ignore-known to see them.

Madge has no baseline flag. On a repository with 127 runtime cycles, a madge gate is red from day one, so a gate on an existing codebase needs a wrapper script that diffs against a saved list.

Rules: where dependency-cruiser pulls ahead

dependency-cruiser enforces architectural boundaries; madge cannot. The cycles above are a symptom. The cause is lower layers importing from the UI package, and that is a rule you can write down:

js
// .dependency-cruiser.js (excerpt)
{
  name: 'base-packages-stay-below-ui',
  severity: 'error',
  from: { path: '^packages/(common|math|element)/' },
  to: { path: '^packages/excalidraw/' }
}

On Excalidraw this rule found 132 violating imports: 123 from packages/element, 7 from packages/common and 2 from packages/math. Forty-six of them point at one file, packages/excalidraw/types.ts, and 49 are type-only. Another 45 point at three UI test helpers (tests/helpers/api.ts 22, tests/test-utils.ts 12, tests/helpers/ui.ts 11), so the lower packages' tests depend on the UI package's test kit. That points at two fixes, moving shared types into a lower package and splitting the UI test helpers out of the element tests, and the rule names every import involved by file.

Rules can also match dependency types (npm-dev, type-only, dynamic-import), orphans, deprecated core modules and reachability, and --affected main limits a run to files changed since a branch. Nx's @nx/enforce-module-boundaries is configured with project tags, so it works at the level of Nx projects.

Graph output

Both tools draw graphs; dependency-cruiser has far more formats. Madge writes an image through Graphviz (--image graph.svg), DOT or JSON, and --depends <file> lists what imports a module. dependency-cruiser's -T option covers dot, ddot (folder level), archi (high-level), mermaid, d2, flat, text, json and HTML reports, and --focus or --reaches trims the graph to the neighbourhood of a file.

For the commands behind each format, see five ways to generate a JavaScript dependency graph.

Maintenance status

As of October 29, 2026, madge's latest release is 8.0.0 from August 5, 2024, about 26 months earlier. The repository is not archived and last saw a push in January 2026, and 8.0.0 ran cleanly on TypeScript 5.9 with path aliases. dependency-cruiser is on 18.7.0, its repository had commits three days before our run, and its engines field requires Node 22, 24 or 26. If you are still on Node 20, pin an older dependency-cruiser major or upgrade Node first.

Alternatives worth knowing

Three other tools cover part of the same ground:

  • Knip is a dead-code tool, but it now has a cycles issue type, off by default. knip --include cycles listed 130 circular dependencies on Excalidraw in 3.3 seconds. We did not check how it counts them, so do not read 130 against madge's 127 as like for like. Our Knip vs ts-prune vs unimported test covers the rest of Knip on the same checkout.
  • dpdm is a circular-dependency CLI with -T to drop type-only imports and --exit-code circular:1 for CI. Version 4.3.0 shipped in July 2026. We did not run it for this post.
  • Nx draws the project graph and enforces boundaries between projects through its lint rule, if your monorepo already runs on Nx.

The dependency graph tools roundup compares these at the package level as well.

Where repowise fits

A 361-file tangle is too big to untangle in one pass, so the next question is where to start. repowise builds a file and symbol graph for 28 languages, joins it with git history, and scores every file for defect risk, with no LLM key.

We indexed the same Excalidraw commit with repowise init, which took 160 seconds. It rated 47 files "At risk" (a health score under 4 out of 10, from complexity, size and change history). The 361-file tangle holds about half of the 675 TypeScript files, yet 43 of those 47 sit inside it. With type-only edges dropped, the 106-file runtime tangle contains 13 of them, a shorter list to start from. packages/element/src/binding.ts is a good example: it is in the runtime cycle, 20 files import it, and repowise flags it as a hotspot with 9 bug-fix commits in the last six months.

bash
repowise init
repowise context packages/element/src/binding.ts

Start by cutting the back-imports that touch files like that one, and leave the quiet parts of the tangle for later. This is one repository at one commit, so treat the ranking as a starting order, not a measured result. The dependency-cruiser rule above remains the CI gate.

How to choose

  • Pick madge if you want to know whether you have cycles today, you want an image for a design discussion, or you are adding a first gate to a codebase that is already clean.
  • Pick dependency-cruiser if you want CI to enforce layering, you have existing violations and need a baseline, or you need to separate type-only and dev-only imports from runtime ones.
  • Use both if developers like madge's one-liner locally and CI runs dependency-cruiser's rules. Expect different counts, and compare components, not totals.
  • Tangle too large to cut at once: rank its files by change history and bug fixes first; repowise does this from the same commit.

Try it on your repo

Run madge with --circular, and dependency-cruiser after npx depcruise --init yes, against the same folder. Then switch no-circular to error, add viaOnly: { dependencyTypesNot: ['type-only'] }, and record a baseline. To see the history side for a public repository without installing anything, paste its GitHub URL on the repowise homepage.

How we measured

  • Repository: excalidraw/excalidraw at e7b534668a60add7f725531f3c9abf32508c7b4e (October 28, 2026), dependencies installed with yarn install --frozen-lockfile --ignore-scripts, scanned paths packages and excalidraw-app.
  • Versions: madge 8.0.0, dependency-cruiser 18.7.0, Knip 6.43.0, TypeScript 5.9.3, Node 22.19.0, repowise 0.59.0, on Windows 11 (Git Bash), AMD Ryzen 9 8940HX, 31 GB RAM, on October 29, 2026.
  • Timing: wall clock per run. The first run of each tool is reported as cold; warm figures are three consecutive runs.
  • Components: we loaded each tool's JSON graph (madge --json, dependency-cruiser -T json, local modules only) and ran Tarjan's strongly connected components algorithm. For the runtime-only row we dropped edges marked type-only (dependency-cruiser) or re-ran madge with skipTypeImports.
  • Release dates and licences come from the npm registry and each project's GitHub repository on the same day. What this does not show: behaviour on Yarn PnP or pnpm's strict layout, or speed on a repository ten times larger.

FAQ

Which is better, madge or dependency-cruiser?
For a one-off check or a picture, madge is quicker to start. For CI, dependency-cruiser: it enforces custom rules, separates type-only and dev imports, and supports a baseline of known violations. On Excalidraw it was also faster warm, 3.6 seconds against 7.
Does Knip replace madge for circular dependencies?
For a list of cycles, it can. Knip has a cycles issue type, off by default, and knip --include cycles reported 130 on Excalidraw in 3.3 seconds. It does not draw a graph image or enforce boundary rules, so it replaces madge's list, not dependency-cruiser.
Why does madge report so many circular dependencies?
It lists cycle paths, and barrel files multiply them: every module re-exported through an index.ts adds paths through the same loop. On Excalidraw, 839 listed cycles reduced to three strongly connected components. Skipping type-only imports cut the list to 127.
Can dependency-cruiser ignore type-only imports in cycles?
Yes. Add viaOnly: { dependencyTypesNot: ['type-only'] } to the to block of the no-circular rule. Madge does the same with skipTypeImports: true in the TypeScript detective options of .madgerc.
How do I fail CI only on new circular dependencies?
Use dependency-cruiser's baseline: run depcruise-baseline once to write the known violations file, then run depcruise --ignore-known in CI. Existing cycles are reported as known and new ones fail the build.