- Formatter: “Does this file look consistent?”
- Linter: “Does this file follow the rules?”
- Fallow: “Is this codebase structurally sound?”
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.
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 |
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.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
// 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.
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:
- GitHub Actions
- Single job
- GitLab CI
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
| 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 */) |
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.