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
| 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). 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-cycletakes 45-305 seconds on large projects and has known OOM failures (#2348, #2076). Teams often setmaxDepth: 1to keep it fast, and then miss longer cycles. The rule covers only ESimport, notrequire(). - Biome v2:
noImportCyclesis written in Rust and is graph-aware, but Biome documents it as computationally expensive. - oxlint:
no-cycleis written in Rust and is faster than the JS version. - Fallow: builds and caches the graph once, has no depth cap, and covers both
importandrequire().
[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-boundariesenforces import rules between element types with capture groups and templates.no-restricted-pathsenforces 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:
noRestrictedImportsaccepts 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.
Recommended CI pipeline
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
| 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 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.