> ## Documentation Index
> Fetch the complete documentation index at: https://docs.fallow.tools/llms.txt
> Use this file to discover all available pages before exploring further.

# Fallow vs linters

> Why fallow and linters (ESLint, Biome, oxlint) are complementary, not competing. Linters work at the file level. Fallow is codebase intelligence that works at the project graph level. They find fundamentally different things.

Linters check files. TypeScript checks types. Fallow checks the codebase.

* **Formatter**: "Does this file look consistent?"
* **Linter**: "Does this file follow the rules?"
* **Fallow**: "Is this codebase structurally sound?"

A secondary framing we still use: linters enforce style, formatters enforce consistency, fallow enforces relevance.

What fallow provides is graph-level codebase intelligence: static by default, with an optional runtime intelligence layer (see [static vs runtime](/explanations/static-vs-runtime)) for teams that want to ground dead code decisions in production evidence.

This page explains the conceptual difference, what each tool category finds, and how to combine them in CI.

## Three pillars of code quality

<CardGroup cols={3}>
  <Card title="Formatter" icon="paintbrush">
    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.
  </Card>

  <Card title="Linter" icon="list-check">
    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`.
  </Card>

  <Card title="Codebase intelligence" icon="diagram-project">
    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.
  </Card>
</CardGroup>

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

| Issue                                      |   ESLint  |     Biome    |  oxlint  | Fallow |
| :----------------------------------------- | :-------: | :----------: | :------: | :----: |
| Unused variable within a file              |    Yes    |      Yes     |    Yes   |   No   |
| Missing `useEffect` dependency             |    Yes    |      Yes     |    Yes   |   No   |
| Suspicious `==` vs `===`                   |    Yes    |      Yes     |    Yes   |   No   |
| Code style violations                      |    Yes    |      Yes     |    Yes   |   No   |
| Exported symbol never imported             | Slow \[1] |      No      |    No    |   Yes  |
| File not reachable from entry point        |     No    |      No      |    No    |   Yes  |
| Circular import chain across N files       | Slow \[2] |   Yes \[2]   | Yes \[2] |   Yes  |
| Package in deps but never imported         |  No \[3]  |    No \[3]   |    No    |   Yes  |
| Import of package not in package.json      |    Yes    |      Yes     |    No    |   Yes  |
| Architecture boundary enforcement          |  Yes \[4] | Limited \[4] |    No    |   Yes  |
| Duplicated logic blocks across modules     |     No    |      No      |    No    |   Yes  |
| Per-function cyclomatic complexity         |    Yes    |      No      |    Yes   |   Yes  |
| Per-function cognitive complexity          |     No    |      Yes     |    No    |   Yes  |
| Complexity + git churn hotspot correlation |     No    |      No      |    No    |   Yes  |

**\[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](https://github.com/import-js/eslint-plugin-import/issues/1487)). Produces incorrect results with `import { name as alias }` ([#1339](https://github.com/import-js/eslint-plugin-import/issues/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](https://github.com/import-js/eslint-plugin-import/issues/2348), [#2076](https://github.com/import-js/eslint-plugin-import/issues/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](/configuration/boundaries) (`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](https://github.com/import-js/eslint-plugin-import/issues/1487)). Most teams that enable it disable it within weeks.

**Correctness.** The rule produces incorrect results with `import { name as alias }` ([#1339](https://github.com/import-js/eslint-plugin-import/issues/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 <Tooltip tip="Index files (typically index.ts) that re-export symbols from other modules for cleaner imports">barrel file</Tooltip> 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.

<Info>
  If you're building a library, your public API exports are intentionally consumed externally. Mark them with `/** @public */` (or `@internal`, `@beta`, `@alpha`) JSDoc tags or configure your [entry points](/configuration/overview#entry) so fallow doesn't flag them.
</Info>

```bash theme={null}
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](https://github.com/import-js/eslint-plugin-import/issues/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](/configuration/boundaries) (`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.

```jsonc theme={null}
{
  "boundaries": {
    "zones": [
      { "name": "ui", "include": ["src/components/**"] },
      { "name": "api", "include": ["src/api/**"] }
    ],
    "rules": [
      { "from": "ui", "disallow": ["api"] }
    ]
  }
}
```

## Recommended CI pipeline

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:

<Tabs>
  <Tab title="GitHub Actions">
    ```yaml theme={null}
    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
    ```
  </Tab>

  <Tab title="Single job">
    ```yaml theme={null}
    jobs:
      quality:
        runs-on: ubuntu-latest
        steps:
          - uses: actions/checkout@v4
          - run: npx prettier --check .
          - run: npx eslint .
          - run: npx fallow --ci
    ```
  </Tab>

  <Tab title="GitLab CI">
    ```yaml theme={null}
    include:
      - remote: 'https://raw.githubusercontent.com/fallow-rs/fallow/main/ci/gitlab-ci.yml'

    lint:
      script:
        - npx eslint .

    fallow:
      extends: .fallow
    ```
  </Tab>
</Tabs>

<Info>
  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.
</Info>

## What to use when

| Situation                                              | Tool                                                                                                                    |
| :----------------------------------------------------- | :---------------------------------------------------------------------------------------------------------------------- |
| Variable used in wrong scope                           | Linter                                                                                                                  |
| Missing React hook dependency                          | Linter                                                                                                                  |
| Inconsistent code style                                | Formatter                                                                                                               |
| Exported function nobody imports                       | Fallow                                                                                                                  |
| File that was left behind after a refactor             | Fallow                                                                                                                  |
| `lodash` in `package.json` but never imported          | Fallow                                                                                                                  |
| 8-file circular dependency chain                       | Fallow                                                                                                                  |
| Feature module importing directly from core internals  | Fallow                                                                                                                  |
| Growing codebase, want to prevent new dead code in PRs | Fallow (`--changed-since`)                                                                                              |
| Library export consumed by external consumers          | Fallow (configure [entry points](/configuration/overview#entry) or `/** @public */` / `@internal` / `@beta` / `@alpha`) |

<Tip>
  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](/integrations/ci) for incremental adoption.
</Tip>

## Summary

None of them replaces the others. A codebase that passes all three is formatted, linted, and free of dead weight.

<Info>
  This page compares fallow to linters. For a comparison with knip (which operates at the same graph level as fallow), see [Fallow vs Knip](/migration/comparison).
</Info>

<CardGroup cols={2}>
  <Card title="Quick Start" icon="rocket" href="/quickstart">
    Add fallow to your project in under a minute.
  </Card>

  <Card title="CI Integration" icon="shield-check" href="/integrations/ci">
    Full guide for GitHub Actions, GitLab CI, and other pipelines.
  </Card>

  <Card title="Dead code explained" icon="skull-crossbones" href="/explanations/dead-code">
    All 15 issue types fallow detects, with examples.
  </Card>

  <Card title="Configuration" icon="gear" href="/configuration/overview">
    Entry points, ignore patterns, severity rules.
  </Card>
</CardGroup>
