Skip to content
Fallow home
All docs pages

Custom plugins

Stop false positives from an internal framework or a tool that fallow does not know. A custom plugin is a JSON or TOML file that declares entry points, config files, and used exports.

When fallow does not know an internal framework or tool, it can report the files and exports that the framework uses as unused. A custom plugin fixes this. It is a declarative JSON or TOML file that tells fallow the conventions of your framework.

Start with the JSONC format. It supports $schema and IDE autocomplete.

Quick start

  1. Create the plugin file

    Create a fallow-plugin-<name>.jsonc file in your project root:

    {
      "$schema": "./plugin-schema.json",
      "name": "my-framework",
      "enablers": ["my-framework"],
      "entryPoints": ["src/routes/**/*.{ts,tsx}"],
      "alwaysUsed": ["src/setup.ts"],
      "toolingDependencies": ["my-framework-cli"],
      "usedExports": [
        { "pattern": "src/routes/**/*.{ts,tsx}", "exports": ["default", "loader", "action"] }
      ]
    }

    Fallow discovers fallow-plugin-* files in your project root automatically.

  2. Configure the fields

    Set the plugin fields for your framework. Only name is required. Add the fields that you need.

    Required

    FieldTypeDescription
    namestringUnique plugin name (shown in fallow list --plugins)

    Optional

    FieldTypeDescription
    enablersstring[]Package names that activate this plugin
    entryPointsstring[]Glob patterns for entry point files
    entryPointRolestringCoverage role of the entry points: runtime, test, or support. Defaults to support
    configPatternsstring[]Glob patterns for config files (always used)
    alwaysUsedstring[]Files that fallow always treats as used
    toolingDependenciesstring[]Packages that you use through a CLI, not through imports
    detectionobjectActivation logic with conditions (see below)
    usedExportsobject[]Exports that fallow always treats as used
    usedClassMembers(string | object)[]Class method or property names that the framework calls at runtime. A scoped object { extends?, implements?, members } matches only classes with matching heritage
    manifestEntriesobject[]Entry points that come from framework manifest files (see Manifest-derived entries)
  3. Verify the plugin

    Make sure that fallow loads your plugin. For manifestEntries, also see what the plugin matched and seeded:

    fallow list --plugins            # is the plugin active?
    fallow plugin-check --format json  # what did each rule match, seed, and warn about?

    fallow plugin-check is a read-only dry run. It always exits 0. It reports whether each plugin activated, and the unmet requirement when a plugin did not. For manifestEntries rules, it also reports:

    • The manifests that matched.
    • The when-gate result for each manifest.
    • The entries seeded, with a path_exists flag.
    • Typed warnings[].

Supported formats

FormatExtensionComments
JSONC.jsonc// and /* */
JSON.jsonNo
TOML.toml#

Detection strategies

A plugin activates through simple enablers, or through detection logic with boolean combinators.

Simple detection (enablers)

This is the simplest way to activate a plugin. Fallow checks each package name against package.json. The plugin activates if any enabler matches:

{
  "enablers": ["my-framework", "@myorg/"]  // prefix matching with trailing /
}

A trailing / turns on prefix matching, so @myorg/ matches any package in the @myorg scope.

File-based detection

Activate a plugin when specific config files exist in the project:

{
  "detection": { "type": "fileExists", "pattern": "nuxt.config.*" }
}
Combined detection (boolean logic)

Combine conditions with all (AND) or any (OR). There is no not condition:

{
  "detection": {
    "type": "all",
    "conditions": [
      { "type": "dependency", "package": "@my-org/core" },
      { "type": "fileExists", "pattern": "my-org.config.*" }
    ]
  }
}

This plugin activates only when @my-org/core is installed and a my-org.config.* file exists.

Used exports

In a framework that works by convention, the framework uses some exports by name at runtime. Mark these exports as always used:

{
  "usedExports": [
    { "pattern": "src/routes/**/*.{ts,tsx}", "exports": ["default", "loader", "action", "meta"] }
  ]
}

Used class members

List the class method or property names that the framework calls at runtime through an interface or contract. Fallow adds these names to its built-in Angular and React lifecycle allowlist. It then never flags members that implement these contracts as unused class members.

Each entry is one of these:

  • A plain member name, which suppresses the name everywhere.
  • A scoped object, which matches only classes whose heritage clause includes the configured extends or implements identifier.
{
  "name": "ag-grid",
  "enablers": ["ag-grid-angular"],
  "usedClassMembers": [
    "agInit",
    { "implements": "ICellRendererAngularComp", "members": ["refresh"] }
  ]
}

A plain name like agInit is unique enough to suppress everywhere. A common name like refresh or execute would cause false negatives on unrelated classes, so scope it with implements or extends. A scoped rule needs at least one of extends or implements. Fallow rejects an object rule without either at load time.

Use usedClassMembers for libraries that call methods on your classes through reflection. Examples:

  • The ag-Grid AgFrameworkComponent<T>.
  • The TypeORM MigrationInterface (up, down).
  • Web Components (connectedCallback, disconnectedCallback, attributeChangedCallback).
  • Any strategy or plugin pattern in which the library calls your methods, and your own code does not.

