Skip to main content

What It Does

The circular dependencies check analyzes your project’s import/require statements to detect circular dependency chains — a common source of bugs, initialization issues, and bundling problems. Key Features:
  • Walks your source directory to build a dependency graph
  • Detects cycles using depth-first search (DFS)
  • Resolves relative imports (./ ../) to absolute paths
  • Supports TypeScript and JavaScript projects
  • Shows up to 3 cycles to keep output readable
  • Ignores node_modules and build directories
Source: /src/checks/circular.ts

What It Checks

The check follows this process:
  1. Determine File Extensions:
    • Checks for tsconfig.json to detect TypeScript projects
    • TypeScript: .ts, .tsx, .js, .jsx, .mjs
    • JavaScript: .js, .jsx, .mjs, .cjs
  2. Find Source Directory:
    • Prefers src/ directory if it exists
    • Falls back to current working directory
  3. Walk Directory Tree:
    • Recursively scans for matching file extensions
    • Skips: node_modules, .git, dist, build, .next, coverage
  4. Extract Imports:
    • Parses ES6 imports: import foo from './bar'
    • Parses CommonJS: require('./bar')
    • Ignores type-only imports: import type { Foo } from './bar'
    • Skips external packages (non-relative imports)
  5. Build Dependency Graph:
    • Resolves relative paths to absolute paths
    • Tries extensions and index files
    • Creates adjacency list representation
  6. Find Cycles:
    • Uses DFS with stack tracking
    • Reports all detected cycles
    • Limits output to first 3 cycles
/src/checks/circular.ts:152-224
export async function checkCircular(): Promise<CheckResult> {
  const cwd = process.cwd();

  // Determine if it's a TS or JS project
  const isTs = fs.existsSync(path.join(cwd, "tsconfig.json"));
  const extensions = isTs
    ? [".ts", ".tsx", ".js", ".jsx", ".mjs"]
    : [".js", ".jsx", ".mjs", ".cjs"];

  // Find src dir or fall back to cwd
  const srcDir = fs.existsSync(path.join(cwd, "src"))
    ? path.join(cwd, "src")
    : cwd;

  const files = walkDirectory(srcDir, extensions);

  if (files.length === 0) {
    return {
      checkName: "circular",
      status: "skip",
      messages: [
        {
          level: "info",
          text: `No ${extensions.join("/")} files found to analyze`,
        },
      ],
    };
  }

  const graph = buildGraph(files, extensions);
  const cycles = findCycles(graph);

  if (cycles.length === 0) {
    return {
      checkName: "circular",
      status: "pass",
      messages: [
        {
          level: "info",
          text: `No circular dependencies found across ${files.length} files`,
        },
      ],
    };
  }

  const messages: CheckResult["messages"] = [
    {
      level: "error",
      text: `Found ${cycles.length} circular dependency chain(s)`,
    },
  ];

  // Show up to 3 cycles to keep output readable
  for (const cycle of cycles.slice(0, 3)) {
    messages.push({
      level: "warn",
      text: cycle.map(relativize).join(" → "),
    });
  }

  if (cycles.length > 3) {
    messages.push({
      level: "info",
      text: `...and ${cycles.length - 3} more. Fix the above first.`,
    });
  }

  return {
    checkName: "circular",
    status: "fail",
    messages,
  };
}

Example Output

 circular No circular dependencies found across 142 files

Why It Matters

Runtime & Build ProblemsCircular dependencies cause:
  • Undefined exports at runtime (variables accessed before initialization)
  • Bundler failures or warnings in Webpack/Rollup/Vite
  • Tree-shaking issues — prevents dead code elimination
  • Test failures due to module loading order
  • Memory leaks in module caching
  • Hard-to-debug errors like Cannot access 'X' before initialization

How to Fix

1. Identify the Cycle

Look at the cycle path from the check output:
src/auth/index.ts → src/auth/user.ts → src/auth/session.ts → src/auth/index.ts
This means:
  • index.ts imports from user.ts
  • user.ts imports from session.ts
  • session.ts imports back to index.tsThis is the problem

