Dead code explained
Decide which dead-code findings to fix first and which are safe to keep. Learn what each issue type in fallow dead-code output means, including unused class members with inheritance tracking and circular dependencies with no depth limit.
Use this page to decide which fallow dead-code findings to fix, in which order, and which ones you can keep. For each issue type, it explains what fallow reports, when to act, and the common false positives.
Issue types
Each dead-code issue type has a default severity. An error finding makes the run exit with code 1. A warn finding shows in the output, but does not fail the run unless you pass --fail-on-issues. An off rule does not run. To change a severity, use rules.
Unused files
Files that no entry point reaches. No module in the project imports them, and no framework plugin or entry point pattern matches them.
| Severity | Default |
|---|---|
| Error | Yes |
When to act: Almost always. Unused files make IDE indexing slower and add noise to search results.
Common false positives:
- Scripts run directly (e.g.,
node scripts/seed.ts): Add the script toentryin config, or call it from apackage.jsonscript field (fallow parses those). - Files loaded by tools that fallow does not know about: Add them to
entryor to the relevant plugin config.
Unused exports
Exported symbols that no other module imports.
A TypeScript namespace without the export keyword is a local binding. This applies to namespace Foo { export const inner = 1 }, to declare namespace, and to the legacy module Foo {} form. The inner export declarations are members of that namespace, not exports of the file. Fallow never reports them here or under unused types. Only export namespace Foo { ... } adds a file-level export (Foo itself).
| Severity | Default |
|---|---|
| Error | Yes |
When to act: Remove most unused exports, or remove their export keyword. Unused exports make the public API larger and make it unclear what the module is for.
When it's OK to keep:
- Library public API: External users import these exports, and fallow cannot see those users. Mark the exports with the
/** @public */JSDoc tag (or@internal,@beta,@alpha), add the library entry point toentry, or useignoreExportsfor broad patterns. - Convention-based exports: Frameworks use these exports through naming conventions (e.g., Next.js
getStaticProps). The fallow framework plugins handle most of them. If a plugin misses one, configure the plugin or add an ignore pattern.
Unused types
Type aliases and interfaces that no module references.
| Severity | Default |
|---|---|
| Error | Yes |
When to act: Remove unused types. They add noise to auto-import suggestions and make the public type API larger.
When it's OK to keep:
- Shared type packages: Types that you export for external users. Add the package entry point to
entry.
Private type leaks
Exported values, functions, classes, and types whose public TypeScript signature references a type that is declared in the same file but is NOT exported. Users can see the name of the private type in generated declarations or API docs, but they cannot import it. They must then type it again, or use workarounds such as Parameters<typeof X>[0] or ReturnType<typeof X>.
This is an opt-in API hygiene check. It is not a default dead-code finding. It is most useful for packages that publish TypeScript declarations or have a public API. For exports inside an app, private Props, Options, and State helper types are often intentional. Do not export them only to satisfy this rule.
| Severity | Default |
|---|---|
| Off | Yes |
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 packages that you publish or modules that emit declarations. To fix a finding, export the backing type together with the exported symbol. You can also change the public signature so that it does not 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 report the new export as unused-types, even if no other module imports it. If a public signature in the same file references a type by name, fallow treats that type as part of the public API. The unused-types check skips it.
Skipped patterns:
-
ECMAScript
#fieldprivate members and TypeScriptprivateaccessibility: these are not part of the public signature, so the types they reference do not leak.class Service { #state: InternalState; }does not flagInternalState. -
Storybook story files: fallow skips
*.stories.{ts,tsx,js,jsx,mts,cts,mjs,cjs}and the*.story.*variants. Without this, the canonicaltype Story = StoryObj<typeof X>; export const Default: Story = ...pattern gives one finding per story. -
Framework routing conventions: route files where the framework forces several exports per file to share a private
Props,Params, orLoaderArgstype. Fallow skips these files:- 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). Fallow still checks subdirectories underapp/routes/<segment>/, because users put non-route helpers there. - Expo Router:
_layout.tsxand+*.tsxspecial files.
The patterns match anywhere in the path, so fallow also skips monorepo subpackages (
packages/web/src/app/blog/[slug]/page.tsx). - Next.js App Router:
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: the Rust unreachable_pub lint, and the TypeScript TS4023 and TS4060 errors that --declaration emits. ESLint has no equivalent rule today.
Deprecated exports in use
An export whose leading JSDoc has a @deprecated tag, and that at least one reachable file still uses. The finding tells you who still uses a deprecated API, and which deprecated exports you can remove now.
This is an opt-in check. A deprecation window is a normal state, so the rule is off by default.
| Severity | Default |
|---|---|
| Off | Yes |
Example:
/** @deprecated Use `formatDate` instead. */
export function oldFormat(d: Date): string { /* ... */ }
Each finding has:
consumer_count: the exact number of consumers.consumers: a sample of up to 10 consumers, each with path, line, column, and reference kind. The sample is sorted by path and line.deprecated_reason: the deprecation message as plain text, up to 200 characters. A bare@deprecatedtag gives nodeprecated_reasonkey.public_api: truewhen the export is in an entry point, or when a re-export chain carries it to an entry point. Fallow cannot see external consumers, so it makes no removal claim for such an export.
For the full consumer list, run fallow dead-code --trace <file>:<export>. The next_steps array names this command for the deprecated export with the most consumers (id trace-deprecated-export).
Rules for consumers:
- A consumer in an unreachable file does not count. That file is dead code itself and has its own
unused-filefinding. - A deprecated export with no consumer stays an unused export. That finding then has
deprecated: trueand thedeprecated_reason. The human and markdown output add(marked @deprecated). - A per-path override applies to the file that declares the export, not to the consumer. You cannot keep a deprecated API legal only for the consumers in
legacy/**. - Under
fallow auditwithgate: new-only, the finding is new only when the file that declares the export changed. A change that only adds a consumer does not make the finding new.
When to act: Move each consumer to the replacement API. When the last consumer is gone, fallow reports the export as an unused export, and you can remove it. For a public_api export, keep your deprecation policy for external users.
Enable: run fallow dead-code --deprecated-exports-in-use to report only this issue type, or set deprecated-exports-in-use: "warn" / "error" in rules. The MCP analyze tool accepts issue_types: ["deprecated-exports-in-use"]. An overrides entry alone cannot turn the rule on: the base rule must not be off.
Suppression: add // fallow-ignore-next-line deprecated-export-in-use above the export.
There is no editor diagnostic for this finding, because TypeScript already strikes deprecated names through in the editor.
Unused dependencies
Packages in dependencies that no source file imports and that no package.json script uses as a binary.
| Severity | Default |
|---|---|
| Error | Yes |
When to act: If nothing imports the package, remove it from package.json. If the finding says that another workspace imports the package, move the dependency to that workspace. Unused dependencies slow down npm install, make node_modules larger, and increase the attack surface for supply chain vulnerabilities.
Internal workspace packages count too: in monorepos, fallow checks a workspace package name (e.g., @repo/ui) in the dependencies of another workspace the same way as an external npm package. If a workspace declares @repo/ui but never imports it, fallow reports the dependency as unused. A workspace package does not need to list its own name.
Common false positives:
- Uninstalled peer dependencies of other packages: Fallow counts a required peer as used when the package that requires it is installed under
node_modules. If only an environment outside the analyzed install tree requires the peer, check whether removing it breaks that runtime. - Runtime-only packages (polyfills, CSS frameworks loaded through
@import): Add them toignoreDependenciesin 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. Fallow reports these by default, and the --unused-deps filter includes them.
| Severity | Default |
|---|---|
| Warning | Yes |
Unused enum members
Enum values that no code references.
| Severity | Default |
|---|---|
| Error | Yes |
When to act: Remove the unused member. Unused enum members add to serialized values and make it unclear which values are valid.
Unused class members
Class methods and properties that no code outside the class body references.
| Severity | Default |
|---|---|
| Error | Yes |
What it detects: Public methods and properties that no module other than the class itself accesses. Fallow tracks internal this.member accesses in the class body separately. They do not count as external usage.
Inheritance awareness: Fallow builds an inheritance map from extends clauses and passes member accesses through the hierarchy. If a parent class method calls this.getArea(), the child class overrides (Circle.getArea(), Rectangle.getArea()) count as used. External member accesses on a parent type also count for the child implementations. This prevents false positives when a base class defines a contract that subclasses implement.
Decorator exclusion: Fallow automatically skips members with decorators (e.g., @Get(), @Column(), @Input(), @Inject()). A decorator means runtime wiring that static analysis cannot trace, so fallow never flags decorated members.
The one exception is a Lit @state() reactive property on a direct LitElement or ReactiveElement subclass, when lit is a declared dependency. @state is internal component state that no outside code can use. If no method or html template reads a @state field through this.<name>, fallow flags it as an unused class member. The public @property() decorator stays excluded. It is the attribute API of the element, and HTML, a parent binding, setAttribute, or CSS can set it.
Lifecycle method allowlists: Fallow never flags framework lifecycle methods. It 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.), use the usedClassMembers config option to extend the allowlist.
Whole-object use heuristics: Some patterns mark all members as used, to be safe: Object.values(instance), Object.keys(instance), rest destructuring (const { foo, ...rest } = instance), and for (const key in obj).
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 the member or mark it as private. An unreferenced public method is either dead functionality or wired at runtime. Investigate both cases.
Configuration: The usedClassMembers config option marks specific member names as always used. Use it for convention-based methods that DI frameworks or third-party libraries call reflectively (e.g., agInit, connectedCallback). Custom plugin definitions can also add names to this allowlist through their usedClassMembers field.
Unused store members
A Pinia store member that no code in the project accesses. A store member is a state, getters, or actions key, or a key that a setup store returns.
| Severity | Default |
|---|---|
| Warning | Yes |
What it detects: Code imports the store binding (so the module is reachable), but never reads a specific state property, getter, or action. Single-file linters (eslint-plugin-vue, eslint-plugin-svelte, eslint-plugin-pinia) and type-checkers (vue-tsc, svelte-check) cannot find members that no file uses, because that needs the project graph. The rule defaults to warn, not error like the class and enum member rules. Plugins and dynamic dispatch can add to a store, so the confidence is lower.
Activation: The rule runs only when pinia or @pinia/nuxt is a declared dependency. It never fires on an unrelated helper named defineStore in a non-Pinia project.
Usage it counts: const s = useStore(); s.member, destructures (const { count } = useStore(), const { count } = storeToRefs(store)), template store.member in .vue and .svelte files, and this.member usage between getters and actions in the same store.
Abstain heuristics (false-positive safe): Whole-object usage ({ ...store }, Object.keys(store), dynamic store[key]) marks the whole store as used, so fallow flags no member. The Options-API mapState, mapGetters, and mapActions helpers do the same. Fallow never reports the Pinia $-prefixed API ($patch, $reset, $subscribe, etc.). It skips a setup store completely when the returned object uses a spread (return { ...base, x }).
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 Pinia plugin or dynamic dispatch can access a store member reflectively. If code uses a member in a way that fallow cannot see, suppress it with // fallow-ignore-next-line unused-store-member.
Scope: Pinia only. The rule does not cover Svelte stores and Vuex yet. A Svelte writable is opaque and its value shape changes at runtime, so member-level detection would give false positives. The unused-export check already finds a store export that no code uses.
Unresolved imports
Import specifiers that fallow cannot resolve to a file on disk or a node_modules package.
| Severity | Default |
|---|---|
| Error | Yes |
When to act: Always investigate. An unresolved import usually means that 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.jsonpaths
A re-export through an unresolved import, such as export { iconsList } from '@scope/icons/index', still gives this finding. But the files that import iconsList credit the re-exported name, the same as a re-export from an npm package. So the name does not also report as an unused export. This happens for example with a workspace package whose main and types point to a dist directory that is not in the checkout. A re-exported name that no file imports still reports as an unused export.
Unlisted dependencies
Packages that source code imports but package.json does not declare.
| Severity | Default |
|---|---|
| Error | Yes |
When to act: Add the package to dependencies or devDependencies. An unlisted dependency works by accident, because it is hoisted from another package. It will break in environments with strict node_modules resolution (pnpm, Yarn Plug'n'Play).
Internal workspace packages count too: in monorepos, a workspace can import a workspace package (e.g., import { x } from '@repo/utils') while its own package.json does not list @repo/utils. Fallow reports that import as unlisted. Add the workspace package to the dependencies of that workspace, or move the import. Self-references stay allowed, and a package does not need to depend on itself.
The result is the same for a direct import such as @repo/utils/format and for a package.json imports alias such as "#utils/*": "@repo/utils/*". It is also the same before and after an install that links the workspace packages into node_modules. One exception: when a tsconfig paths alias maps the workspace package to its source and no install link exists, fallow does not report the import.
The root package follows the same rule. A file that belongs to the root package, such as e2e/run.ts, must list the workspace packages that it imports in the root package.json. Fallow does not guess whether the install links a workspace package into the root node_modules, because that depends on the package manager, its version, and its hoisting settings.
Duplicate exports
The same symbol name exported from more than one module. Auto-imports become ambiguous, and it is unclear which module is the canonical source.
| Severity | Default |
|---|---|
| Error | Yes |
When to act: Keep one canonical export. If more than one module must expose the symbol, re-export it from that one location.
Circular dependencies
Modules that import each other, directly or transitively, and so form a cycle.
| Severity | Default |
|---|---|
| Error | Yes |
Detection during graph construction: Fallow finds circular dependencies while it builds the module graph, with Tarjan's strongly connected components algorithm (O(V + E)). There is no separate analysis pass, so cycle detection adds almost no cost.
No depth limits: Fallow detects cycles of any length. The graph algorithm processes all strongly connected components of any size. Then it lists the individual elementary cycles in each component.
Type-only cycle filtering: Fallow automatically removes cycles where every edge is an import type import. The compiler erases type imports, so they cannot cause runtime initialization issues. Fallow tells these apart from runtime cycles with the is_type_only flag on each import symbol in the graph.
Lazy imports: By default, every runtime import edge takes part in cycle detection, also a dynamic import(). A top-level await import() loads eagerly, because the module waits for the target before it continues. To skip edges that load on demand or on another thread, set circularDependencies.ignoreLazyImports to true.
Cycle path reporting: Fallow reports the full path of each cycle, so you can see which files are part of it:
src/auth.ts → src/user.ts → src/permissions.ts → src/auth.ts
With the full path, you can find the weakest edge to break.
Why circular dependencies are risky:
- The module evaluation order becomes unpredictable. JavaScript evaluates modules depth-first, and a cycle forces at least one module to get an incomplete namespace object.
- An export that you access before its module finishes evaluating returns
undefined. This causes runtime errors that are hard to trace. - When bundlers cannot find a clean evaluation order, they can produce incorrect output or larger bundles.
- Refactoring becomes dangerous. When you move code between modules in the cycle, you can break the initialization order, and you see the error only 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 uses only
import type, there is no runtime initialization risk. Fallow automatically removes these from circular dependency reports.
Re-export cycles
Two or more barrel files that re-export from each other in a loop, or one barrel file that re-exports from itself. This is different from a circular dependency. A circular dependency is a runtime import loop. A re-export cycle is a declarative loop in the export * from graph, with no final module that declares the names.
| 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 almost always bugs. Take src/api/index.ts with export * from './internal', and src/api/internal/index.ts with export * from '../index'. Neither file declares anything, so the chain has no source. An import { foo } from './api' cannot resolve foo, and the import silently gives undefined at runtime.
Two structural shapes:
multi-node: a strongly connected component of two or more files in the re-export graph. Example:barrel-a.tsre-exports frombarrel-b.ts, andbarrel-b.tsre-exports back frombarrel-a.ts.self-loop: one file that re-exports from itself. This is almost always a leftover from a rename, wherebarrel.tsnow hasexport * from './'orexport * from './barrel'. It shows up under the same rule, with a differentkindvalue so that tools can tell the two shapes apart.
Type-only cycles still fire: export type * from './b' together with export type * from './a' is also a re-export cycle. A type-only loop resolves no names, the same as a value loop, so fallow reports it too.
Human output:
── Structure ─────────────────────────────────────
● Re-Export Cycles (2)
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(orexport { ... } from) statement that points back into the cycle. - One removal breaks the cycle and makes the re-exports work again. You do not need to change every file in the loop.
For a self-loop, the fix is clear. Open the file and delete the stray export * from './' (or export * from './<this-file>' after a rename).
Suppression: the finding is file-scoped, because the cycle spans more than one file and has no single line. Suppress it with a file-level comment in any file of the loop:
// fallow-ignore-file re-export-cycle
export * from "./internal";
Fallow accepts four spellings: re-export-cycle, re-export-cycles, reexport-cycle, and reexport-cycles. A per-file overrides.rules.re-export-cycle is a no-op, because the severity of the rule is project-wide and the finding spans more than one file. Configure the rule in 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 from before ESM strictness can have harmless cycles that no code relies on. At warn, the diagnostic stays visible and CI does not break. Change the rule to error after you remove the cycles. After an upgrade, expect total_issues to go up on the first run. Cycles that existed before this rule shipped now show up as findings.
Package cycles
Workspace packages that import each other in a loop. Package a imports package b, and package b imports package a, directly or through other packages. A package cycle can exist when no file-level cycle exists: a/src/x.ts imports b/src/y.ts, and b/src/z.ts imports a/src/w.ts. The files do not form a loop, but the packages do.
| Severity | Default |
|---|---|
| Warning | Yes |
Why it matters: packages in a cycle cannot be built, versioned, or published in dependency order. A build tool that builds dependencies first (for example with TypeScript project references or a task runner) has no valid order. One package must then use the build output of the other package from an earlier run.
How fallow builds the package graph:
- Each workspace package is a node. A file belongs to the workspace package with the longest root that contains the file.
- Each resolved import from a file in one package to a file in another package is an edge. A relative import that goes into another package counts the same as an import by package name.
- Declared dependencies in
package.jsonare not edges. Only imports in the code count. - Imports from test, spec, story, and fixture files and from tooling config files (for example
vitest.config.ts) are not edges. A package often imports a sibling package only in its tests, and that import does not change the build order. Fallow matches these files by their path relative to the package root. - The check needs two or more workspace packages. A project without workspaces has no package cycles.
Type-only hops: a type-only import (import type) is an edge too. A type-only import has no runtime effect, but a declaration build can still need the other package first. Each hop in the finding has a type_only flag. The flag is true when every import on that hop is type-only. This is different from circular dependencies, which ignore type-only imports.
What the finding shows: the packages in cycle order, starting at the package with the lowest name, and one example import for each hop. The example import is the first runtime import of the hop by path and line. When the hop has only type-only imports, it is the first type-only import.
● Package cycles (1)
@acme/core → @acme/utils → @acme/core
packages/core/src/format.ts:3 → packages/utils/src/index.ts
packages/utils/src/log.ts:1 → packages/core/src/index.ts (type-only)
In --format json, each finding in package_cycles has these fields:
| Field | Description |
|---|---|
packages | Package labels in cycle order. The last package imports the first |
package_roots | Root directory of each package, in the same order as packages |
length | Number of packages in the cycle |
edges | One example import per hop: from_package, to_package, path, target_path, line, col, and type_only |
group_truncated | true when the group of packages that holds this cycle has more cycles than fallow lists |
Packages with the same name: a label is the package name. When two or more workspace packages have the same name, for example several examples/* packages named example, the label also shows the package root: example (examples/basic). The label is then unique in all output formats, in baseline keys, and in audit keys.
Limits: fallow lists the shortest cycles first, and at most 20 cycles for one group of packages that are connected in a loop. On a very dense package graph, fallow can also stop the search earlier. Fallow then sets group_truncated on each cycle of that group, and every output format shows the note "this package group has more cycles than listed". Break a listed cycle and run fallow again to see the other cycles.
Suppression: a suppression removes one import or re-export statement from the package graph. It does not hide the finding directly. Each statement counts on its own: when two statements in one file import the same module, a suppression on one statement leaves the other statement in the graph. For a statement on more than one line, put the comment above the first line of the statement.
// fallow-ignore-next-line package-cycle
import { log } from "@acme/utils";
The cycle goes away when every import on one hop is removed. If a hop has more than one import, suppress each import on that hop, or put // fallow-ignore-file package-cycle in each file that holds an import on the hop. A per-file override that sets package-cycle to off has the same effect as fallow-ignore-file. While another import keeps the hop, the cycle stays and the example import moves to that import. A suppression on an import that is not part of a cycle is reported as a stale suppression. Fallow also accepts the alias package-cycles.
Scoped runs: --changed-since, the diff scope of fallow audit, and --workspace look only at the example imports in the finding (edges[].path), not at every import of a hop. Fallow keeps a cycle when one of its example import files is in scope. A new cycle always adds a hop whose imports are all new, so a new cycle always shows. A change that adds a second import to a hop that already exists does not bring the cycle into scope. Per-file severity in overrides also uses the example import files: the highest severity among them applies. A per-file off removes the imports of that file from the package graph, the same as // fallow-ignore-file package-cycle. The cycle goes away only when no import that is not removed keeps a hop. --group-by puts a cycle in the group of its first example import file.
Gating: at the default warn, a package cycle shows in every output format but does not fail the run. At error, a package cycle makes fallow exit with code 1. --fail-on-issues also fails the run on a cycle at warn. A regression baseline and fallow audit key a cycle by its package labels, so a cycle that already exists does not fail a gate again.
When to act: move the shared code to a package that both packages can import, or reverse the direction of one hop. A cycle where every hop is type-only is lower risk, because it cannot cause runtime initialization issues, but it still blocks a strict build order. Set the rule to error after you remove the cycles, or to off if your build tool does not need a package order.
Boundary violations
Imports that cross the architecture zone boundaries that you define. You define zones in the boundaries config section. Each zone maps file path patterns to a name. A boundary violation is an import from a file in one zone to a file in another zone that the boundary rules do not allow. Fallow checks every analyzed file, also a file that no entry point reaches. For a named or default import through a barrel file, fallow judges the zone of the module that declares the symbol. The finding then names the barrel in via_path.
| Severity | Default |
|---|---|
| Error | Yes |
When to act: Always. A boundary violation means that the code breaks the intended architecture. Change the imports so that they respect the zone boundaries. If the import is an accepted exception, update the boundary config to allow it.
Common false positives:
- Shared utilities: Code in a shared or common zone can need access from more than one zone. Change the boundary rules to allow these imports.
- New zones: When you add boundaries to an existing codebase, expect violations. Start with
warnseverity.
Related boundary findings: the boundary-violation rule also sets the severity of two other findings:
boundary-call-violation: a file in a zone calls a callee that matches a pattern inboundaries.calls.forbidden.boundary-coverage: an analyzed source file matches no zone, andboundaries.coverage.requireAllFilesistrue. To let some files stay outside every zone, list them inboundaries.coverage.allowUnmatched.
Policy violations
Calls, imports, exports, or catalogue-derived effects that match a banned-call, banned-import, banned-effect, or banned-export rule from a configured rule pack (the rulePacks config key). A rule pack is a standalone JSON or JSONC file with declarative policy data only. Loading a pack never executes project code. Every output format identifies a finding as <pack>/<rule-id>.
| Severity | Default |
|---|---|
| Warning | Yes |
The rules."policy-violation" master setting defaults to warn, so a new pack never fails CI on its first run. A per-rule severity in the pack overrides the master setting for each finding. The exit code uses the effective severity of each finding, so one error rule fails the run even when the master setting is warn. off on the master setting turns off the whole evaluator. An invalid or missing pack makes the config load fail with exit code 2, so fallow never silently enforces nothing.
When to act: Replace the banned call or import with the alternative that the rule message names. If a rule fires in directories where the usage is allowed, limit the rule with files and exclude globs. To suppress one rule, use // fallow-ignore-next-line policy-violation:<pack>/<rule-id>. Use bare policy-violation only when you want 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()). banned-import matches only the raw specifier, so it does not match rewritten forms such as npm:moment. Fallow does not check require() calls and dynamic import().
To print the pack JSON Schema for editor autocomplete, run fallow rule-pack-schema. For the pack file format, see the rulePacks config key.
Invalid client exports
A file with the "use client" directive that also exports a Next.js server-only or route-segment-config name. Next.js rejects these files 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 finds the problem statically, before a build, in the same pass as the rest of the dead-code analysis. Fallow never flags the default export of the component.
| Severity | Default |
|---|---|
| Warning | Yes |
The rule runs only when next is a declared dependency, so it cannot give false positives in other projects. There is no auto-fix, because the removal of a valid client export would break the component.
When to act: Move the export out of the "use client" file into a server module, or into a shared module without the directive. If the export is intentional and you handle the boundary another way, suppress it with // fallow-ignore-next-line invalid-client-export.
Mixed client/server barrels
A barrel file (a file with at least one export ... from re-export) that re-exports both a "use client" module and a server-only module. When you import one name from such a barrel, the directive context of the other module can cross the React Server Components boundary. This is a common Next.js App Router mistake. It shows up as confusing build or hydration errors far from the barrel.
| Severity | Default |
|---|---|
| Warning | Yes |
A module counts as server-only when it does one of these:
- has a
"use server"directive - imports the
server-onlypackage,next/server,node:fs, ornode:child_process(thenode:and bare forms both count) - imports a server-only
next/headersAPI (cookies,headers,draftMode)
To stay false-positive-safe, the check classifies only the direct re-export origins and does not walk transitive chains. It skips type-only re-exports, because they have no runtime directive context. It never flags a barrel that mixes a client module with an ordinary utility. The rule runs only when next is a declared dependency.
When to act: Split the barrel, so that separate entry points re-export the client modules and the server-only modules. There is no auto-fix, because the split of a barrel is a design decision. If the grouping is intentional, suppress it with // fallow-ignore-next-line mixed-client-server-barrel.
Misplaced directives
A "use client" or "use server" directive that is a bare string-literal statement below an import (or any other statement), instead of at the very top of the file. A directive works only in the leading prologue of the file. When a statement comes before it, the parser treats the string as an ordinary expression, and the bundler silently ignores it. A file that you meant as a client component then becomes a server module. Nothing shows an error, and that 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, because 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" or "use server" line to the top of the file, before all imports. Suppress it with // fallow-ignore-next-line misplaced-directive only if the bare string is intentional and is not a directive (rare).
Route collisions
Two or more App Router route files (a page or a route handler) that resolve to the same URL in 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. A URL can have only one owner, a Page or a Route Handler. Next.js fails the build with "You cannot have two parallel pages that resolve to the same path". Fallow finds the problem statically, before a build, and reports all colliding files at once. The build error names only one file and stops.
| Severity | Default |
|---|---|
| Error | Yes |
The rule defaults to error because it matches a next build failure. A project with this issue already fails its build, so the fallow exit code agrees with the build.
Fallow compares routes per app-root, based on your workspace package roots. A monorepo with several independent Next apps that share a path (apps/web/app/about and apps/admin/app/about) is not flagged. Fallow excludes files under a private _folder and files under an intercepting marker ((.), (..), (...)). Two dynamic segments that differ only by name ([id] vs [slug]) show up under 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 that each URL has one owner. A suppression does not make the build pass, so fix the files. // fallow-ignore-file route-collision is only an escape hatch.
Dynamic segment name conflicts
Two or more sibling dynamic route segments at the same App Router tree position that use different param names ([id] vs [slug], or a catch-all [...x] vs an optional catch-all [[...x]]). One position must resolve to one param name. When a request hits the position, Next.js throws "You cannot use different slug names for the same dynamic path" at dev and production runtime. next build does NOT catch this, and the build succeeds. CI stays green, and the route crashes on its first request. Fallow finds the conflict statically. Route groups do not change the position, and parallel slots create a new position, so only true sibling segments conflict.
| Severity | Default |
|---|---|
| Error | Yes |
The rule defaults to error. The issue is a deterministic runtime crash on the first request, and next build does not stop it, so fallow is the only check that fails on it. The detector only compares paths, with the same logic as route-collision, so no heuristic can misfire.
The rule runs only when next is a declared dependency.
When to act: Give the dynamic segments at the position one slug name (use [id] or [slug] for both). Use // 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}>. Dead server actions build up when you refactor a page. eslint-plugin-next checks one file at a time, so it cannot see them. The finding does not mean that the endpoint is unreachable. Next still registers a generated action id, so clients can still POST to it. The finding means that no project code calls the action, so it is a strong delete candidate.
| Severity | Default |
|---|---|
| Warning | Yes |
This rule is a more specific version of unused-export for "use server" files. It uses the fallow reference graph of the whole project. The action={fn} and <form action={fn}> bindings, plain import-and-call, and component-prop forwarding all count as real uses. A wrapped action factory (authenticatedActionClient.action(...), withAuditLogging(...)) counts as used when code references the wrapped const. Fallow flags only an action that nothing uses. The rule runs only when next is a declared dependency. It does not cover inline "use server" body directives yet (export async function f() { "use server" } in a file without "use server"). Such dead actions still show up as unused-export.
When to act: Connect the action to code that calls it, or delete it. There is no auto-fix. Suppress it with // fallow-ignore-next-line unused-server-action, or set the rule to off. If CI fails on dead server actions, note that the action now shows up as unused-server-action (default warn) instead of unused-export (default error). To keep CI failing on it, set this rule to error, and save any --save-baseline snapshot again once.
Unprovided injects
A Vue inject(KEY) or Svelte getContext(KEY) whose symbol key no code passes to provide() or setContext(). At runtime, a dead inject silently returns undefined, and you see it only when the affected path renders. No static tool in the Vue, Svelte, or Nuxt ecosystems catches it. Vue and Nuxt emit only a runtime warning, and the Svelte eslint proposal is not implemented.
| Severity | Default |
|---|---|
| Warning | Yes |
The key must be a symbol with cross-file identity (an imported const or a module-local symbol). This keeps the check false-positive-safe. Fallow skips these cases:
- a key imported from a package, because the provider can be inside that package
- a key bound to a string literal, because a literal provider matches it by string identity
- a key that is part of the public API of your package, because a "bring-your-own-provider" library exports the key for its users to provide
- any provide with a dynamic key (
keys.forEach(k => provide(k))), which makes fallow skip the whole project
App-level app.provide(KEY, value) counts as a provide. Fallow also matches a provide that imports the key directly with an inject that gets the same key through a barrel re-export. The rule runs only when vue, @vue/runtime-core, or svelte is a declared dependency. It does not cover the string-keyed Nuxt nuxtApp.provide / $x API yet, or the inverse case of a key that is provided but never injected.
When to act: Provide the key (provide(KEY, value) or setContext(KEY, value) in an ancestor), or remove the dead inject. There is no auto-fix. Suppress it 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, or .astro file) that a barrel re-export keeps reachable, but that no file in the project renders. There is no <Tag>, no :is or this= binding, no components or app.component registration, no h() or Nuxt auto-import, and no script value-read. This happens often: a refactor removes a component from every template, but its barrel re-export keeps it alive. unused-file misses it because the file is reachable. unused-export misses it because the re-export counts as a use. The eslint rule vue/no-unused-components checks one file at a time, so it cannot see a barrel-reexported component that a sibling renders.
| Severity | Default |
|---|---|
| Warning | Yes |
Fallow counts renders generously across barrel chains, Nuxt auto-imports, and dynamic and side-effect imports. Extra credit can only hide a finding, never create one. Fallow skips a component that is itself an entry point (route page, layout, App.vue, Nuxt app.vue or error.vue). It also skips a component that a non-private package entry point re-exports through any chain, for example a library that exports components for its users to render. So fallow does not flag component libraries by mistake. The rule runs only when one of these is a declared dependency: vue, @vue/runtime-core, or nuxt (for .vue), svelte or @sveltejs/kit (for .svelte), or astro (for .astro).
The same rule also covers two cases outside single-file components:
- Angular (
framework: "angular", runs only with@angular/core): flags an@Componentwhose element selector appears in no template in the project. Fallow skips components used in routes,bootstrapApplication, lazyloadComponent, and dynamic renders (ViewContainerRef.createComponent,*ngComponentOutlet). - Lit (
framework: "lit", runs only withlit,lit-element, or@lit/reactive-element): flags a registered custom element (@customElement('x-foo'), orcustomElements.define('x-foo', C)on any receiver) whose tag nohtmltemplate and no standalone.htmldocument renders, and that no imperativecreateElement,customElements.get, orwhenDefinedcall uses. Fallow skips published elements (public API) and elements defined under a docs, dev, or demo directory, because site or dev tooling that fallow cannot parse renders them.
When to act: Render the component where it belongs, or delete it and its barrel re-export. There is no auto-fix. Code can render a component reflectively, through a dynamic <component :is> with a non-literal value, or create a Lit element from a computed tag. Suppress the finding with // fallow-ignore-next-line unrendered-component, or set the rule to off.
Unused component props
A prop declared with defineProps in Vue <script setup> that no code in its own single-file component references, in <script> or in <template>. vue-tsc and Volar do not find this. They check that callers pass correct props, not that the component uses each declared prop. The eslint rule vue/no-unused-properties is opt-in, off by default, and historically unreliable with <script setup> reactive destructure.
| Severity | Default |
|---|---|
| Warning | Yes |
Fallow reads prop names from the inline TS form (defineProps<{ foo: T }>()), the runtime object form (defineProps({ foo: {...} })), and withDefaults(...). It counts usage in the script (the destructured binding or props.foo) and in the template (\{\{ foo \}\}, :x="foo", props.foo, $props.foo). To stay false-positive-safe, fallow skips the whole component when it cannot see all usage: a v-bind="$attrs" or 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>(), where the names cannot be resolved statically). The rule runs only when vue, @vue/runtime-core, or nuxt is a declared dependency. It covers Vue <script setup> and Options API props.
For React and Preact, the same rule key reports a prop that code destructures from the first parameter of a component but never reads in the component body. This check runs only when react, react-dom, next, or preact is a declared dependency. It covers inline-destructured literal props. To stay false-positive-safe, it skips rest spread ({...rest}), props forwarded by spread, props passed as a whole to a hook, a forwardRef or memo component whose props come from an imported interface (not statically resolvable), and the props of exported public-API components.
For Astro, the same rule key reports a field of a .astro interface Props (or type Props) that no code reads. That means no Astro.props destructure (also a nested destructure such as const { post: { id } } = Astro.props), no Astro.props.<name>, no {expr} template position, and no <style> or <script> define:vars=\{\{ prop \}\} binding reads it. This check runs only when astro is a declared dependency. It skips 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: Use the prop where it belongs, or remove it from defineProps (Vue) or from the destructure (React). There is no auto-fix. Suppress the finding with // fallow-ignore-next-line unused-component-prop, or set the rule to off.
Unused component emits
An event declared with defineEmits in Vue <script setup> that no code in its own single-file component emits: no emit('name') in <script>, and no emit('name') or $emit('name') in <template>. This is the emit-side version of unused component props. vue-tsc and Volar do not find it. They check that callers use correct listeners, not that the component emits each declared event.
| Severity | Default |
|---|---|
| Warning | Yes |
Fallow reads event names from the array form (defineEmits(['save', 'close'])), the type or object form (defineEmits<{ save: [] }>()), and the bound form (const emit = defineEmits(...)). For the bound form, fallow tracks the real local binding name, so a renamed const notify = defineEmits(...) still counts. Fallow counts usage in the script calls and in the template (@click="$emit('save')"). To stay false-positive-safe, fallow skips the whole component when it cannot see all emits: a dynamic emit(eventName) without a literal event name, an emit function that code passes, returns, or spreads, 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, or nuxt is a declared dependency. It covers Vue <script setup> and Options API emits.
When to act: Emit the event where it must fire, or remove it from defineEmits. There is no auto-fix, because the removal of an emit can break a public component API that you keep stable on purpose. Suppress the finding 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 component listens for that event name.
| Severity | Default |
|---|---|
| Warning | Yes |
Fallow tracks the local binding that createEventDispatcher() returns, and reads event names from literal dispatcher calls such as dispatch('save'). It counts listeners from Svelte component usage with on:event syntax and from forwarded listener attributes. To stay false-positive-safe, the rule skips dynamic event names, dispatcher values that leave the component, and projects without a declared Svelte dependency.
When to act: Add the intended listener, forward the event on purpose, or remove the dispatch. There is no auto-fix. Suppress the finding with // fallow-ignore-next-line unused-svelte-event, or set the rule to off.
Unused component inputs
An Angular component input (@Input(), the signal input() or input.required(), or model()) that no code in its own component reads. Neither the class body nor the template (inline template or an external templateUrl file) uses it. This is the Angular version of unused component props, and no Angular tooling finds it. There is no @angular-eslint rule for it. The Angular compiler checks only that a parent binding such as [input]="..." matches a declared input. It never checks that the component uses the input. Fallow DOES flag an input that only a parent template binding sets, if its own component never reads it. The parent binding is valid, but the component never uses the value.
| Severity | Default |
|---|---|
| Warning | Yes |
Fallow reads input names from the decorator form and the signal form. The decorator form is @Input() foo, the aliased @Input('alias'), and the metadata inputs: ['foo'] on the @Component decorator. The signal form is foo = input<T>(), input.required<T>(), and model<T>(). Fallow treats model() as input-only, and its two-way write half does not count as an emit. Fallow counts usage in the class body (this.foo) and in the template of the component (interpolation, property and attribute bindings, structural directives, and template references). To stay false-positive-safe, fallow skips the whole component when it cannot see all usage: any extends clause (an inherited template or base class can read the input), a {...this} spread, an input name that is a JS reserved word, or an accessor input (a get and set pair, where the side effect of the setter is the usage). Fallow counts members in host: decorator metadata as used. The rule runs only when @angular/core is a declared dependency.
When to act: Read the input where it belongs, or remove the @Input(), input(), or model() declaration. There is no auto-fix. Suppress the finding 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 no code in its own component emits with .emit(...). This is the emit-side version of unused component inputs, and no Angular tooling finds it. The compiler checks only that a parent listener such as (output)="..." matches a declared output. It never checks that the component emits the output.
| Severity | Default |
|---|---|
| Warning | Yes |
Fallow reads output names from the decorator form (@Output() saved = new EventEmitter(), the aliased @Output('alias'), and the metadata outputs: ['saved'] on the @Component decorator) and from the signal form (saved = output<T>()). Fallow reads only new EventEmitter() initializers. It ignores an observable-stream @Output, which uses a Subject or Observable whose emits fallow cannot follow. Fallow counts any this.saved.emit(...) call in the component as usage. To stay false-positive-safe, fallow skips the whole component when it cannot see all emits: any extends clause, a {...this} spread, or an output name that is a JS reserved word. The rule runs only when @angular/core is a declared dependency.
When to act: Emit the output where it must fire, or remove the @Output() or output() declaration. There is no auto-fix, because the removal of an output can break a public component API that you keep stable on purpose. Suppress the finding with // fallow-ignore-next-line unused-component-output, or set the rule to off.
Prop drilling
A React or Preact prop that two or more intermediate components pass on without using it, before a component finally uses it. "Pass on without using" means that a component reads the prop identifier only as the root of a child JSX attribute value, and never for anything else. For example, <Child userName={user.name} /> counts, because the user prop goes straight through. This finding needs the cross-file graph, so per-file linters (eslint-plugin-react) cannot see it.
| Severity | Default |
|---|---|
| Off (opt-in) | Yes |
This rule is off by default. To enable it, set prop-drilling: "warn" (or "error") under rules. Fallow then reports one chain per drilled prop. The chain shows the source component that owns the value, each pass-through hop, and the component that uses the prop, each with file, line, and component name. To stay false-positive-safe, fallow skips the whole chain when it cannot prove a clean pass-through: any {...props} or {...rest} spread, a cloneElement call, a component passed as a prop, render prop, or function-as-children, a matching context *.Provider in the subtree (where drilling can be a deliberate choice), or any dynamic or unresolvable hop. The rule runs only when react, react-dom, next, or preact is a declared dependency. When you enable it, it also adds a small, capped penalty to the health score: one point per chain, with a cap of five. With the rule off, the health score does not change.
When to act: Move the component that uses the prop next to the data, move the value to a React context or provider at a hop in the middle of the chain, or compose the components so that the intermediate components no longer pass the prop. There is no auto-fix, because the right refactor is a design choice. To suppress one chain, use // fallow-ignore-next-line prop-drilling, or set the rule to off.
Thin wrapper
A React or Preact component whose entire body is return <Child {...props} />. It renders one child component, passes all of its props on through a spread, and adds no markup, hooks, or logic. Every call site could render the child directly. This finding needs the graph across components, so per-file linters cannot see it.
| Severity | Default |
|---|---|
| Off (opt-in) | Yes |
This rule is off by default. To enable it, set thin-wrapper: "warn" (or "error") under rules. Fallow reports the wrapper component (at its definition) and the child that it passes props to. To stay false-positive-safe, fallow skips wrappers that are intentional:
- a
forwardReformemowrapper (the standard way to make a child ref-able or to set a performance boundary) - an exported (public-API) component, for example a library that gives a child its own name
- a context-provider wrapper
- a
cloneElementcall - a render prop or function-as-children
- any wrapper whose body has more than the single spread-forwarded child (a host element, sibling, hook, or branch)
The rule runs only when react, react-dom, next, or preact is a declared dependency.
When to act: Inline the wrapper at its call sites and delete it, or give it a real job (markup, defaults, logic). There is no auto-fix. Suppress the finding with // fallow-ignore-next-line thin-wrapper, or set the rule to off.
Duplicate prop shape
Three or more React or Preact components, in two or more files, that declare the same set of prop names. This suggests that you can extract a shared Props type or a base component. The finding needs the project graph, so per-file linters cannot see it.
| Severity | Default |
|---|---|
| Off (opt-in) | Yes |
This rule is off by default. To enable it, set duplicate-prop-shape: "warn" (or "error") under rules. Fallow reports one finding per member of a group and names the other components with the same shape. To keep noise low, a group needs all of these:
- an exact match of the full set (a superset or subset does not group)
- at least four significant prop names, after fallow removes common DOM-passthrough names (
id,className,children,style, ...) - at least three components in at least two files
So two buttons that only share {label, onClick} never form a group. The rule runs only when react, react-dom, next, or preact is a declared dependency. It skips components whose props fallow cannot read statically (rest spread, imported-interface generics).
When to act: Extract a shared Props type or a base component that the group can reuse. There is no auto-fix, because the right abstraction is a design choice. Suppress the finding with // fallow-ignore-next-line duplicate-prop-shape, or set the rule to off.
Unused load data keys
A key of the object that a SvelteKit load() returns (in +page.ts or +page.server.ts and the .js variants) that no code reads. The sibling +page.svelte does not read data.<key>, and no file in the project reads page.data.<key> (Svelte 5 $app/state) or $page.data.<key> (Svelte 4 $app/stores). A dead returned key still costs a real server-side fetch or DB query on every request, for data that nothing renders. No other static tool finds it. svelte-check types data through the generated $types, but it never flags a returned key that no code reads.
| Severity | Default |
|---|---|
| Warning | Yes |
To stay false-positive-safe, fallow skips a case when it cannot see all usage:
- a
loadbody that fallow cannot read: a spread return, a non-literal or multi-branch return, a computed key, or a wrapped or re-exportedload - a sibling component that passes the whole
dataobject on (data={data},{...data},fn(data),const x = data) - a
+page.server.tswhose universal+page.tssibling reads or forwards itsdataparam - a reflective whole-object read of the page-data store anywhere in the project (
Object.values(page.data)), which makes fallow skip every route
Page-load data goes only to the sibling +page.svelte. A +layout.svelte gets layout-load data, not page-load data, so a data.<key> read there does not count for a page key. The rule runs only when @sveltejs/kit is a declared dependency. It does not cover layout loads (+layout.ts, +layout.server.ts) yet.
When to act: Read the key where you need the data, or remove it from the load() return. If nothing else needs the fetch that produced the key, remove the fetch too. There is no auto-fix, because a load fetch can have side effects and a person must decide. Suppress the finding with // fallow-ignore-next-line unused-load-data-key, or set the rule to off.
Type-only dependencies
Production dependencies (dependencies) that code imports only through import type. The compiler erases type imports, so these packages never load at runtime. Move them to devDependencies.
| Severity | Default |
|---|---|
| Warning | Yes |
When to act: Move the package to devDependencies. This makes the production install smaller and shows that you need the package only for type checking.
Test-only dependencies
Production dependencies (dependencies) that only test files import. Test files match test patterns such as *.test.ts, *.spec.ts, and test directories. Production code never uses these packages, so move them to devDependencies.
| Severity | Default |
|---|---|
| Warning | Yes |
When to act: Move the package to devDependencies. This makes the production install smaller and shows that you need the package only for tests.
Dev dependencies in production
A package in devDependencies that production source code imports with a runtime (value) import. type-only-dependencies and test-only-dependencies find a production dependency that only tests or types need. This rule finds the opposite problem. A production-only install (npm install --omit=dev, pnpm install --prod, yarn workspaces focus --production) leaves out devDependencies, so the import breaks at runtime. The package belongs in dependencies.
| Severity | Default |
|---|---|
| Warning | Yes |
What counts as a production import. An import counts only when all of these are true:
- A runtime entry point reaches the importing file. Repo tooling that you do not ship does not count:
scripts/, benchmarks, playgrounds, and files that only a config-file chain reaches (such as a Rollup config that imports helpers). - The importing file is not a test, story, or fixture file, and not a config file. Fallow uses the same exclusion set as
--productionmode:*.test.*,*.spec.*,*.e2e.*,*.bench.*,*.stories.*,__tests__/,__mocks__/,test/,tests/. - The import is a runtime (value) import. A production file that imports the package only through
import typedoes not trigger the rule, because the build erases type imports.type-only-dependenciesuses the same import-kind analysis.
What is never flagged. Fallow skips a devDependency in these cases:
- another manifest section already provides it at runtime (it is also in
dependencies,peerDependencies, oroptionalDependencies) - it is a workspace package
- it is known dev tooling (
@types/*,typescript, and similar) - it is in
ignoreDependencies
The rule currently checks the root package.json against the import graph of the whole project. Fallow skips imports from files that belong to a workspace package, because the manifest of that workspace controls their runtime resolution. Per-workspace detection is a shared follow-up with the related dependency rules.
Interaction with --production mode. This rule stays on in --production mode. test-only-dependencies needs test files in the graph, but this rule does not. Production mode makes the result cleaner, because every file that fallow finds is production code. A production CI run is also where a broken production-only install matters most.
Known limitation. A file that a package.json scripts command references counts as a runtime entry point. So fallow can still flag a dev-only helper script that value-imports a devDependency. To suppress such a package, add it to ignoreDependencies.
Configuration and suppression. The config key is dev-dependencies-in-production under rules. Set it to "error", "warn" (the default), or "off":
{
"rules": {
"dev-dependencies-in-production": "off"
}
}
This rule has no inline suppression comment (suppressible: false in the capability manifest). That is intentional. The finding points at the line of the package in package.json, and JSON cannot have comments. To silence one package, add it to ignoreDependencies. To silence the whole rule, set it to off.
When to act: Move the package from devDependencies to dependencies. If the flagged import is really dev-only tooling (the script-entry limitation above), add the package to ignoreDependencies instead.
Unused pnpm catalog entries
Packages 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. If a workspace still pins a hardcoded version of the package, switch it to catalog: first.
Empty pnpm catalog groups
Named catalogs.<name>: groups in pnpm-workspace.yaml that have no package entries. Fallow does not report an empty top-level catalog: map, because it can be a harmless placeholder for the default catalog.
| Severity | Default |
|---|---|
| Warning | Yes |
When to act: Remove the empty named group header.
Unresolved pnpm catalog references
Workspace package.json dependencies that use catalog: or catalog:<name>, where the selected catalog does not declare the package.
| Severity | Default |
|---|---|
| Error | Yes |
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
| Severity | Default |
|---|---|
| Warning | Yes |
unused-dependency-overrides reports override entries for a package that no workspace package.json declares and that is not in the lockfile (pnpm-lock.yaml, package-lock.json, npm-shrinkwrap.json, or the bun text lockfile bun.lock). Fallow treats an override as used when it targets a transitive dependency in the lockfile. This is the common pattern to pin a version for a CVE.
When there is no lockfile, fallow checks only package.json. It adds a hint to each finding, so you can review transitive pins before you remove them.
Sometimes the only lockfile next to the root package.json is the legacy bun binary bun.lockb, with no text lockfile that fallow can parse next to it. Fallow never reads a yarn.lock. In this case, fallow cannot read the resolved versions, so it skips the check instead of running a weaker one. The bun-lockb-override-resolution-skipped workspace diagnostic tells you this. It names the lockfiles that fallow looked for and suggests bun install --save-text-lockfile (bun 1.2 or newer), or the removal of a stale bun.lockb.
A lockfile that does not parse also stops the check, when no other lockfile parses. A pnpm-lock.yaml with merge-conflict markers gives the pnpm-lock-override-resolution-skipped workspace diagnostic. A package-lock.json or npm-shrinkwrap.json with merge-conflict markers gives npm-lock-override-resolution-skipped. A bun.lock that does not parse gives bun-lock-override-resolution-skipped. Resolve the conflicts, or run the install command of your package manager to write the lockfile again.
Misconfigured dependency overrides
| Severity | Default |
|---|---|
| Error | Yes |
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 build up as a codebase changes. A suppression that once silenced a real finding can become stale when you fix the issue, move the code, or when another module starts to import an export that was unused.
| Severity | Default |
|---|---|
| Warning | Yes |
Fallow tracks two origins:
- Inline comments (
// fallow-ignore-next-line,// fallow-ignore-file): stale when the target line or file no longer has a matching issue. @expected-unusedJSDoc tags: stale when another module now imports the tagged export. Use/** @expected-unused */to mark exports that are dead code on purpose, so that fallow flags them if code starts to use them 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): a marker can list more than one issue kind. When one token matches no known issue kind, fallow still applies the recognized tokens, and reports each unknown token as a stale suppression. Without this, a typo (unused_export instead of unused-export) or a kind that a newer release renamed would silently discard every suppression on the same line. The explanation of the diagnostic shows the exact token. When a known kind is within Levenshtein distance 2, it also shows a "Did you mean ...?" suggestion.
// 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.
In JSON output, the additive origin.kind_known field on stale_suppressions[].origin separates unknown-kind tokens from known tokens that are stale. The field is present and false only for an unknown kind. It is absent when fallow recognizes the kind.
When to act: Remove the stale suppression comment or JSDoc tag. For an unknown kind, fix the typo or remove the unknown token. The rest of the marker still applies. Stale suppressions add noise and can hide future issues when the code changes again.
Configuration:
{
"rules": {
"stale-suppressions": "error" // default: "warn"
}
}
To show only stale suppression findings, use the --stale-suppressions filter flag.
Required reasons: the opt-in require-suppression-reason rule (default off) reports a missing-suppression-reason finding for each fallow-ignore-* comment or @expected-unused tag without a -- <reason> suffix. Set it to "warn" or "error" under rules to make every suppression explain itself.
Prioritizing findings
Fix findings in this 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 are correctness issues that can cause runtime failures. The others are maintenance and hygiene.
Incremental adoption
Most projects already have dead code. To adopt fallow step by step, use baselines, --changed-since, or enable one issue type at a time. See Adopting fallow.
Common false positive patterns
Fallow detects frameworks and tools from package.json and config files. So it handles most convention-based exports with no setup, for example Next.js pages, Storybook stories, Jest configs, tap suites, tsd declaration tests, and Contentlayer content roots. To see which plugins are active and which entry points they add, run fallow list.
The false positives that remain usually fall 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 | No plugin exists for this framework | Add entry points manually or write a custom plugin |
Before you act on a finding, check it with --trace. A finding is only as correct as the fallow module graph. If code uses an export through a dynamic pattern that fallow cannot resolve, --trace shows it.
# 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: fallow cannot resolve
import(variable)orrequire(variable)when the argument has no static structure. Fallow does resolve template literals with partial paths (import(`./locales/${lang}.json`)) andimport.meta.glob. Only fully opaque variables are a limitation. Add those directories toentry. - Syntactic analysis by default: the default analysis does not run the TypeScript compiler. The opt-in
--type-awareflag adds TypeScript semantic evidence for exact symbol usage. Conditional types, and dead code behind generic constraints, need type resolution to detect. For details, see known limitations. - Side-effect imports (
import './polyfill'): fallow tracks these as file-level edges. It does not flag the imported file as unused, but it still analyzes the individual exports in the file.
Fallow handles monorepo workspaces, framework conventions (Next.js, NestJS, Angular, etc.), and decorator-driven wiring automatically, with auto-detected plugins and workspace detection. For edge cases, see known limitations.
JSON _meta object
When you pass --explain, or run through MCP, the JSON output includes a _meta object:
{
"schema_version": 9,
"_meta": {
"docs": "https://docs.fallow.tools/cli/dead-code",
"rules": {
"unused-file": {
"name": "Unused Files",
"description": "Source files that are not imported by any other module and are not entry points. Detection uses graph reachability from configured entry points.",
"docs": "https://docs.fallow.tools/explanations/dead-code#unused-files"
},
"circular-dependency": {
"name": "Circular Dependencies",
"description": "A cycle in the module import graph.",
"docs": "https://docs.fallow.tools/explanations/dead-code#circular-dependencies"
}
}
}
}
This is an excerpt. _meta.rules has an entry for every issue type, keyed by rule id. _meta also has a field_definitions block that describes shared output fields.
With _meta, AI agents and CI systems can interpret issue types without external documentation.