The allowlist applies only to class methods and properties. Fallow still checks enum members with the same names.

For a project-wide allowlist that applies whatever packages are installed, use the top-level usedClassMembers field in your fallow config. See Configuration overview.

Manifest-derived entries

Some frameworks declare their entry points in a manifest file in each package, not in package.json main/exports or a single app entry. A large monorepo can have hundreds of these manifests, and the fields of each manifest decide its entry file. Static entryPoints globs cannot read those fields. With manifestEntries, a plugin gets its entries from the manifests.

Each rule does these steps:

  1. Finds manifest files with a recursive glob.
  2. Parses them (JSON or JSONC).
  3. For every manifest that passes the when gate of the rule, resolves each entries[].path relative to the directory of that manifest into an entry point. The entry point gets the entryPointRole of the plugin.
// fallow-plugin-kibana.jsonc
{
  "name": "kibana",
  "detection": { "type": "fileExists", "pattern": "**/kibana.jsonc" },
  "entryPointRole": "runtime",
  "manifestEntries": [
    {
      "manifests": "**/kibana.jsonc",   // recursive glob selecting manifests
      "when": { "type": "plugin" },      // only manifests whose `type` is "plugin"
      "entries": [
        { "path": "public/index.{ts,tsx}", "when": { "plugin.browser": true } },
        { "path": "server/index.{ts,tsx}", "when": { "plugin.server": true } },
        { "path": "${plugin.extraPublicDirs}/index.{ts,tsx}" }
      ]
    }
  ]
}
FieldTypeDescription
manifestsstringRecursive glob that selects the manifest files to read
format"jsonc" | "json"Manifest format. Defaults to jsonc, which also parses plain JSON
whenobjectGate for the manifest: a map from dotted field path to an expected value or an { "exists": true } / { "exists": false } presence test. Fallow processes the manifest only when ALL conditions match. An empty map matches every manifest
entriesobject[]The entries to seed for each matching manifest (each { path, when? })

entries[].path is a glob relative to the directory of the manifest. It must include its own extension (public/index.{ts,tsx}). Fallow matches entry-point globs literally against discovered files and does not try other source extensions.

The path can contain ${dotted.field} interpolation, which expands over the named manifest field:

  • A string field gives one entry.
  • An array field gives one entry for each element.
  • A missing or empty field gives no entry.

Each entry can have its own when gate.

when matching uses strict equality. { "plugin.browser": true } matches a manifest whose plugin.browser is literally true. Fallow skips that entry for a manifest with plugin.browser: false. To test only that a field is present, use { "plugin.browser": { "exists": true } }. A path can go through an array of objects with [*], and it then matches when any value matches.

Manifest discovery respects .gitignore and skips node_modules. Fallow does not see manifests in ignored directories, and it would not analyze their entries anyway.

Fallow emits a warning when a manifests glob matches nothing, when a when gate excludes every manifest, or when a field path resolves in none of the matched manifests (probably a typo).

To debug a rule that seeds nothing, run fallow plugin-check --format json. For each rule, it shows which manifests matched, which passed the when gate, the entries seeded (with path_exists), and any warnings. The warnings are manifests-matched-none, when-excluded-all, field-path-unresolved, entries-empty, manifest-parse-failed, field-values-limit-exceeded, entry-expansion-limit-exceeded, entry-outside-root, and seeded-paths-missing.

Discovery order

Fallow searches for plugin files in this order:

  1. Explicit paths from the plugins config field
  2. .fallow/plugins/ directory
  3. Project root: fallow-plugin-*.{jsonc,json,toml} files

Using the plugins config field

// .fallowrc.json
{
  "plugins": [
    "tools/fallow-plugins/",
    "vendor/my-plugin.jsonc"
  ]
}

Examples

React Router

{
  "$schema": "./plugin-schema.json",
  "name": "react-router",
  "enablers": ["react-router", "@tanstack/react-router"],
  "entryPoints": ["src/routes/**/*.{ts,tsx}", "app/routes/**/*.{ts,tsx}"],
  "configPatterns": ["react-router.config.{ts,js}"],
  "toolingDependencies": ["@react-router/dev"],
  "usedExports": [
    { "pattern": "{src,app}/routes/**/*.{ts,tsx}", "exports": ["default", "loader", "action", "meta", "handle", "shouldRevalidate"] }
  ]
}

Internal tooling

{
  "$schema": "./plugin-schema.json",
  "name": "our-build-system",
  "enablers": ["@internal/build"],
  "configPatterns": ["build.config.{ts,js}", ".buildrc"],
  "alwaysUsed": ["scripts/build/**/*.ts", "config/**/*.ts"],
  "toolingDependencies": ["@internal/build", "@internal/lint-rules"]
}

External plugins cover most use cases. To parse config files with an AST, as the built-in ESLint and Vite plugins do, you need a built-in Rust plugin.

JSON Schema

For IDE autocomplete, generate the schema locally. Fallow generates the schema on demand, and it is not available at a remote URL:

fallow plugin-schema > plugin-schema.json

Then reference it in your plugin file:

{
  "$schema": "./plugin-schema.json"
}

See also