Skip to content
Fallow home
All docs pages

Fallow vs linters

Find the issues your linter cannot see, and decide which tool runs which check in CI. Linters such as oxlint, ESLint, and Biome check one file at a time. Fallow checks the project graph, so you run both.

Linters check files. TypeScript checks types. Fallow checks the codebase, so it finds project-level issues that a linter misses. Use this page to see which tool finds which issue, and how to run them together in CI.

Each tool answers a different question:

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

Fallow analyzes the module graph of the whole project. The analysis is static by default. Teams that want production evidence for dead code decisions can add Fallow Cloud, the optional paid product that adds production data (see static analysis vs production data).

Three pillars of code quality

Formatter

oxfmt (recommended), or Prettier, Biome, dprint. Makes formatting consistent in all files, for example indentation, quotes, and trailing commas. Works on one file at a time and does not know about imports or project structure.

Linter

oxlint (recommended), or ESLint, Biome. Enforces coding rules and finds local code smells, for example undefined variables, missing dependencies in useEffect, and suspicious patterns. Works on one file at a time, with optional type context from tsc.

Codebase intelligence

Fallow. Finds issues that exist only at the project level, for example unused files, orphaned exports, circular dependencies, and dependency graph violations. To find them, fallow builds and walks the full module graph.

Linters read files. Fallow builds the import graph of the whole project and checks the 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). The rule gives incorrect results with import { name as alias } (#1339), and it does not follow barrel file re-export chains. Biome and oxlint have no equivalent rule.

[2] Circular imports.

  • ESLint: eslint-plugin-import/no-cycle takes 45-305 seconds on large projects and has known OOM failures (#2348, #2076). Teams often set maxDepth: 1 to keep it fast, and then miss longer cycles. The rule covers only ES import, not require().
  • Biome v2: noImportCycles is written in Rust and is graph-aware, but Biome documents it as computationally expensive.
  • oxlint: no-cycle is written in Rust and is faster than the JS version.
  • Fallow: builds and caches the graph once, has no depth cap, and covers both import and require().

[3] Unused vs unlisted deps. no-extraneous-dependencies (ESLint) and noUndeclaredDependencies (Biome) find imports of packages that are missing from package.json. That is the opposite problem. No linter finds packages that package.json lists but no file imports. 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 you must list every forbidden pair. Neither rule follows re-export chains, so an import through a barrel file can bypass a boundary.
  • Biome: noRestrictedImports accepts plain string paths only. It has no globs or patterns.
  • oxlint: has no boundary rules.
  • Fallow: has zone presets (bulletproof, layered, hexagonal, feature-sliced) and follows re-export chains.

Detailed comparison

Unused exports

Fallow builds a persistent module graph, resolves re-export chains iteratively, and resolves TypeScript path aliases.

In a library, external code imports your public API exports on purpose. To stop fallow from flagging them, mark them with the /** @public */ JSDoc tag (or @internal, @beta, @alpha), or configure your entry points.

fallow dead-code --unused-exports
● Unused exports (3)
  src/utils/format.ts (2)
    :14 formatCurrency
    :28 formatDate
  src/components/Card/index.ts
    :1 CardFooter

Unused files

no-unused-modules finds exports with no importers. It does not find files that no file imports. A file with no exports and no importers does not trigger any ESLint rule. Examples are an orphaned utility, or the leftover test file of a deleted component.

To find unreachable files, a tool must start from the entry points and walk the full import graph. ESLint has no concept of entry points. It lints files, and it does not walk a graph from its roots. Fallow reads entry points from package.json fields and framework conventions (Next.js pages, Vite entry, etc.). Then it marks every file that those roots do not reach.

Circular dependencies

src/auth.ts → src/user.ts → src/permissions.ts → src/auth.ts

Some cycles are not bugs. Dependency injection frameworks (NestJS, InversifyJS) create intentional cycles. To suppress one, use // fallow-ignore-next-line circular-dependency, the same way you use an ESLint disable comment. No tool automatically tells an intentional cycle from an accidental one.

Architecture boundary violations

Fallow follows re-export chains when it checks boundaries. If shared/index.ts re-exports a symbol from core/, fallow treats a named or default import of that symbol as an import from core/, not shared/. The finding names the barrel in via_path. Zone presets (bulletproof, layered, hexagonal, feature-sliced) cover common architecture patterns. Zones use glob patterns. You write rules as allowlists ("this zone can only import from these zones"), so you do not have to list every forbidden path.

{
  "boundaries": {
    "zones": [
      { "name": "ui", "patterns": ["src/components/**"] },
      { "name": "api", "patterns": ["src/api/**"] },
      { "name": "shared", "patterns": ["src/shared/**"] }
    ],
    "rules": [
      { "from": "ui", "allow": ["shared"] }
    ]
  }
}

With this config, a ui file can import from shared, and fallow reports an import from ui into api as a boundary violation.

Run the formatter, the linter, and fallow in parallel. The example uses oxfmt and oxlint, which we recommend. They are fast, and they use the same Oxc parser as fallow. Some linter plugins (no-cycle, no-unused-modules) find a subset of the issues that fallow finds. If you already use those plugins, fallow finds the same issues faster. It also finds the issues that linters cannot find: unused files, unused deps, duplication, and churn hotspots. If you do not use those plugins, fallow covers all of these in one pass:

jobs:
  format:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: npx oxfmt --check .

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

  analyze:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: fallow-rs/fallow@v3

With the sarif: true input and the security-events: write permission, the fallow GitHub Action uploads SARIF results to GitHub Code Scanning, so you get inline annotations on PR diffs. Linter results usually show up as separate status checks or linter-review actions. For GitLab CI and other runners, see CI integration.

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 */ / @internal / @beta / @alpha)

If your codebase already has unused exports and files, use --save-baseline to save the current issues. Then run CI with --baseline so that it fails only on new issues. For incremental adoption, see the CI integration guide.

See also