Skip to main content
interlace
Plugin: import-nextRules

no-cycle

Detect circular dependencies that cause bundle memory bloat and initialization issues

💼 This rule is enabled in the following configs: recommended, typescript.

Rule Details

Reports an import whose target imports the current module back, directly or through other modules, by runtime edges. Such a cycle makes the module loader hand one module to another while it is still initializing (an undefined binding or a TDZ error), and keeps both modules in every bundle that loads either.

An edge is a runtime edge when it survives compilation:

  • value imports (default, named value, namespace), side-effect imports, export { x } from, export * from, export * as ns from, require() and TS import x = require() are runtime edges;
  • import type and export type { T } from are erased;
  • an inline import { type T } is erased, unless verbatimModuleSyntax is true (tsc then emits import {} from, which still loads the module);
  • a plain import { T } that takes only types (interfaces, type aliases) is elided by TypeScript, unless verbatimModuleSyntax is true;
  • import() loads lazily, after both modules have initialized, and does not form an initialization cycle.

Options:

  • ignorePatterns (default: test, spec and story files, __tests__, __mocks__): files that are not checked;
  • maxDepth (default unlimited): how many imports deep the edge back may be; 1 finds only A -> B -> A;
  • verbatimModuleSyntax: see above;
  • barrelExports, fixStrategy, moduleNamingConvention, coreModuleSuffix, extendedModuleSuffix shape the suggested fix in the message; reportAllCycles is accepted for compatibility. None of them changes what is reported.

Generated files (marked by a @generated header) are not checked.

// ❌ Incorrect
// a.ts
import { b } from './b';
export const a = () => b();
// b.ts
import { a } from './a';
export const b = () => a();

// ✅ Correct
// b.ts
import type { A } from './a'; // erased: no runtime edge
export const lazyA = () => import('./a');

Options

[
  {
    "type": "object",
    "properties": {
      "maxDepth": {
        "type": "number",
        "default": 9007199254740991,
        "description": "Maximum depth to traverse when detecting cycles (performance optimization)"
      },
      "ignorePatterns": {
        "type": "array",
        "items": {
          "type": "string"
        },
        "default": [
          "**/*.test.ts",
          "**/*.test.tsx",
          "**/*.spec.ts",
          "**/*.spec.tsx",
          "**/*.stories.tsx",
          "**/__tests__/**",
          "**/__mocks__/**"
        ],
        "description": "File patterns to ignore (glob patterns)"
      },
      "barrelExports": {
        "type": "array",
        "items": {
          "type": "string"
        },
        "default": [
          "index.ts",
          "index.tsx",
          "index.js",
          "index.jsx"
        ],
        "description": "Files considered barrel exports"
      },
      "reportAllCycles": {
        "type": "boolean",
        "default": true,
        "description": "Report all circular dependencies found (not just the first one)"
      },
      "verbatimModuleSyntax": {
        "type": "boolean",
        "default": false,
        "description": "Treat an inline `import { type Foo }` specifier as a runtime edge. Set this when the project compiles with TypeScript's `verbatimModuleSyntax`, under which the inline form is emitted as `import {} from './foo.js'` rather than erased — so the target module is still evaluated and the cycle is real. A statement-level `import type` is erased under every setting and stays skipped either way."
      },
      "fixStrategy": {
        "type": "string",
        "enum": [
          "module-split",
          "direct-import",
          "extract-shared",
          "dependency-injection",
          "auto"
        ],
        "default": "auto",
        "description": "Strategy for fixing circular dependencies (auto = smart detection)"
      },
      "moduleNamingConvention": {
        "type": "string",
        "enum": [
          "semantic",
          "numbered"
        ],
        "default": "semantic",
        "description": "Naming convention for split modules (semantic: .core, .api | numbered: .1, .2)"
      },
      "coreModuleSuffix": {
        "type": "string",
        "default": "core",
        "description": "Suffix for core module when splitting (e.g., \"core\", \"base\", \"main\")"
      },
      "extendedModuleSuffix": {
        "type": "string",
        "default": "extended",
        "description": "Suffix for extended module when splitting (e.g., \"extended\", \"api\", \"helpers\")"
      }
    },
    "additionalProperties": false
  }
]

Implementation

OWASP Foundation

  • Category: A05:2021 - Security Misconfiguration

Use this rule

npm install -D oxlint-plugin-import-next
.oxlintrc.json
{  "jsPlugins": ["oxlint-plugin-import-next"],  "rules": {    "import-next/no-cycle": "error"  }}
vite.config.ts
import { defineConfig } from 'vite-plus';export default defineConfig({  lint: {    jsPlugins: ['oxlint-plugin-import-next'],    rules: {      'import-next/no-cycle': 'error',    },  },});

oxlint-plugin-import-next re-exports eslint-plugin-import-next/oxlint: the same rules under the same IDs, and no ESLint install. Why oxlint.

Did this rule catch something? Star the repo to get new CWE coverage as we ship it — or follow the AI-code-security benchmarks behind these rules.