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 or --ci. 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).
An exported namespace that holds only types emits no JavaScript, so Fallow treats it as a type export. This covers a body with only interfaces, type aliases, declare declarations, import type aliases and nested namespaces of the same kind, for example export namespace JSX { export interface Element {} }. When it is unused, it reports under unused types, and it is never an untested export in coverage gaps. A namespace with any other statement, or a namespace that merges with a function, class, enum or variable of the same name, stays a value export.
| 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, this.member usage between getters and actions in the same store, a read through a parameter typed ReturnType<typeof useStore> (or a type alias of that form in the same file), and a read inside the setup function of a setup store, for example count.value in a computed. Pinia needs returned setup state for registration and hydration, so a returned ref that only the setup body reads is live. A plain write such as token.value = x is not a read.
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.
Circular dependencies, re-export cycles, package cycles, boundary violations, and policy violations are architecture findings. fallow architecture reports them. fallow dead-code also reports them until the next major version. To report only the cycles, run fallow architecture --cycles.
| 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. To report only this issue type, run fallow architecture --boundaries.
| 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, effects, or gdp-ts proof producers that violate 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>. To report only this issue type, run fallow architecture --policy.
| Severity | Default |
|---|---|
| Warning | Yes |
The rules."policy-violation" master setting defaults to warn. 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: 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. These checks do not cover require() calls and dynamic import().
gdp-ts proof producer violations
The opt-in gdp-proof-producer rule follows calls to @gdp-ts/core.defineProof through the module graph. It reports factory creation outside the rule's allowedFiles. With proofKinds, you can require a specific literal proof label to be created only in its owner file. Configure both rules in a rule pack.
Fallow recognizes direct, renamed, and namespace imports, including unambiguous re-exports through project modules and workspace packages. A locally shadowed import name does not match. Each recognized call site gets its own finding, with the resolved factory and a static proof label when available. This rule also checks analyzed files unreachable from entry points.
The rule checks JavaScript and TypeScript source, including JSX expressions, Vue and Svelte script blocks, Astro frontmatter, and MDX module statements. It does not check calls in Vue, Svelte, Astro, or MDX template expressions, or in Astro inline client scripts.
Move the factory to an allowed authorization module and expose an operation that performs the permission check. Retain the gdp-ts ESLint or Oxlint rules, including no-exported-prover, and your TypeScript checks. Restricting factory locations does not prevent an allowed module from exporting a prover that callers can invoke without authorization.
Fallow does not trace arbitrary wrappers, local value aliases, require(), or dynamic imports to the factory. It skips optional calls, computed member calls such as gdp["defineProof"](...), and ambiguous re-export origins. When proofKinds contains labels, calls with dynamic labels are skipped. A global producer rule without a label filter still checks their producer locations. This analysis does not verify the authorization check or runtime permission validity.
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.
Absent component props
An optional prop that its component reads, but no inspected reachable caller supplies. Fallow requires at least one known caller and a complete set of statically identified callers in the analyzed project. A component exported for external package users has unknown callers, so fallow skips it. An export used only inside an application can still qualify.
The rule is off by default. To enable it, set absent-component-props to warn or error in rules:
{
"rules": {
"absent-component-props": "warn"
}
}
With warn, these findings do not fail the run unless you pass --fail-on-issues or --ci. With error, they count as error-severity findings. A required prop omitted by a caller remains the responsibility of TypeScript or the framework's type checker. A prop that the component never reads belongs to unused component props.
For example, an application component reads an optional compact prop:
interface CardProps {
title: string;
compact?: boolean;
}
export const Card = ({ title, compact = false }: CardProps) => (
<article className={compact ? "card-compact" : "card"}>{title}</article>
);
If every known caller renders <Card title="Overview" />, compact can be a review candidate. The default may be intentional. Keep a stable API when callers are expected to opt in later.
Supplying compact={false} or compact={undefined} still counts as supplying the prop. Fallow can use a spread only when it can prove which keys the object supplies. An unknown spread can hide a supplied prop, so fallow skips absence findings for that component. Dynamic component selection, unsupported wrappers, and prop forwarding also prevent a complete static caller set.
Supported syntax
Fallow uses these declarations and caller forms when it can resolve the component and prove a complete static caller set. It does not infer optional props from imported or unsupported type shapes. A required typed prop remains required even when a destructure or withDefaults gives it a default.
| Framework | Declarations | Caller syntax | Cases that prevent a finding |
|---|---|---|---|
| React and Preact, including Next.js | JSX function and arrow components with same-file inline types, interfaces, or type aliases; untyped destructure defaults; recognized memo and forwardRef wrappers | JSX attributes and children, including module scope, imported aliases, namespace members, and proven object spreads | Unresolved prop types, unsupported wrappers, component values that escape, or unknown spreads |
| Solid | JSX function and arrow components with recognized Solid bindings and supported local prop contracts | JSX attributes, children, and proven object spreads | Unsupported wrappers, unresolved types, or opaque component use |
| Qwik | Recognized component$ wrappers, including supported same-file generic prop types | JSX attributes, children, and proven object spreads | Unrecognized wrappers, unresolved types, or opaque component use |
| Vue and Nuxt | Typed or runtime defineProps, local named types, withDefaults, and supported Options API props | Static component tags, camel-case and kebab-case bindings, v-model, and literal or proven local constant v-bind | Dynamic or scoped component use, exposed or forwarded props, defineModel, rest props, or unresolved declarations |
| Svelte and SvelteKit | Typed or destructured Svelte 5 $props, untyped defaults, and supported legacy export let props | Static tags, shorthand attributes, bind, paired-tag children, and proven spreads | Dynamic components, scoped component bindings, $bindable, rest props, or forwarding |
| Astro | Same-file Props interfaces or type aliases, with Astro.props reads and destructure defaults | Static tags, explicit attributes, and proven spreads; an Astro caller can target another supported component framework | Dynamic tags, unresolved types, scoped component expressions, or opaque props use |
| Angular | Recognized Component or Directive classes with aliased @Input or signal input declarations | Static selectors and attribute, property, or two-way bindings in supported inline or external HTML templates | Inheritance, unsupported metadata or host directives, model behavior, dynamic outlets, or imperative component use |
| Lit | Direct recognized LitElement or ReactiveElement subclasses with supported property decorators and attribute aliases | Imported html templates, static tags, normalized HTML attributes, and case-sensitive property bindings | Unsupported inheritance or registration, imperative DOM use, injected HTML, dynamic static-HTML templates, or callers through unsupported framework syntax |
| Ember and Glimmer | Direct component subclasses with optional fields in a same-file typed Signature.Args; reads in script or the owning class template | Static Glimmer component tags and named @arguments | Classic registry lookup, unsupported signatures, whole-args forwarding, computed argument access, or component values forwarded through template arguments |
Angular reads in an external templateUrl belong to the class that declares that template. A read in another class's template does not make the prop eligible. Glimmer template reads through @arg or this.args.arg count when the template belongs to the declaring class. Passing a component value to a child through a template argument makes its callers uncertain.
Lit attribute names follow HTML case rules and the property's configured attribute alias. Direct .property bindings use the exact property name, including when attribute: false disables attribute supply. A custom-element caller in Astro, Vue, Svelte, or JSX remains evidence of a caller, but prevents a Lit absence finding when fallow cannot model that syntax bridge.
A proven spread has a known set of own keys and comes from a literal or an immutable local binding. Mutated or escaped objects, imported objects, computed keys, and methods or accessors do not establish complete prop supply. A .tsx filename alone does not establish which framework's component rules apply.
Each finding points to the declaration and the inspected callers. It records whether the declaration has a default. Review these locations and the component's API before changing code. Static caller inspection does not prove that conditional UI is unreachable at runtime.
There is no automatic prop removal or conditional-UI deletion. To keep an intentional prop, suppress the finding at its declaration with fallow-ignore-next-line absent-component-prop, using the comment syntax of that source file. You can also set the rule to off for matching paths in overrides.
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. Set prop-drilling: "warn" under rules for advisory findings, or "error" to fail CI when fallow finds a chain. A warn finding stays advisory even with --fail-on-issues. 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.
You can save existing chains with --save-baseline and gate later runs with --baseline. Fallow identifies a chain by its source path, component, and prop. A change to the route or depth of the same source chain stays inherited. fallow audit --gate new-only gates new source identities; use --gate all to include inherited findings.
Changed-file and changed-line scopes retain an enforced (error) chain when any downstream hop changes, while the reported location stays at the source. Changed-file scopes retain an advisory (warn) chain only when its source file changes. In the editor, both levels remain hints.
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.
Unused Effect layers
An Effect Layer that only other unused layers compose. The layer is a module-scope const (const CacheLive = Cache.layer.pipe(Layer.provide(Db.layer))) or a class static (static readonly layer = Layer.effect(this, make)). No Effect.provide, ManagedRuntime.make, Layer.launch or other runtime use reaches it, so it never runs. The type checker does not see this, because the dead layers still type-check.
| Severity | Default |
|---|---|
| Warning | Yes |
Fallow builds a layer graph from Layer.* initializers and .pipe(Layer.*) chains. Every value reference outside a layer definition is a root. This includes references in functions, tests and scripts. A layer that an entry module exports, directly or through re-exports, is also a root, because code outside the project can use it. A layer with zero references is not reported here: unused-export or unused-class-member reports it. So this rule reports the tail of a dead composition, and the finding names the unused layers that compose it (dead_consumers). The rule runs only when effect is a declared dependency, and only reads modules that import effect or an @effect/* package.
When to act: Remove the unused consumer layers first, then this layer. Or compose the layer into a layer that reaches a runtime. There is no auto-fix. Suppress the finding with // fallow-ignore-next-line unused-effect-layer, or set the rule to off.
Unused tagged errors
An Effect tagged error class (Data.TaggedError("X"), Schema.TaggedError<Self>()("X", ...) or Schema.TaggedErrorClass) that type positions or catchTag("X") handlers reference, but that no code constructs or fails with. The error is in an error channel type, so the type checker accepts it, but the failure never happens.
| Severity | Default |
|---|---|
| Warning | Yes |
Any value reference counts as a producer: new X(), X.make, Schema.Union(X), an API or RPC error declaration that decodes the error at a boundary, or passing the class to a function. Only type positions and catch-tag strings do not count. Fallow follows import aliases, export aliases, namespace members and namespace re-exports. To stay false-positive-safe, fallow skips these classes:
- a class with zero references, which stays an
unused-export - a default-exported class, because an importer can bind it under any name
- a class that an entry module exports, because code outside the project can construct it
- a class whose tag is not a string literal
The finding lists the catchTag handlers for the tag (catch_sites). The rule runs only when effect is a declared dependency.
When to act: Fail with the error where the condition occurs, or remove the class, its type references and its catchTag handlers. There is no auto-fix. Suppress the finding with // fallow-ignore-next-line unused-tagged-error, or set the rule to off.
Unproduced catch tags
A catchTag("X", ...) or catchTags({ X: ... }) handler for a project error tag that no code produces. The handler never runs. Fallow reads catchTag and catchTags on Effect, Stream, Layer and method chains, with data-first calls and tag arrays.
| Severity | Default |
|---|---|
| Warning | Yes |
Fallow reports a handler only when every project class with tag X is unused, and no object literal _tag: "X" (also with as const), class _tag field or tagged constructor produces the tag. A tag that no project class declares can come from a package, so fallow skips it. Fallow also skips Effect built-in tags such as TimeoutException and NoSuchElementException. The rule reports a handler only when no unused-tagged-error finding covers its classes, so one cause gives one finding. You see this finding when unused-tagged-errors is off, or when its finding is suppressed. The rule runs only when effect is a declared dependency.
When to act: Remove the handler, or fail with the error where the condition occurs. There is no auto-fix. If a package or code outside the project produces the tag, suppress the finding with // fallow-ignore-next-line unproduced-catch-tag, 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 and no pnpm override references with the catalog: protocol.
A catalog: value in a pnpm override counts as a reference. The target package of the override uses the catalog entry, so "parent>child": "catalog:x" uses the child entry of catalog x. Fallow reads only the override sources that the declared pnpm version reads. See override sources and the pnpm version.
| 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.
Fallow also checks the catalog: values in pnpm overrides, because pnpm install fails when the catalog does not declare the target package of the override. The finding points to the line of the override in pnpm-workspace.yaml or in the root package.json. Fallow checks only the override sources that the declared pnpm version reads. See override sources and the pnpm version.
| 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.
Override sources and the pnpm version
pnpm reads different override sources in different versions. Fallow reads the pnpm version from the packageManager field of the root package.json. Then it checks only the sources that this version reads. This rule applies to the override checks and to the catalog checks.
| Source | When fallow skips it |
|---|---|
overrides in pnpm-workspace.yaml | pnpm before 10.5.1. Also pnpm 10 or earlier when the root package.json declares non-empty pnpm.overrides or resolutions |
pnpm.overrides in the root package.json | pnpm 11 and later |
top-level resolutions in the root package.json (catalog checks) | pnpm 11 and later, and projects without a pnpm version |
When packageManager has no pnpm version, fallow reads pnpm-workspace.yaml overrides and pnpm.overrides. A version without a minor or patch, such as pnpm@10, does not count as a version before 10.5.1. A prerelease comes before its release, so pnpm@10.0.0-rc.3 is before 10.5.1.
A skipped pnpm-workspace.yaml section gives no override findings, and its catalog: values do not count as catalog references. pnpm prints no warning for this case, so fallow records the pnpm-workspace-overrides-ignored workspace diagnostic. Its cause field tells why:
pnpm-version: the pnpm version is before 10.5.1. UpdatepackageManagerto pnpm 10.5.1 or later, or move the entries intopnpm.overridesin the rootpackage.json.package-json-overrides: the pnpm version is 10 or earlier, and the rootpackage.jsondeclarespnpm.overridesorresolutions. Move the entries into one source.
When both causes apply, cause is pnpm-version.
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": 10,
"_meta": {
"docs": "https://fallow.tools/docs/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://fallow.tools/docs/explanations/dead-code/#unused-files"
},
"circular-dependency": {
"name": "Circular Dependencies",
"description": "A cycle in the module import graph.",
"docs": "https://fallow.tools/docs/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.