Linters check files. TypeScript checks types. Fallow checks the codebase.
What fallow provides is graph-level codebase intelligence: static by default, with an optional runtime intelligence layer (see 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.
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.
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.
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.
| 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). 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().
[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.
Fallow builds a persistent module graph, resolves re-export chains iteratively, and handles TypeScript path aliases.
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 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
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.
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.
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@v3
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. For GitLab CI and other runners, see CI integration.
| 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) |
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.
For a comparison with knip, which operates at the same graph level as fallow, see Fallow vs Knip.