This page covers what fallow dead-code reports, how to prioritize findings, and when to act.
Pass --explain to any command with --format json to include issue type definitions directly in the JSON output as a _meta object. The MCP server always includes _meta automatically.
Issue types
Fallow reports multiple dead-code issue types. Each type has a default severity that determines whether it causes a non-zero exit code with --fail-on-issues.
Unused files
Files not reachable from any entry point. No module in the project imports them, and they are not matched by any framework plugin or entry point pattern.
When to act: Almost always. Unused files add to IDE indexing time and confuse search results.
Common false positives:
- Scripts run directly (e.g.,
node scripts/seed.ts): Add to entry in config, or ensure the script is in a package.json script field (fallow parses those).
- Files loaded by tools fallow doesn’t know about: Add to
entry or the relevant plugin config.
Unused exports
Exported symbols never imported by any other module. The export exists but nothing references it.
When to act: Most unused exports should be removed or unexported. They inflate the public API surface and confuse consumers about what the module does.
When it’s OK to keep:
- Library public API: Exports consumed by external users fallow can’t see. Mark them with
/** @public */ (or @internal, @beta, @alpha) JSDoc tags, add the library entry point to entry, or use ignoreExports for broad patterns.
- Convention-based exports: Exports consumed by frameworks via naming conventions (e.g., Next.js
getStaticProps). Fallow’s framework plugins handle most of these. If one is missed, configure the plugin or add an ignore pattern.
Unused types
Type aliases and interfaces never referenced by any module.
| Severity | Default |
|---|
| Warning | Yes |
When to act: Remove unused types. They add noise to auto-import suggestions and inflate the public type surface.
When it’s OK to keep:
- Shared type packages: Types exported for external consumers. Mark the package entry point in
entry.
Private type leaks
Exported values, functions, classes, and types whose public TypeScript signature references a same-file type declaration that is NOT itself exported. Consumers may see the private type’s name in generated declarations or API docs but cannot import it, so they end up retyping it or using Parameters<typeof X>[0] / ReturnType<typeof X> workarounds.
This is an opt-in API hygiene check, not a default dead-code finding. It is most useful for packages that publish TypeScript declarations or maintain a public API surface. For app-internal exports, private Props, Options, and State helper types are often intentional and should not be exported just to satisfy this rule.
Example:
type Props = { label: string; }; // private to the module
export function Component(p: Props): void {} // public, but Props leaks
When to act: Enable this check for publishable packages or declaration-emitting modules. For findings on that API surface, export the backing type alongside the exported symbol, or restructure the public signature so it doesn’t reference the private type:
export type Props = { label: string; };
export function Component(p: Props): void {}
Interaction with unused types: when you export the backing type to fix a leak, fallow does not turn around and report the new export as unused-types even if no other module imports it. Any type whose name is referenced from a public signature in the same file is treated as load-bearing for the public API and is excluded from the unused-types check.
Skipped patterns:
-
ECMAScript
#field private members and TypeScript private accessibility: these are not part of the public signature, so types they reference do not leak. class Service { #state: InternalState; } does not flag InternalState.
-
Storybook story files:
*.stories.{ts,tsx,js,jsx,mts,cts,mjs,cjs} and *.story.* variants are skipped because the canonical type Story = StoryObj<typeof X>; export const Default: Story = ... pattern would otherwise generate one finding per story.
-
Framework routing conventions: route files where the framework forces multiple exports per file to share a private
Props / Params / LoaderArgs type. Coverage:
- Next.js App Router:
app/**/{page,layout,template,loading,error,not-found,route,default,global-error,forbidden,unauthorized}.{ts,tsx,js,jsx} plus the metadata files (opengraph-image, twitter-image, icon, apple-icon, manifest, sitemap, robots).
- Next.js Pages Router: any file under
pages/.
- Gatsby: pages and templates.
- Remix v2 + TanStack Router: top-level files in
app/routes/ and folder-route entries (route.tsx, _layout.tsx, _index.tsx, index.tsx, __root.tsx). Subdirectories under app/routes/<segment>/ are still checked because users co-locate non-route helpers there.
- Expo Router:
_layout.tsx and +*.tsx special files.
Patterns match anywhere in the path so monorepo subpackages (packages/web/src/app/blog/[slug]/page.tsx) are skipped too.
Enable: run fallow dead-code --private-type-leaks, or set private-type-leaks: "warn" / "error" in rules.
Suppression: add // fallow-ignore-next-line private-type-leak above the affected export, or keep private-type-leaks: "off" in rules.
Prior art: Rust’s unreachable_pub lint, TypeScript’s TS4023 / TS4060 errors emitted with --declaration. ESLint has no equivalent rule today.
Unused dependencies
Packages listed in dependencies that are never imported in source code and not used as script binaries in package.json.
When to act: Remove from package.json when nothing imports it. If the finding says the package is imported in another workspace, move the dependency to that consuming workspace instead. Unused dependencies slow down npm install, increase node_modules size, and expand the attack surface for supply chain vulnerabilities.
Internal workspace packages count too: in monorepos, workspace package names (e.g., @repo/ui) listed in another workspace’s dependencies are checked the same way as external npm packages. If a workspace declares @repo/ui but never imports it, the dependency is reported as unused. A workspace package’s own name does not need to be self-listed.
Common false positives:
- Uninstalled peer dependencies of other packages: Fallow credits required peers when the package is installed under
node_modules. If a peer is required only by an environment outside the analyzed install tree, check whether removing it breaks that runtime.
- Runtime-only packages (polyfills, CSS frameworks loaded via
@import): Add to ignoreDependencies in config.
Unused devDependencies
Same as unused dependencies, but for packages in devDependencies.
| Severity | Default |
|---|
| Warning | Yes |
Unused optionalDependencies
Same as unused dependencies, but for packages in optionalDependencies. Only reported when the --unused-optional-deps flag is passed.
| Severity | Default |
|---|
| Warning | No (opt-in) |
Unused enum members
Enum values declared but never referenced.
| Severity | Default |
|---|
| Warning | Yes |
When to act: Remove the unused member. Unused enum members inflate serialized values and can cause confusion about which values are valid.
Unused class members
Class methods and properties never referenced outside their class body.
| Severity | Default |
|---|
| Warning | Yes |
What it detects: Public methods and properties that are never accessed by any module other than the class itself. Internal this.member accesses within the class body are tracked separately and do not count as external usage.
Inheritance awareness: Fallow builds an inheritance map from extends clauses and propagates member accesses through the hierarchy. If a parent class method calls this.getArea(), that access credits child class overrides (Circle.getArea(), Rectangle.getArea()). External member accesses on a parent type also propagate to child implementations. This prevents false positives on abstract-like patterns where a base class defines a contract that subclasses fulfill.
Decorator exclusion: Members with decorators (e.g., @Get(), @Column(), @Input(), @Inject()) are automatically excluded from detection. Decorators indicate runtime wiring that static analysis cannot trace, so decorated members are never flagged. The one exception is a Lit @state() reactive property on a direct LitElement / ReactiveElement subclass (when lit is a declared dependency): @state is internal component state with no external surface, so a @state field read nowhere via this.<name> (in a method or the html template) is flagged as an unused class member. The public @property() decorator stays excluded (it is the element’s attribute API, settable from HTML, a parent binding, setAttribute, or CSS).
Lifecycle method allowlists: Framework lifecycle methods are never flagged. Fallow has built-in allowlists for React (componentDidMount, render, shouldComponentUpdate, etc.) and Angular (ngOnInit, ngOnDestroy, canActivate, validate, etc.). For other frameworks (Web Components, ag-Grid, etc.), the usedClassMembers config option lets you extend the allowlist.
Whole-object use heuristics: Patterns like Object.values(instance), Object.keys(instance), rest destructuring (const { foo, ...rest } = instance), and for (const key in obj) conservatively mark all members as used.
class UserService {
// Used: called in app.ts
async getUser(id: string) { ... }
// Unused: never called outside this class
private formatName(user: User) { ... }
// Excluded: has decorator (runtime wiring)
@Get('/users')
handleGetUsers() { ... }
// Excluded: React lifecycle method
componentDidMount() { ... }
}
When to act: Remove or mark as private. An unreferenced public method is either dead functionality or wired at runtime. Both are worth investigating.
Configuration: The usedClassMembers config option marks specific member names as always-used. This is useful for convention-based methods called by DI frameworks or third-party libraries that invoke methods reflectively (e.g., agInit, connectedCallback). Plugins can also contribute to this allowlist via the usedClassMembers field in custom plugin definitions.
Unused store members
A Pinia store member (a state / getters / actions key, or a setup-store returned key) that is declared but accessed by no consumer anywhere in the project.
| Severity | Default |
|---|
| Warning | Yes |
What it detects: The store binding is imported (so the module is reachable), yet a specific state property, getter, or action is never read. This is the cross-graph “declared but never used by any file” direction that single-file linters (eslint-plugin-vue, eslint-plugin-svelte, eslint-plugin-pinia) and type-checkers (vue-tsc, svelte-check) do not cover. Defaults to warn, not error like the closed-set class and enum member rules: a store has an open declaration surface (plugins, dynamic dispatch), so confidence is lower.
Activation: Runs only when pinia or @pinia/nuxt is a declared dependency, so it never fires on an unrelated defineStore-named helper in a non-Pinia project.
Consumption it credits: const s = useStore(); s.member, destructures (const { count } = useStore(), const { count } = storeToRefs(store)), template store.member in .vue / .svelte files, and intra-store this.member usage between getters and actions.
Abstain heuristics (false-positive safe): Whole-object usage ({ ...store }, Object.keys(store), dynamic store[key]) and the Options-API mapState / mapGetters / mapActions helpers conservatively mark the whole store as used, so no member is flagged. The Pinia $-prefixed API ($patch, $reset, $subscribe, etc.) is never reported, and a setup store whose returned object spreads (return { ...base, x }) abstains entirely.
export const useCartStore = defineStore('cart', {
state: () => ({
items: [], // Used: read in CartView.vue
legacyTotal: 0, // Unused: no component or store ever reads it
}),
getters: {
total: (s) => s.items.length, // Used: rendered in the template
},
actions: {
add() { /* ... */ }, // Used: called on click
deprecatedReset() { /* ... */ }, // Unused: never dispatched
},
})
When to act: Remove the unused state property, getter, or action. There is no auto-fix, because a store member can be accessed reflectively through a Pinia plugin or dynamic dispatch; suppress with // fallow-ignore-next-line unused-store-member if a member is consumed in a way fallow cannot see.
Scope: Pinia only. Svelte stores and Vuex are not yet covered (a Svelte writable is opaque and its value shape is reshaped at runtime, so member-level detection would be false-positive-prone; an entirely unused store export is already caught by the unused-export check).
Unresolved imports
Import specifiers that cannot be resolved to a file on disk or a node_modules package.
When to act: Always investigate. An unresolved import usually means the code will fail at runtime. Common causes:
- Typo in the import path
- Missing dependency (not installed or not in
package.json)
- Path alias not configured in
tsconfig.json paths
Unlisted dependencies
Packages imported in source code but not declared in package.json.
When to act: Add the package to dependencies or devDependencies. Unlisted dependencies work by accident (hoisted from another package) and will break in environments with strict node_modules resolution (pnpm, Yarn Plug’n’Play).
Internal workspace packages count too: in monorepos, importing a workspace package (e.g., import { x } from '@repo/utils') from a workspace whose own package.json does not list @repo/utils is reported as unlisted. Add the workspace package to that workspace’s dependencies (or move the import). Self-references stay allowed without requiring a package to depend on itself.
Duplicate exports
The same symbol name exported from multiple modules. This causes ambiguous auto-imports and makes it unclear which module is the canonical source.
| Severity | Default |
|---|
| Warning | Yes |
When to act: Consolidate to a single canonical export. Re-export from one location if multiple modules need to expose it.
Circular dependencies
Modules that import each other directly or transitively, forming a cycle.
| Severity | Default |
|---|
| Warning | Yes |
Detection during graph construction: Circular dependencies are detected as a natural byproduct of building the module graph using Tarjan’s strongly connected components algorithm (O(V + E)). There is no separate analysis pass, so cycle detection adds zero meaningful cost to the analysis.
No depth limits: Fallow detects cycles of any length. The graph algorithm processes all strongly connected components regardless of size, then enumerates individual elementary cycles within each component. There is no cap on cycle depth.
Type-only cycle filtering: Cycles where every edge consists exclusively of import type imports are filtered out automatically. Type imports are erased at compile time and cannot cause runtime initialization issues. Fallow distinguishes these from runtime cycles by tracking the is_type_only flag on each import symbol through the graph.
Cycle path reporting: Fallow reports the full path of each cycle, so you can see exactly which files are involved:
src/auth.ts → src/user.ts → src/permissions.ts → src/auth.ts
This makes it straightforward to identify the weakest edge to break.
Why circular dependencies are risky:
- Module evaluation order becomes unpredictable. JavaScript evaluates modules depth-first, and a cycle forces at least one module to receive an incomplete namespace object.
- Accessing an export before the module finishes evaluating returns
undefined, causing subtle runtime errors that are hard to trace.
- Bundlers may produce incorrect output or larger bundles when they cannot determine a clean evaluation order.
- Refactoring becomes dangerous because moving code between cycle members can break initialization order in ways that only manifest at runtime.
| Cycle length | Risk | Action |
|---|
| 2 (A ↔ B) | Moderate, usually a design issue | Extract shared code into a third module |
| 3-5 | Higher, harder to trace | Break the cycle at the weakest edge |
| 5+ | Architectural issue | Refactor module boundaries |
When it’s OK to keep:
- Type-only cycles: If the cycle only involves
import type, there is no runtime initialization risk. These are filtered from circular dependency reports automatically.
Re-export cycles
Two or more barrel files that re-export from each other in a loop, or a single barrel file that re-exports from itself. Distinct from circular dependencies: circular deps describe runtime import loops; re-export cycles describe declarative loops in the export * from graph where chain propagation has no terminating module to resolve names against.
| Severity | Default |
|---|
| Warning | Yes |
Why this is its own category: Circular dependencies in the runtime import graph are sometimes intentional (mutual recursion between application modules). Re-export cycles are essentially always bugs. When src/api/index.ts does export * from './internal' and src/api/internal/index.ts does export * from '../index', neither file actually declares anything; the chain has no terminating source. Any consumer doing import { foo } from './api' is asking the chain to resolve foo against a graph with no leaf, and the import silently comes up undefined at runtime.
Two structural shapes:
multi-node — a strongly connected component of two or more files in the re-export edge subgraph. Example: barrel-a.ts re-exports from barrel-b.ts and barrel-b.ts re-exports back from barrel-a.ts.
self-loop — a single file that re-exports from itself. Almost always a rename leftover where barrel.ts ended up doing export * from './' or export * from './barrel'. Surfaces under the same rule with a distinct kind discriminator so consumers can tell the two shapes apart.
Detection runs in the graph layer: detection happens during Phase 4 (re-export chain resolution) via iterative Tarjan SCC over the (barrel, source) edge subgraph. Same Tarjan implementation that already powers circular-dependency detection; the SCC pass runs once and surfaces both findings (and the tracing::warn! lines for operators running RUST_LOG=warn stay, additive to the typed finding).
Type-only cycles still fire: export type * from './b' paired symmetrically with export type * from './a' is also a re-export cycle. Chain propagation through a type-only loop is the same no-op as through a value loop; the cycle is reported regardless.
Mock human output:
Re-Export Cycles (Architecture)
src/api/index.ts
Cycle (2 files):
- src/api/index.ts
- src/api/internal/index.ts
To fix: remove one `export * from` statement on any member file.
src/utils/index.ts
Self-loop (1 file):
- src/utils/index.ts
To fix: remove the `export * from './'` (or equivalent) inside this file.
How to fix it:
- Pick any one file in the loop.
- Remove the
export * from (or export { ... } from) statement that points back into the cycle.
- Any single removal breaks the cycle and restores working re-exports. You do not need to touch every member.
For a self-loop, the fix is unambiguous: open the offending file and delete the stray export * from './' (or export * from './<this-file>' after a rename).
Suppression: the finding is file-scoped (the cycle spans multiple files; there is no single line to anchor at). Suppress with a file-level comment on any member:
// fallow-ignore-file re-export-cycle
export * from "./internal";
The four spellings re-export-cycle, re-export-cycles, reexport-cycle, and reexport-cycles are all accepted. Per-file overrides.rules.re-export-cycle is a no-op because the rule’s severity is project-wide and the finding spans multiple files; configure it at the top-level rules block instead, or use the file-level fallow-ignore-file suppression above.
When to keep it as a warning: legacy projects with hand-written barrels predating ESM strictness may have benign-looking cycles that no consumer actually relies on. Leaving the rule at warn keeps the diagnostic visible without breaking CI; promote to error once the cycles are cleaned up. Marcus’s rule of thumb on upgrade: expect total_issues to step up on the first run because cycles that existed before this rule shipped now appear as findings.
Boundary violations
Imports that cross user-defined architecture zone boundaries. Zones are defined in the boundaries config section and map file path patterns to named architecture zones. A boundary violation occurs when a file in one zone imports from a file in another zone that the boundary rules disallow.
When to act: Always. Boundary violations indicate that code is breaking the intended architecture. Fix by restructuring imports to respect zone boundaries, or update the boundary config to allow the import if it’s an accepted exception.
Common false positives:
- Shared utilities: Code in a shared/common zone may need to be accessible from multiple zones. Adjust boundary rules to allow these imports.
- Newly created zones: When introducing boundaries to an existing codebase, expect violations. Use
warn severity initially.
Policy violations
Calls, imports, or catalogue-derived effects matching a banned-call, banned-import, or banned-effect rule from a configured rule pack (the rulePacks config key). Rule packs are standalone JSON or JSONC files of pure declarative policy data; loading a pack never executes project code. Findings are identified as <pack>/<rule-id> across every output format.
| Severity | Default |
|---|
| Warning | Yes |
The rules."policy-violation" master defaults to warn so a new pack never hard-fails CI on its first run. A per-rule severity in the pack overrides the master per finding, and the exit-code gate reads the effective per-finding severity, so one error rule fails the run even under a warn master. off on the master is a kill switch for the whole evaluator. Invalid or missing packs fail config load with exit code 2 instead of silently enforcing nothing.
When to act: Replace the banned call or import with the alternative named in the rule’s message. Scope a rule with files / exclude globs if it fires in directories where the usage is sanctioned, or suppress one rule with // fallow-ignore-next-line policy-violation:<pack>/<rule-id>. Use bare policy-violation only when you intend to suppress every rule-pack finding at that line or file scope.
Limitations: Matching is syntactic. banned-call does not follow aliased or re-bound callees (const run = cp.exec; run()), and banned-import matches the raw specifier only, so rewritten forms such as npm:moment are not matched; require() calls and dynamic import() are not checked.
Run fallow rule-pack-schema to print the pack JSON Schema for editor autocomplete. See the rulePacks config key for the pack file format.
Invalid client exports
A file carrying the "use client" directive that also exports a Next.js server-only or route-segment-config name. Next.js rejects these at build time: a client module cannot export metadata, generateMetadata, viewport, generateViewport, generateStaticParams, dynamic, dynamicParams, revalidate, fetchCache, runtime, preferredRegion, maxDuration, the Pages Router data functions (getServerSideProps, getStaticProps, getStaticPaths), or a route HTTP method handler (GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS). fallow catches it statically, before a build, in the same pass as the rest of dead-code analysis. The component’s default export is never flagged.
| Severity | Default |
|---|
| Warning | Yes |
The rule only runs when next is a declared dependency, so it cannot false-positive on non-Next projects. There is no auto-fix, because removing a legitimate client export would break the component.
When to act: Move the offending export out of the "use client" file into a server module (or a shared module without the directive). Suppress with // fallow-ignore-next-line invalid-client-export if the export is intentional and you handle the boundary another way.
Mixed client/server barrels
A barrel file (one with at least one export ... from re-export) that re-exports both a "use client" module and a server-only module. Importing any single name from such a barrel can drag the other origin’s directive context across the React Server Components boundary, a common Next.js App Router footgun that surfaces as confusing build or hydration errors far from the barrel.
| Severity | Default |
|---|
| Warning | Yes |
A module counts as server-only when it carries a "use server" directive, imports the server-only package, next/server, node:fs, or node:child_process (the node: and bare forms both count), or imports a server-only next/headers API (cookies, headers, draftMode). The check classifies only direct re-export origins (it does not walk transitive chains), skips type-only re-exports (which carry no runtime directive context), and never flags a barrel that mixes a client module with an ordinary utility, so it stays false-positive-safe. It runs only when next is a declared dependency.
When to act: Split the barrel so client and server-only modules are re-exported from separate entry points. There is no auto-fix (splitting a barrel is a design decision); suppress with // fallow-ignore-next-line mixed-client-server-barrel if the grouping is intentional.
Misplaced directives
A "use client" or "use server" directive written as a bare string-literal statement below an import (or any other statement) instead of at the very top of the file. A directive is only honored in the file’s leading prologue; once any statement precedes it, the parser treats the string as an ordinary expression and the bundler silently ignores it, so a file you intended as a client component is treated as a server module. Nothing errors, which is what makes it dangerous.
| Severity | Default |
|---|
| Warning | Yes |
The fix is always the same: move the directive above every import. The rule runs only when next is a declared dependency. There is no auto-fix (fallow does not reorder your source); the finding points at the misplaced directive so you can move it by hand.
When to act: Move the "use client" / "use server" line to the top of the file, before all imports. Suppress with // fallow-ignore-next-line misplaced-directive only if the bare string is intentional and not meant as a directive (rare).
Route collisions
Two or more App Router route files (a page or a route handler) that resolve to the same URL within one app-root. Route groups (name) and parallel slots @name do not change the URL, so app/(marketing)/about/page.tsx and app/(shop)/about/page.tsx both own /about. Next.js fails the build with “You cannot have two parallel pages that resolve to the same path” because a URL can have at most one owner, whether a Page or a Route Handler. fallow catches it statically, before a build, and surfaces every colliding file at once (the build error names only one and aborts).
It defaults to error because it mirrors a next build failure: a project hitting it was already red, so erroring aligns fallow’s exit code with the build it mirrors.
Collision buckets are scoped per app-root using your workspace package roots, so a monorepo with several independent Next apps that happen to share a path (apps/web/app/about and apps/admin/app/about) is not flagged. Files under a private _folder and files under an intercepting marker ((.), (..), (...)) are excluded. Two dynamic segments that differ only by name ([id] vs [slug]) are reported by dynamic segment name conflicts, not here. The rule runs only when next is a declared dependency.
When to act: Move or merge one of the colliding files so each URL has a single owner. Suppressing a guaranteed build error does not make the build pass, so the primary action is the fix; // fallow-ignore-file route-collision exists only as an escape hatch.
Dynamic segment name conflicts
Two or more sibling dynamic route segments at the same App Router tree position using different param spellings ([id] vs [slug], or a catch-all [...x] vs an optional catch-all [[...x]]). Next.js throws “You cannot use different slug names for the same dynamic path” at dev and production runtime when the position is hit, because one position must resolve to a single param name. Note that next build does NOT catch this (the build succeeds), so CI stays green while the route crashes the first time it is requested; fallow’s static catch closes that gap. Route groups are transparent to the position and parallel slots fork it, so only genuinely-sibling segments conflict.
It defaults to error because it is a deterministic runtime crash on first request that next build lets through, so fallow is the only gate that fails on it. The detector is pure path arithmetic on the same primitive as route-collision, so there is no heuristic to misfire.
The rule runs only when next is a declared dependency.
When to act: Rename the dynamic segments at the position to one consistent slug name (pick [id] or [slug] for both). Suppress with // fallow-ignore-file dynamic-segment-name-conflict only as an escape hatch.
Unused server actions
A Next.js Server Action (an export of a "use server" file) that no code in the project references: no import-and-call, no action={fn} binding, and no <form action={fn}>. This is the cross-graph “exported but wired to nothing” direction, exactly where dead server actions accumulate as a page is refactored, and eslint-plugin-next cannot see it because it is single-file. It does not mean the endpoint is unreachable: Next still registers a generated action id, so it stays POST-able; it means no project code calls it, so it is a strong delete candidate.
| Severity | Default |
|---|
| Warning | Yes |
This is a more specific re-classification of unused-export for "use server" files. It reuses fallow’s whole-project reference graph, so the action={fn} and <form action={fn}> bindings, plain import-and-call, and component-prop forwarding all count as real uses, and wrapped action factories (authenticatedActionClient.action(...), withAuditLogging(...)) are credited by the wrapped const’s references; only a genuinely orphaned action is flagged. The rule runs only when next is a declared dependency. Inline "use server" body directives (export async function f() { "use server" } in a non-"use server" file) are not covered yet; such dead actions still surface as unused-export.
When to act: Wire the action to a consumer or delete it. There is no auto-fix. Suppress with // fallow-ignore-next-line unused-server-action, or set the rule to off. If you gate CI on dead server actions, note the action now reports as unused-server-action (default warn) instead of unused-export (default error); set this rule to error to keep failing on it, and re-save any --save-baseline snapshot once.
Unprovided injects
A Vue inject(KEY) or Svelte getContext(KEY) whose symbol key is provide() / setContext()’d nowhere in the project. At runtime a dead inject silently returns undefined, surfaced only when the affected path renders; no static tool in the Vue / Svelte / Nuxt ecosystems catches it (Vue and Nuxt emit a runtime-only warning, Svelte’s eslint proposal is unimplemented).
| Severity | Default |
|---|
| Warning | Yes |
The key must be a symbol with cross-file identity (an imported const or a module-local symbol), which makes the check false-positive-safe by construction: a key imported from a package abstains (the provider may live inside that package), a key bound to a string literal abstains (string identity, a literal provider matches it), a key that is part of your package’s public API abstains (a “bring-your-own-provider” library exports the key for the consumer to provide), and any dynamic-keyed provide (keys.forEach(k => provide(k))) abstains project-wide. App-level app.provide(KEY, value) is credited, and a provide imported directly while the inject reaches the same key through a barrel re-export are matched. The rule runs only when vue, @vue/runtime-core, or svelte is a declared dependency. Nuxt’s string-keyed nuxtApp.provide / $x API and the inverse provided-never-injected direction are not covered yet.
When to act: Provide the key (provide(KEY, value) / setContext(KEY, value) in an ancestor) or remove the dead inject. There is no auto-fix. Suppress with // fallow-ignore-next-line unprovided-inject, or set the rule to off.
Unrendered components
A single-file component (the default export of a .vue / .svelte / .astro file) that is kept reachable by a barrel re-export but instantiated by no file in the project: no <Tag>, no :is / this= binding, no components / app.component registration, no h() / Nuxt auto-import, and no script value-read. This is the common rot where a component is refactored out of every template but its barrel re-export keeps it alive, so unused-file (the file is reachable) and unused-export (the re-export counts as a use) both miss it; eslint’s vue/no-unused-components is single-file and cannot see a barrel-reexported component rendered by a sibling.
| Severity | Default |
|---|
| Warning | Yes |
The render set is built liberally across barrel chains, Nuxt auto-imports, and dynamic / side-effect imports, so over-crediting only suppresses a finding (never creates one). A component that is itself an entry point (route page, layout, App.vue, Nuxt app.vue / error.vue) and a component re-exported through any chain from a non-private package entry point (a library exporting components for its consumers to render) are both abstained, so component libraries are not false-flagged. The rule runs only when vue / @vue/runtime-core / nuxt (for .vue), svelte / @sveltejs/kit (for .svelte), or astro (for .astro) is a declared dependency.
The same rule has two non-SFC arms. An Angular arm (framework: "angular", gated on @angular/core) flags an @Component whose element selector appears in no template project-wide, with route / bootstrapApplication / lazy-loadComponent and dynamic-render (ViewContainerRef.createComponent, *ngComponentOutlet) abstains. A Lit arm (framework: "lit", gated on lit / lit-element / @lit/reactive-element) flags a registered custom element (@customElement('x-foo'), or customElements.define('x-foo', C) on any receiver) whose tag is rendered in no html template, no standalone .html document, and via no imperative createElement / customElements.get / whenDefined; it abstains on published elements (public API) and on elements defined under a docs / dev / demo directory (rendered by site or dev tooling that fallow cannot parse).
When to act: Render the component where it belongs, or delete it and its barrel re-export. There is no auto-fix (a component can be rendered reflectively through a dynamic <component :is> resolved from a non-literal value, or a Lit element created from a computed tag). Suppress with // fallow-ignore-next-line unrendered-component, or set the rule to off.
Unused component props
A Vue <script setup> defineProps declared prop that is referenced by no code in its own single-file component, neither in <script> nor in <template>. This is the in-component dead-input direction that vue-tsc / Volar do not cover (they check caller-side prop correctness, not “this declared prop is wired to nothing”); eslint’s vue/no-unused-properties is opt-in, off by default, and historically unreliable on <script setup> reactive destructure.
| Severity | Default |
|---|
| Warning | Yes |
Prop names are harvested from the inline TS form (defineProps<{ foo: T }>()), the runtime object form (defineProps({ foo: {...} })), and withDefaults(...); usage is credited from both script (the destructured binding or props.foo) and template ({{ foo }}, :x="foo", props.foo, $props.foo). It stays false-positive-safe by abstaining on the whole component when it cannot see all consumption: a v-bind="$attrs" / v-bind="props" fallthrough, a whole-object props use (toRefs(props), {...props}), a defineExpose or defineModel call, or a prop type from an imported alias (defineProps<ImportedProps>(), names not statically resolvable). The rule runs only when vue / @vue/runtime-core / nuxt is a declared dependency and covers Vue <script setup> and Options API props.
A React / Preact arm shares this rule key and emits the same finding for a prop that is destructured from a component’s first parameter and read nowhere in the component body. It is dep-gated on react / react-dom / next / preact, covers inline-destructured literal props, and stays false-positive-safe by abstaining on rest spread ({...rest}), props forwarded by spread, props passed wholesale to a hook, a forwardRef / memo component whose props come from an imported interface (not statically resolvable), and exported public-API component props.
An Astro arm shares this rule key for a .astro interface Props (or type Props) field read nowhere: not through an Astro.props destructure (including a nested destructure such as const { post: { id } } = Astro.props), not via Astro.props.<name>, not in a {expr} template position, and not through a <style> / <script> define:vars={{ prop }} binding. It is dep-gated on astro and abstains on the whole component for a {...Astro.props} template spread, a whole-object Astro.props use, an interface Props extends ... or imported-type alias (names not statically resolvable), or an array destructure of Astro.props.
When to act: Wire the prop where it is meant to be used, or remove it from defineProps (Vue) / the destructure (React). There is no auto-fix. Suppress with // fallow-ignore-next-line unused-component-prop, or set the rule to off.
Unused component emits
A Vue <script setup> defineEmits declared event that is emitted by no code in its own single-file component, neither through emit('name') in <script> nor emit('name') / $emit('name') in <template>. This is the emit-side sibling of unused component props: the in-component dead-output direction that vue-tsc / Volar do not cover (they check caller-side listener correctness, not “this declared event is never emitted”).
| Severity | Default |
|---|
| Warning | Yes |
Event names are harvested from the array form (defineEmits(['save', 'close'])), the type / object form (defineEmits<{ save: [] }>()), and the bound form (const emit = defineEmits(...), tracking the actual local binding name so a renamed const notify = defineEmits(...) is still credited); usage is credited from both the script call walk and the template (@click="$emit('save')"). It stays false-positive-safe by abstaining on the whole component when it cannot see all emission: a dynamic emit(eventName) whose event name is not a literal, the emit function passed or returned or spread elsewhere, a defineModel call (which generates implicit update:x emits), or an emit type from an imported alias (defineEmits<ImportedEmits>(), names not statically resolvable). The rule runs only when vue / @vue/runtime-core / nuxt is a declared dependency and covers Vue <script setup> and Options API emits.
When to act: Emit the event where it is meant to fire, or remove it from defineEmits. There is no auto-fix (removing an emit can break a deliberately-stable public component API). Suppress with // fallow-ignore-next-line unused-component-emit, or set the rule to off.
Unused Svelte events
A Svelte component dispatches a custom event through createEventDispatcher, but no reachable Svelte consumer listens for that event name.
| Severity | Default |
|---|
| Warning | Yes |
Event names are harvested from literal dispatcher calls such as dispatch('save') after tracking the local binding returned by createEventDispatcher(). Listeners are credited from Svelte component usage with on:event syntax and forwarded listener attributes. The rule stays false-positive-safe by abstaining from dynamic event names, dispatcher values that escape the component, and projects without a declared Svelte dependency.
When to act: Add the intended listener, forward the event intentionally, or remove the dispatch. There is no auto-fix. Suppress with // fallow-ignore-next-line unused-svelte-event, or set the rule to off.
An Angular component input (@Input(), the signal input() / input.required(), or model()) declared on a component that is read by no code in its own component, neither in the class body nor in its template (inline template or an external templateUrl file). This is the Angular analogue of unused component props: the in-component dead-input direction that no Angular tooling covers. There is no @angular-eslint rule for it, and the Angular compiler only checks caller-side binding correctness (that a parent’s [input]="..." matches a declared input), never “this declared input is wired to nothing inside the component.” An input consumed only by a parent’s template binding but read nowhere in its own component IS flagged, because the parent binding is satisfied while the component never uses the value.
| Severity | Default |
|---|
| Warning | Yes |
Input names are harvested from the decorator form (@Input() foo, including the aliased @Input('alias') and the metadata inputs: ['foo'] on the @Component decorator) and the signal form (foo = input<T>(), input.required<T>(), model<T>()); model() is treated as input-only (its two-way write half is not an emit). Usage is credited from the class body (this.foo) and from the component’s template (interpolation, property and attribute bindings, structural directives, and template references). It stays false-positive-safe by abstaining on the whole component whenever it cannot see all consumption: any extends clause (an inherited template or base class may read the input), a {...this} spread, a JS-reserved-word input name, an accessor input (a get / set pair, where the setter side effect is the consumption), and host: decorator-metadata members are credited as used. The rule runs only when @angular/core is a declared dependency.
When to act: Read the input where it is meant to be used, or remove the @Input() / input() / model() declaration. There is no auto-fix. Suppress with // fallow-ignore-next-line unused-component-input, or set the rule to off.
Unused component outputs
An Angular component output (@Output() or the signal output()) that is emitted (.emit(...)) by no code in its own component. This is the emit-side sibling of unused component inputs: the in-component dead-output direction that no Angular tooling covers (the compiler checks caller-side listener correctness, that a parent’s (output)="..." matches a declared output, never “this declared output is never emitted”).
| Severity | Default |
|---|
| Warning | Yes |
Output names are harvested from the decorator form (@Output() saved = new EventEmitter(), including the aliased @Output('alias') and the metadata outputs: ['saved'] on the @Component decorator) and the signal form (saved = output<T>()); only new EventEmitter() initializers are harvested, so an observable-stream @Output (one wired to a Subject / Observable whose emissions fallow cannot follow) is left alone. Usage is credited from any this.saved.emit(...) call in the component. It stays false-positive-safe by abstaining on the whole component whenever it cannot see all emission: any extends clause, a {...this} spread, or a JS-reserved-word output name. The rule runs only when @angular/core is a declared dependency.
When to act: Emit the output where it is meant to fire, or remove the @Output() / output() declaration. There is no auto-fix (removing an output can break a deliberately-stable public component API). Suppress with // fallow-ignore-next-line unused-component-output, or set the rule to off.
Prop drilling
A React / Preact prop that is forwarded unused through two or more intermediate components before a component finally consumes it. “Forwarded unused” means a receiving component reads the prop’s identifier only as the root of a child-JSX attribute value (so <Child userName={user.name} /> counts: the user prop is projected straight through), never substantively. This is a cross-file graph signal that per-file linters (eslint-plugin-react) structurally cannot see.
| Severity | Default |
|---|
| Off (opt-in) | Yes |
This rule is off by default; enable it with prop-drilling: "warn" (or "error") under rules. When enabled it reports one located chain per drilled prop, with the source component that owns the value, each pass-through hop, and the consumer, each with file, line, and component name, so you or an agent can act. It stays false-positive-safe by abstaining on the whole chain whenever it cannot prove a clean pass-through: any {...props} / {...rest} spread, a cloneElement call, a component passed as a prop or render prop or function-as-children, a matching context *.Provider in the subtree (where drilling may be a deliberate choice), or any dynamic or unresolvable hop. The rule runs only when react / react-dom / next / preact is a declared dependency. When enabled it also contributes a small, capped penalty to the health score (one point per chain, capped at five); with the rule off the health score is unchanged.
When to act: Colocate the consumer with the data, lift the value to a React context / provider at a mid-chain hop, or compose the components so the intermediates no longer thread the prop. There is no auto-fix (the right refactor is a design choice). Suppress an individual chain with // fallow-ignore-next-line prop-drilling, or set the rule to off.
Thin wrapper
A React / Preact component whose entire body is return <Child {...props} />: it renders a single child component, forwards all of its props through a spread, and adds no markup, no hooks, and no logic of its own. It is pure structural indirection: every call site could render the child directly. This is a cross-component graph signal that per-file linters cannot see.
| Severity | Default |
|---|
| Off (opt-in) | Yes |
This rule is off by default; enable it with thin-wrapper: "warn" (or "error") under rules. It reports the wrapper component (at its definition) and the child it forwards to. It stays false-positive-safe by abstaining on wrappers that are intentional indirection: a forwardRef or memo wrapper (the sanctioned way to make a child ref-able or to set a perf boundary), an exported (public-API) component (a library re-branding a child under its own name), a context-provider wrapper, a cloneElement call, a render-prop / function-as-children, or any wrapper whose body is more than the single spread-forwarded child (a host element, sibling, hook, or branch). The rule runs only when react / react-dom / next / preact is a declared dependency.
When to act: Inline the wrapper at its call sites and delete it, or give it a real responsibility (markup, defaults, logic). There is no auto-fix. Suppress with // fallow-ignore-next-line thin-wrapper, or set the rule to off.
Duplicate prop shape
Three or more React / Preact components, across two or more files, that declare an identical set of prop names, suggesting a shared Props type (or a base component) should be extracted. Cross-graph, which per-file linters cannot see.
| Severity | Default |
|---|
| Off (opt-in) | Yes |
This rule is off by default; enable it with duplicate-prop-shape: "warn" (or "error") under rules. It reports one finding per member of a group, naming the other components that share the shape. It stays noise-free by requiring an exact full-set match (a superset or subset does not group), a minimum of four significant prop names after stripping ubiquitous DOM-passthrough names (id, className, children, style, …), and at least three components in at least two files, so two buttons that merely share {label, onClick} are never grouped. The rule runs only when react / react-dom / next / preact is a declared dependency, and abstains on components whose props are not statically harvestable (rest spread, imported-interface generics).
When to act: Extract a shared Props type or a base component the group can reuse. There is no auto-fix (the right abstraction is a design choice). Suppress with // fallow-ignore-next-line duplicate-prop-shape, or set the rule to off.
Unused load data keys
A SvelteKit load() return-object key (in +page.ts / +page.server.ts and the .js variants) that is read by no consumer: not the sibling +page.svelte’s data.<key>, and not any project-wide page.data.<key> (Svelte 5 $app/state) or $page.data.<key> (Svelte 4 $app/stores). A dead returned key still runs its real server-side fetch / DB cost on every request for data nothing renders, and no other static tool catches it: svelte-check types data through the generated $types but never flags an unread returned key (the unused-input direction).
| Severity | Default |
|---|
| Warning | Yes |
It stays false-positive-safe by abstaining whenever it cannot see all consumption: an unharvestable load body (a spread return, a non-literal or multi-branch return, a computed key, a wrapped / re-exported load), a sibling component that passes the whole data object opaquely (data={data}, {...data}, fn(data), const x = data), a +page.server.ts whose universal +page.ts sibling reads or forwards its data param, and any project-wide reflective whole-object read of the page-data store (Object.values(page.data)), which abstains every route. Page-load data reaches only the sibling +page.svelte, so a data.<key> read in a +layout.svelte (which receives layout-load data, not page-load data) does not credit a page key. The rule runs only when @sveltejs/kit is a declared dependency. Layout loads (+layout.ts / +layout.server.ts) are not covered yet.
When to act: Read the key where the data is meant to be used, or remove it from the load() return (and drop the fetch that produced it if nothing else needs it). There is no auto-fix (a load fetch can have side effects, so removing a key is a human decision). Suppress with // fallow-ignore-next-line unused-load-data-key, or set the rule to off.
Type-only dependencies
Production dependencies (dependencies) that are only ever imported via import type. Since type imports are erased at compile time, these packages are never loaded at runtime and should be moved to devDependencies.
| Severity | Default |
|---|
| Warning | Yes |
When to act: Move to devDependencies. This reduces the production install size and clearly communicates that the package is only needed for type checking.
Test-only dependencies
Production dependencies (dependencies) that are only ever imported by test files (files matching test patterns like *.test.ts, *.spec.ts, test directories). Since they are never used in production code, they should be moved to devDependencies.
| Severity | Default |
|---|
| Warning | Yes |
When to act: Move to devDependencies. This reduces the production install size and makes it clear the package is only needed for testing.
Unused pnpm catalog entries
Packages declared in pnpm-workspace.yaml under catalog: or catalogs.<name>: that no workspace package.json references with the catalog: protocol.
| Severity | Default |
|---|
| Warning | Yes |
When to act: Remove the catalog entry, or switch consumers that still pin a hardcoded version to catalog: first.
Empty pnpm catalog groups
Named catalogs.<name>: groups in pnpm-workspace.yaml that contain no package entries. The top-level catalog: map is not reported when empty because it can be a harmless default-catalog placeholder.
| Severity | Default |
|---|
| Warning | Yes |
When to act: Remove the empty named group header.
Unresolved pnpm catalog references
Workspace package.json dependencies using catalog: or catalog:<name> where the selected catalog does not declare the requested package.
When to act: Add the package to the selected catalog, switch to a catalog that declares it, or pin a direct version.
pnpm dependency overrides
unused-dependency-overrides reports override entries whose target package is not declared by any workspace package.json and is not present in pnpm-lock.yaml. Overrides that target transitive dependencies resolved in the lockfile (the common CVE-pin pattern) are treated as used. When the lockfile is missing or unreadable, fallow falls back to a package.json-only check and keeps a hint on each finding so transitive pins can be reviewed before removal. misconfigured-dependency-overrides reports malformed override keys or empty values that pnpm rejects.
Stale suppressions
// fallow-ignore comments and /** @expected-unused */ JSDoc tags that no longer match any issue. Suppression comments accumulate as codebases evolve. A suppression that once silenced a real finding may become stale when the underlying issue is fixed, the code is moved, or another module starts importing a previously unused export.
| Severity | Default |
|---|
| Warning | Yes |
Two origins are tracked:
- Inline comments (
// fallow-ignore-next-line, // fallow-ignore-file): reported as stale when the target line or file no longer has a matching issue.
@expected-unused JSDoc tags: reported as stale when the tagged export is now imported by another module. Use /** @expected-unused */ to mark exports that are intentionally dead code but should be flagged if they become used again.
// STALE: this suppression no longer matches any issue
// fallow-ignore-next-line unused-export
export const helper = () => {}; // now imported in app.ts
// @expected-unused marks intentional dead code
/** @expected-unused */
export const deprecatedApi = () => {};
// ^ reported as stale if something starts importing deprecatedApi
Unknown issue-kind tokens (typos, obsolete names): fallow accepts the recognized tokens on a multi-kind marker even when one of the tokens does not match any known issue kind, and reports each unknown token as a stale suppression. This guards against the silent-failure case where a typo (unused_export instead of unused-export) or a kind renamed in a newer release would otherwise discard every suppression on the same line. The diagnostic’s explanation surfaces the verbatim token plus a “Did you mean …?” suggestion when a known kind is within Levenshtein distance 2.
// Recognized + unknown on the same marker:
// fallow-ignore-next-line unused-export, complexity-typo
export const foo = 1;
// `unused-export` still suppresses `foo`; `complexity-typo` surfaces as a stale
// suppression so you can fix the typo or drop the unknown token.
JSON consumers can distinguish unknown-kind tokens from stale-but-known tokens via the additive origin.kind_known field on stale_suppressions[].origin (present and false only in the unknown-kind case; absent when the kind is recognized).
When to act: Remove the stale suppression comment or JSDoc tag. For unknown-kind reports, fix the typo or drop the unknown token; the rest of the marker continues to apply. Stale suppressions add noise and may hide future issues if the code changes again.
Configuration:
{
"rules": {
"stale-suppressions": "error" // default: "warn"
}
}
Use --stale-suppressions as a filter flag to show only stale suppression findings.
Prioritizing findings
Not all findings are equally important. Use this priority order:
- Unresolved imports: potential runtime failures
- Unlisted dependencies: breaks in strict environments
- Boundary violations: architecture enforcement
- Unused files: entire modules to delete
- Unused dependencies: security and install time
- Unused exports: API surface cleanup
- Circular dependencies: initialization bugs
- Type-only dependencies: install size optimization
- Test-only dependencies: install size optimization
- Duplicate exports: developer experience
- Stale suppressions: suppression hygiene
- Unused types / enum members / class members: code hygiene
The first two categories indicate correctness issues that may cause runtime failures. Everything else is maintenance and hygiene.
Reading the summary line
Found 401 issues in 0.16s
The total count includes all issue types. Filter with --unused-files, --unused-exports, etc. to focus on specific categories.
With --fail-on-issues, only issues with error severity cause a non-zero exit. Warnings are reported but don’t fail the build.
With --threshold on specific filters, you can set numeric limits:
Found 401 issues (17 errors, 384 warnings)
Incremental adoption
Most projects have existing dead code. Fallow supports gradual adoption:
Baseline comparison
# Save current state
fallow dead-code --save-baseline fallow-baselines/dead-code.json
# Only fail on new issues
fallow dead-code --baseline fallow-baselines/dead-code.json --fail-on-issues
The baseline captures issue fingerprints (file + export + type), not line numbers. Refactoring won’t invalidate the baseline as long as the export names stay the same.
Changed-since
# Only check files in the current PR
fallow dead-code --changed-since main
Reports issues only in files that git shows as changed. Fast and focused for CI.
Filter by type
# Start with just unused files
fallow dead-code --unused-files --fail-on-issues
# Add unused exports later
fallow dead-code --unused-files --unused-exports --fail-on-issues
Adopt one issue type at a time. Fix the findings, then add the next type.
Common false positive patterns
Fallow auto-detects 114 frameworks and tools via package.json and config files, so most convention-based exports (Next.js pages, Storybook stories, Jest configs, tap suites, tsd declaration tests, and Contentlayer content roots) are handled out of the box. Run fallow list to see which plugins are active and what entry points they add.
False positives that do occur are typically in these categories:
| Pattern | Why fallow flags it | Fix |
|---|
Scripts run directly (node scripts/seed.ts) | Not imported by any module | Add to entry in config, or add a package.json script |
| Dependency injection / runtime wiring | Registered via DI container, not import | Add to entry or use /** @public */ (or @internal, @beta, @alpha) |
Dynamic require() with variables | Path not statically resolvable | Add the directory to entry |
| Environment-specific files | Only imported behind process.env checks | Add to entry if always needed |
| Unsupported framework conventions | Plugin doesn’t exist for this framework | Add entry points manually or write a custom plugin |
Before acting on a finding, verify with --trace. A finding is only as good as fallow’s module graph. If an export is used via a dynamic pattern fallow can’t resolve, use --trace to confirm.
# Verify a specific export
fallow dead-code --trace src/utils.ts:formatDate
# See all edges for a file
fallow dead-code --trace-file src/utils.ts
# Check where a dependency is used
fallow dead-code --trace-dependency lodash
Limitations
- Fully runtime-computed paths:
import(variable) or require(variable) where the argument has no static structure cannot be resolved. Template literals with partial paths (import(`./locales/${lang}.json`)) and import.meta.glob are resolved. Only completely opaque variables are a limitation. Mark those directories in entry.
- Syntactic analysis only: fallow doesn’t invoke the TypeScript compiler. Conditional types or dead code behind generic constraints require type resolution to detect. See known limitations for details.
- Side-effect imports (
import './polyfill') are tracked as file-level edges. The imported file won’t be flagged as unused, but individual exports within it are still analyzed.
When --explain is passed (or via MCP), the JSON output includes a _meta object:
{
"schema_version": 3,
"_meta": {
"docs": "https://docs.fallow.tools/explanations/dead-code",
"issue_types": {
"unused_file": {
"name": "Unused file",
"description": "File not reachable from any entry point",
"severity": "error",
"action": "Delete the file or add it as an entry point"
},
"unused_export": {
"name": "Unused export",
"description": "Exported symbol never imported by another module",
"severity": "error",
"action": "Remove the export keyword or delete the symbol"
},
"circular_dependency": {
"name": "Circular dependency",
"description": "Modules that import each other directly or transitively",
"severity": "warning",
"action": "Extract shared code into a separate module"
},
"boundary_violation": {
"name": "Boundary violation",
"description": "Import crosses a user-defined architecture zone boundary",
"severity": "error",
"action": "Restructure the import to respect zone boundaries"
}
}
}
}
AI agents and CI systems can use this to interpret issue types without consulting external documentation.