Cyclic Module Dependencies

ID

javascript.cyclic_package_dependencies

Severity

high

Remediation Complexity

hard

Remediation Risk

medium

Remediation Effort

high

Resource

Reliability

Language

JavaScript

Tags

code-style, reliability

Description

Reports cycles in the directed graph of in-project module imports. An edge A → B is established when module A contains an import statement whose target resolves to module B on disk. When two or more modules mutually depend on each other (directly or transitively), they form a cycle.

// alpha.js
import { thing } from "./beta";   // alpha -> beta

// beta.js
import { other } from "./alpha";  // beta -> alpha   (cycle!)

Rationale

Cyclic imports between modules create real runtime hazards in JavaScript:

  • whichever module finishes evaluating first sees the other as a partially-initialised object — undefined exports, missing functions, or stale class references — depending on which side `import`s first,

  • code splitters and bundlers bundle the entire cycle into a single chunk, defeating tree-shaking,

  • unit tests cannot exercise either side in isolation,

  • refactors that split or move a module ripple through the entire cycle.

Cycles often signal that two modules should be merged or that a shared abstraction should be extracted into a third module.

Remediation

Break the cycle by:

  1. Extracting an interface module — move the types or values both sides agree on into a separate module that both depend on.

  2. Inverting the dependency — have the lower-level module declare an abstraction the higher-level one implements.

  3. Merging modules — if two modules always change together, the cycle is evidence they were already one logical module.

// shared.js  (new shared module)
export const SHARED_KEY = "...";

// alpha.js  ->  shared.js  (no cycle)
// beta.js   ->  shared.js

Notes

The rule resolves only relative-path imports (./foo, ../bar); bare specifiers like lodash and scoped specifiers like @scope/pkg are external and ignored. Each scanned source file is treated as one node, identified by its canonical path with the source extension stripped — so import './foo' matches a scanned file at <dir>/foo.js, <dir>/foo.ts, etc. Imports that resolve to a directory’s index.* file are not auto-resolved (a known limitation for v1).

Mirrors java.cyclic_package_dependencies. The Tarjan SCC implementation is iterative and survives large module graphs without blowing the JVM call stack.