Skip to main content
Linters enforce style. Formatters enforce consistency. Fallow enforces relevance.
  • Formatter: “Does this file look consistent?”
  • Linter: “Does this file follow the rules?”
  • Fallow: “Is this codebase structurally sound?”
This page explains the conceptual difference, what each tool category finds, and how to combine them in CI.

Three pillars of code quality

Formatter

Prettier, Biome, dprint. Enforces consistent formatting across all files — indentation, quotes, trailing commas. Works on a single file. No understanding of imports or project structure.

Linter

ESLint, Biome, oxlint. Enforces coding rules and detects local code smells — undefined variables, missing dependencies in useEffect, suspicious patterns. Works on a single file, with optional type context from tsc.

Codebase analyzer

Fallow. Finds issues that only exist at the project level — unused files, orphaned exports, circular dependencies, dependency graph violations. Requires building and traversing the full module graph.
The key distinction: linters read files. Fallow builds the import graph across the entire project and reasons about relationships between modules.

What each tool finds

IssueESLintBiomeoxlintFallow
Unused variable within a fileYesYesYesNo
Missing useEffect dependencyYesYesYesNo
Suspicious == vs ===YesYesYesNo
Code style violationsYesYesYesNo
Exported symbol never importedSlow [1]NoNoYes
File not reachable from entry pointNoNoNoYes
Circular import chain across N filesSlow [2]Yes [2]Yes [2]Yes
Package in deps but never importedNo [3]No [3]NoYes
Import of package not in package.jsonYesYesNoYes
Architecture boundary enforcementYes [4]Limited [4]NoYes
Duplicated logic blocks across modulesNoNoNoYes
Per-function cyclomatic complexityYesNoYesYes
Per-function cognitive complexityNoYesNoYes
Complexity + git churn hotspot correlationNoNoNoYes
[1] Unused exports. eslint-plugin-import/no-unused-modules rebuilds the full import graph on every lint run. On projects with 20-30K LOC this adds ~320 seconds to total lint time (#1487). Produces incorrect results with import { name as alias } (#1339). Does not follow barrel file re-export chains. Neither Biome nor oxlint have an equivalent rule. [2] Circular imports. ESLint: eslint-plugin-import/no-cycle takes 45-305 seconds on large projects with known OOM failures (#2348, #2076). Teams commonly set maxDepth: 1 to stay fast, missing longer cycles. Only covers ES import, not require(). Biome v2: noImportCycles is Rust-based and graph-aware, but Biome documents it as computationally expensive. oxlint: no-cycle in Rust, faster than the JS version. Fallow: builds and caches the graph once, no depth cap, covers both import and require(), completes in under a second. [3] Unused vs unlisted deps. no-extraneous-dependencies (ESLint) and noUndeclaredDependencies (Biome) catch imports of packages missing from package.json. That is the opposite problem. No linter detects packages listed in package.json that are never imported. Fallow finds both. [4] Boundaries. ESLint: eslint-plugin-boundaries enforces import rules between element types with capture groups and templates. no-restricted-paths enforces directory-level restrictions but requires enumerating every forbidden pair. Neither follows re-export chains, so a boundary can be bypassed via a barrel file. Biome: noRestrictedImports accepts plain string paths only, no globs or patterns. oxlint: no boundary rules. Fallow: zone presets (bulletproof, layered, hexagonal, feature-sliced) with re-export chain tracking.

Detailed comparison

Unused exports

eslint-plugin-import/no-unused-modules finds exported symbols that no other file imports. It works, but with three practical problems: Performance. The rule rebuilds the full module graph from scratch on each lint run. On a 20-30K LOC project, this adds ~320 seconds to total lint time (#1487). Most teams that enable it disable it within weeks. Correctness. The rule produces incorrect results with import { name as alias } (#1339). The aliased import is not recognized as a usage of the original export, so the export is reported as unused even when it has consumers. Re-export chains. no-unused-modules does not follow re-exports. If components/index.ts re-exports from components/Button.ts, and an outside file imports from components/index.ts, the export in Button.ts may still be flagged as unused. Fallow builds a persistent module graph, resolves re-export chains iteratively, and handles TypeScript path aliases. The full unused export analysis runs in under a second.
If you’re building a library, your public API exports are intentionally consumed externally. Mark them with /** @public */ JSDoc tags or configure your entry points so fallow doesn’t flag them.
fallow dead-code --unused-exports
● Unused exports (3)
  src/components/Card/index.ts
    :1  CardFooter
  src/utils/format.ts
    :14 formatCurrency
    :28 formatDate

Unused files

no-unused-modules detects exports with no importers, but does not detect files that are never imported at all. A file with no exports and no importers (an orphaned utility, a deleted component’s leftover test file) does not trigger any ESLint rule. Detecting unreachable files requires starting from entry points and walking the full import graph. ESLint has no concept of entry points. It lints files; it does not traverse graphs from roots. Fallow reads entry points from package.json fields and framework conventions (Next.js pages, Vite entry, etc.), then marks every file not reachable from those roots.

Circular dependencies

src/auth.ts → src/user.ts → src/permissions.ts → src/auth.ts
Not all cycles are bugs. Dependency injection frameworks (NestJS, InversifyJS) create intentional cycles. Use // fallow-ignore-next-line circular-dep to suppress them, the same way you’d use an ESLint disable comment. No tool automatically distinguishes intentional from accidental cycles. Both eslint-plugin-import/no-cycle and Biome v2’s noImportCycles detect circular imports. The difference is performance and search depth, not detection. no-cycle rebuilds the module graph on every run. On larger projects it takes 45-305 seconds and has caused out-of-memory failures (#2348). Teams commonly set maxDepth: 1 to keep lint times tolerable, which means cycles longer than two hops go undetected. The rule only covers ES import statements, not require(). Biome v2’s noImportCycles is Rust-based and faster, but Biome’s own documentation flags it as computationally expensive. oxlint has no-cycle in Rust, faster than the JS version but with less coverage of edge cases. Fallow builds the module graph once (cached across runs), checks for cycles without a depth cap, and covers both import and require(). On projects where no-cycle takes minutes, fallow completes in under a second.

Architecture boundary violations

Two ESLint plugins enforce import boundaries: eslint-plugin-boundaries and eslint-plugin-import/no-restricted-paths. eslint-plugin-boundaries defines element types for your codebase (feature, shared, core) and enforces rules between them. It supports capture groups and templates. The limitation: it enforces per file with no understanding of re-export chains. A boundary rule saying “features cannot import from other features” can be satisfied on paper while violated in practice through a barrel file re-exporting across the boundary. no-restricted-paths enforces directory-level restrictions. It requires enumerating every forbidden path pair, has no glob support, and does not scale to large modular architectures. Fallow tracks re-export chains when evaluating boundaries. If shared/index.ts re-exports a symbol from core/, an import of that symbol is attributed to core/, not shared/, when checking boundary rules. Zone presets (bulletproof, layered, hexagonal, feature-sliced) cover common architecture patterns. Zones use glob patterns, and rules are expressed as allowlists (“this zone can only import from these zones”) rather than enumerating forbidden paths.
{
  "boundaries": {
    "zones": [
      { "name": "ui", "include": ["src/components/**"] },
      { "name": "api", "include": ["src/api/**"] }
    ],
    "rules": [
      { "from": "ui", "disallow": ["api"] }
    ]
  }
}
Run all three in parallel. Some linter plugins (no-cycle, no-unused-modules) overlap with fallow on a subset of issues. If you already have those enabled, fallow covers the same ground faster and adds the issues linters cannot detect (unused files, unused deps, duplication, churn hotspots). If you don’t have them enabled, fallow covers everything in one pass:
jobs:
  format:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: npx prettier --check .          # or: npx biome check --only=format

  lint:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: npx eslint .                    # or: npx biome lint / npx oxlint

  analyze:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: fallow-rs/fallow@v2
Fallow’s GitHub Action automatically uploads SARIF results to GitHub Code Scanning, giving you inline annotations on PR diffs. Linter results typically appear via separate status checks or linter-review actions.

What to use when

SituationTool
Variable used in wrong scopeLinter
Missing React hook dependencyLinter
Inconsistent code styleFormatter
Exported function nobody importsFallow
File that was left behind after a refactorFallow
lodash in package.json but never importedFallow
8-file circular dependency chainFallow
Feature module importing directly from core internalsFallow
Growing codebase, want to prevent new dead code in PRsFallow (--changed-since)
Library export consumed by external consumersFallow (configure entry points or /** @public */)
Existing codebase already has unused exports and files? Use --save-baseline to snapshot current issues, then run CI with --baseline to fail only on regressions. See the CI integration guide for incremental adoption.

Summary

None of them replaces the others. A codebase that passes all three is formatted, linted, and free of dead weight.
This page compares fallow to linters. For a comparison with knip (which operates at the same graph level as fallow), see Fallow vs Knip.

Quick Start

Add fallow to your project in under a minute.

CI Integration

Full guide for GitHub Actions, GitLab CI, and other pipelines.

Dead code explained

All 15 issue types fallow detects, with examples.

Configuration

Entry points, ignore patterns, severity rules.