2. Break the Cycle

Common strategies:
Move shared code to a new file:Before:
src/auth/index.ts
import { getUser } from './user';
export const SESSION_KEY = 'auth_session';
src/auth/user.ts
import { SESSION_KEY } from './index'; // ← Circular!
After:
src/auth/constants.ts
export const SESSION_KEY = 'auth_session';
src/auth/index.ts
import { getUser } from './user';
export { SESSION_KEY } from './constants';
src/auth/user.ts
import { SESSION_KEY } from './constants'; // ✓ No cycle

3. Verify the Fix

stackprobe audit --only circular
Expected output:
✓ circular — No circular dependencies found across 142 files

Import Resolution

The check resolves imports using this logic:
/src/checks/circular.ts:10-34
function resolveImport(
  fromFile: string,
  importPath: string,
  extensions: string[],
): string | null {
  if (!importPath.startsWith(".")) return null; // skip node_modules

  const base = path.resolve(path.dirname(fromFile), importPath);

  // Try exact path
  if (fs.existsSync(base)) return base;

  // Try with extensions
  for (const ext of extensions) {
    if (fs.existsSync(base + ext)) return base + ext;
  }

  // Try index file
  for (const ext of extensions) {
    const indexPath = path.join(base, `index${ext}`);
    if (fs.existsSync(indexPath)) return indexPath;
  }

  return null;
}
Resolution order:
  1. Exact path: ./foo./foo (if it exists)
  2. With extension: ./foo./foo.ts
  3. Index file: ./foo./foo/index.ts

Ignored Import Types

The check ignores:
  • Type-only imports (erased at runtime):
    import type { User } from './user'; // ← Ignored
    import { type User, getData } from './user'; // getData is checked
    
  • External packages (from node_modules):
    import React from 'react'; // ← Ignored
    import express from 'express'; // ← Ignored
    
  • Built-in modules:
    import fs from 'fs'; // ← Ignored
    import path from 'path'; // ← Ignored
    
See /src/checks/circular.ts:36-59 for import extraction logic.

Performance Considerations

The check is optimized for large codebases:
  • Skips common build directories: node_modules, dist, build, .next, coverage
  • Limits output: Shows only first 3 cycles
  • Fast parsing: Uses regex instead of full AST parsing
  • Caches file reads: Only reads each file once
Typical performance:
  • 100 files: ~100ms
  • 500 files: ~400ms
  • 1000+ files: ~1s

Advanced: Using Madge

For deeper analysis, the check mentions using madge (though not currently implemented):
# Install madge for visualization
npm install -g madge

# Generate circular dependency graph
madge --circular --extensions ts,tsx src/

# Create visual diagram
madge --circular --image graph.svg src/
See /src/checks/circular.ts:5-6 for future integration notes.

Common Patterns to Avoid

Problem:
src/utils/index.ts
export * from './format';
export * from './helpers';
src/utils/format.ts
import { sanitize } from './index'; // ← Circular!
Solution: Import directly from sibling files:
src/utils/format.ts
import { sanitize } from './helpers'; // ✓ Direct import
Problem:
class User { posts: Post[] }
class Post { author: User }
Solution: Use IDs instead of direct references:
class User { id: string; posts: string[] } // Post IDs
class Post { id: string; authorId: string }
Problem:
src/store/index.ts
import { userReducer } from './user';
src/store/user.ts
import { store } from './index'; // ← Circular!
Solution: Use context or dependency injection:
src/store/user.ts
export function userReducer(state, action) { ... }
// Don't import store here

Configuration

To disable this check:
stackprobe.config.json
{
  "ignore": ["circular"]
}
Or run it exclusively:
stackprobe audit --only circular

Next Steps

Check Overview

Learn about all available checks

Configuration

Customize check behavior

Build docs developers (and LLMs) love