Design.ImportCycle
Flags circular import chains in the project graph. Cycles cause initialization-order surprises, complicate refactoring, and usually point at modules that should be split.
Why
When file A imports B and B imports A (directly or through any chain), the runtime has to choose which module is "half-loaded" while resolving the other. The result is a class of bugs where exports appear undefined at first call, work later, and disappear again under HMR. Static analysis can find these cleanly — there's no reason to wait for them to bite.
What gets flagged
- Direct cycles:
a.ts → b.ts → a.ts(two-file cycle). - Longer cycles:
a → b → c → … → aof any length. - Self-imports: a file importing itself (degenerate but always wrong).
Each cycle produces one finding, anchored at the alphabetically-lowest member's first import edge into the cycle. Other members appear in the finding's related spans so editors and the SARIF formatter can highlight every participant.
What's not flagged
- Cycles that exist only via
import type { … }edges. TypeScript erases type-only imports during compilation, so they cause no runtime cycle. Flipignore_type_only = falsein config to see them anyway. - Edges that leave the project graph (anything resolving into
node_modules, or unresolved bare specifiers). The check is in-project-only by design.
Configuration
[checks."Design.ImportCycle"]
ignore_type_only = trueLimitations
- Dynamic imports (
import('./m')) participate in the cycle detection — they're real edges. If your code uses dynamic imports specifically to break a static cycle, you'll still get a finding; suppress with the inline directive at the import site. require()is not tracked, so CommonJS-only cycles slip through.- Re-export forwarders (
export { x } from './y') ARE edges because the re-exporter performs the import; if barrel files form a cycle, that shows up here.