fallow flags
Find the feature flags in your codebase, so you can review them. Fallow flags detects environment variable gates, SDK calls, and config-based toggles.
fallow flags finds the feature flags in your codebase, so you know where each flag is and which ones to review. For each feature flag, fallow reports the location and the detection confidence, and links it to dead code findings.
fallow flags
Options
Output
| Flag | Description |
|---|---|
--top <N> | Show only the top N feature flags. With --retirement, it also limits the JSON rows and the candidates in the human section. |
-f, --format <FORMAT> | Output format: human (default), json, sarif, compact, markdown, codeclimate, gitlab-codequality. Other formats exit 2. |
-q, --quiet | Suppress progress output |
--explain | Add metric explanations. In JSON format, adds a _meta object with descriptions and docs links. |
Retirement
| Flag | Description |
|---|---|
--retirement | Add a retirement report: one row per flag, with the reasons that the flag can be retired. Every output format supports it. See Output formats. |
--reason <CODE> | Keep only the rows with this reason. Repeat the flag for more reasons. |
--min-age <DAYS> | Keep only the flags that are at least this many days old. A flag without an age does not pass. With --flag-age off, the command exits 2. |
--sort <KEY> | Row order: age (default, oldest first), sites (fewest read sites first) or name |
--flag-age <MODE> | How to measure flag age: blame (default), pickaxe or off. See Flag age. |
--flag-state <FILE> | Read a vendor flag export and add the vendor reasons. See Vendor flag state. |
--max-flag-age <DAYS> | Exit 1 when a flag in scope is older than this many days. Opt-in. With --flag-age off, the command exits 2. Without git history, the gate is skipped. See Flag age. |
These options need --retirement.
Scoping
| Flag | Description |
|---|---|
-r, --root <PATH> | Project root directory (default: current working directory) |
-c, --config <PATH> | Path to config file (default: auto-detected) |
-w, --workspace <PATTERNS> | Scope output to one or more workspaces. Accepts exact package names, globs, and !-prefixed negation. Pass comma-separated values or repeat the flag. |
--changed-workspaces <REF> | Scope output to the workspaces that contain a file changed since REF. Cannot be combined with --workspace. |
--changed-since <REF> | Report only feature flags in files changed since a git ref |
--production | Production mode: exclude test, story, and dev files |
--no-production | Turn production mode off, even when the project config sets production: true (conflicts with --production) |
Performance
| Flag | Description |
|---|---|
--no-cache | Disable incremental caching and parse all files again |
--threads <N> | Number of parser threads (default: available parallelism) |
CI
| Flag | Description |
|---|---|
fallow flags reports feature flags for review. Without --retirement it has no quality gate, so it always exits 0 after a successful run. With --retirement, the regression gate and --max-flag-age can exit 1. --ci, --fail-on-issues, --sarif-file, and --output-file are not valid with fallow flags and exit 2. To get a SARIF file, use --format sarif and redirect the output. |
Regression
The regression options work on fallow flags only together with --retirement:
| Flag | Description |
|---|---|
--save-regression-baseline <PATH> | Write a baseline file with a flags section: total_flags, distinct_flags and a count for each reason. The PATH is necessary, because the config file holds no flags baseline. |
--fail-on-regression | Exit 1 when distinct_flags grows more than --tolerance. Each --reason code adds the count of that reason to the gate. |
--regression-baseline <PATH> | The baseline file to compare with. The gate needs it. |
--tolerance <N> | Allowed growth: an absolute count such as 2, or a percentage such as 5%. Default 0. |
A run with --changed-since or --workspace skips the gate and saves no baseline, because its counts do not compare with a whole-project baseline. The verdict is in retirement.regression in the JSON output. The verdict of each gate also prints on stderr in every output format, so a run that exits 1 always tells you why.
# On main: record the flag counts
fallow flags --retirement --flag-age off --save-regression-baseline .fallow/flags-baseline.json
# In a pull request: fail when the PR adds a flag, or a new test-only flag
fallow flags --retirement --flag-age off --fail-on-regression \
--regression-baseline .fallow/flags-baseline.json --reason test-only
Without --retirement, the regression options have no effect on fallow flags, write no file, and the exit code stays 0. The command prints a warning in that case. --baseline, --save-baseline, --summary, --group-by, and --performance have no effect on this command.
Detection categories
Fallow detects three categories of feature flag patterns:
| Category | What it finds | Examples |
|---|---|---|
| Environment variable flags | process.env.* and import.meta.env.* checks used as feature gates | process.env.FEATURE_NEW_UI, import.meta.env.VITE_ENABLE_CHAT |
| SDK calls | Feature flag SDK method calls from known providers (LaunchDarkly, Statsig, Unleash, GrowthBook, Split, PostHog, Vercel Flags, ConfigCat, Flagsmith, Optimizely, Eppo) | LaunchDarkly variation(), Statsig checkGate(), PostHog useFeatureFlagEnabled(), Vercel Flags flag({ key }) |
| Config object patterns | Object property lookups used as boolean guards (opt-in) | config.featureX, settings.enableNewFlow |
Each detected feature flag has a confidence level. It shows how sure fallow is that the pattern is a feature flag.
| Confidence | When fallow uses it |
|---|---|
high | An environment variable flag, or an SDK call to a provider-specific name, such as useFlag() or checkGate(). |
medium | An SDK call to a generic name (isEnabled(), getValue() or useFeature()) in a file that imports no flag SDK and no flag module. Other libraries, such as form libraries, use the same names. |
low | A config object pattern. |
A flag SDK or a flag module is an import or a top-level require() whose source contains flag, feature or toggle, or the name of a known provider, such as @unleash/proxy-client-react. When the file has such an import, a generic name has high confidence. To keep high confidence for your own isEnabled() in every file, add the name to sdkPatterns.
Flag names from a registry
An SDK call can name its flag through a registry of keys, such as useFlag(FLAGS.NewCheckout) or useFlag(FLAGS['NewCheckout']). Fallow reports the key that the registry member holds. The registry must be one of these:
- A module-level
as constobject with string values:const FLAGS = { NewCheckout: 'new-checkout' } as const. - An enum with string values:
enum FLAGS { NewCheckout = 'new-checkout' }.
The registry can be local or imported. A relative import names the file that declares the registry. An import through a path alias or a barrel file resolves when exactly one registry in the project has the imported name. Fallow does not report a call that it cannot resolve.
Guards
A flag read that controls a block of code is a guard. Fallow uses the guarded lines to find unused exports inside them, and reports the match in dead_code_overlap. These shapes are guards:
- The test of an
ifstatement or a ternary, for exampleif (useFlag('beta')). flag && <Component />in JSX.if (!flag) return. This guards the rest of the enclosing block.- A
constbinding that holds one flag read, such asconst enabled = useFlag('beta'). The firstif, ternary, or JSX&&that tests the binding is its guard.
Retirement report
fallow flags --retirement groups the sites of each flag into one row and lists the reasons that the flag can be retired. The report is advisory. Fallow does not remove code, and every action has auto_fixable: false. You decide which flags to remove.
The identity of a flag is its detection kind, its SDK provider and its name. In a monorepo, the workspace root is also part of the identity, so each workspace gets its own row.
Reasons
| Reason | What it means |
|---|---|
single-read-site | The flag has exactly one read site in the project. |
test-only | Every read site is in a test, story or mock file. |
literal-constant | The flag is a module-level const with a flag prefix and a literal value, such as const FEATURE_NEW_UI = true, and a guard in the same module tests it. |
identical-branches | Both branches of the guard are the same code. Whitespace and comments do not count. |
empty-branch | No branch of the guard holds code, so the flag does nothing, as in if (flag) {} or flag ? null : <></>. An empty branch is {}, ;, null, undefined, void 0, <></>, or false next to JSX. A missing else is also empty. flag ? <New /> : null and flag ? null : <Old /> are plain gating and do not count. |
guards-dead-code | The guarded block holds unused exports. |
defined-never-read | The flag is defined but no code reads it: a Vercel flag() definition whose export is unused, or an unused member of an exported flag registry enum. |
single-read-site, test-only and defined-never-read count every read of the flag in the project. Reads in other workspaces and reads outside --changed-since or --workspace also count.
These reasons come from the code only. They do not tell you that a flag is on or off in production. Use --flag-state to add the state from your flag provider, or check the state in the provider before you remove the flag.
With --flag-state, these reasons are also available:
| Reason | What it means |
|---|---|
fully-rolled-out | The vendor state is rolled_out, or the export says that the flag serves one variation. |
archived-in-vendor | The vendor state is archived. |
missing-in-vendor | The code reads the flag, but the export does not hold its key. This can be a stale key or a typo. |
vendor-only | The export holds the key, but no code in the project reads it. The row has the kind vendor_export and no sites. |
A literal const flag gets the kind constant. It shows in the retirement report only, not in feature_flags[]. The binding must be the value of the test, as in if (FEATURE_X) or if (FEATURE_X === 'on').
Flag age
Each row also gives the age of the flag from git.
blame(default) runsgit blameon the lines of the flag sites. The age counts from the oldest line that still holds the flag. A rewrite of a line resets its date, so this age is a lower bound.pickaxerunsgit log -S<name>for each flag name and setsfirst_seento the first commit that added the name. This mode is slower on a long history.offmeasures no age and starts no git process.
Ages count days to the analysis clock: the commit time of HEAD, or FALLOW_CLOCK_EPOCH when you set it. Two runs on the same commit give the same ages. Fallow keeps the results in its cache directory for the current HEAD, so a second run starts no git blame.
In a shallow clone, the age is null and workspace_diagnostics[] has a flag-age-shallow-clone entry. Run git fetch --unshallow for the full history. Outside a git repository, or on a branch without commits, the entry is flag-age-unavailable. In these cases generated_at_clock is also null.
Without git history, --max-flag-age cannot check a flag. The gate then has the status skipped in retirement.max_flag_age.status, the command prints Flag age check skipped on stderr, and the run does not fail. The default checkout of GitHub Actions is a shallow clone with depth 1, so set fetch-depth: 0 on actions/checkout for this gate:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- run: npx fallow flags --retirement --max-flag-age 180
retirement.max_flag_age.unmeasured counts the flags in scope without an age. The gate does not check these flags.
Vendor flag state
--flag-state <FILE> reads a local JSON file with the state of each flag in your flag provider. Fallow reads the file offline. It uses no credentials, makes no network calls and has no vendor clients. You make the file from the export of your vendor, for example with one of the jq recipes below.
The file has one vendor-neutral schema:
{
"schema_version": 1,
"source": "launchdarkly",
"exported_at": "2026-09-20T00:00:00Z",
"flags": [
{
"key": "new-checkout",
"state": "rolled_out",
"serves_single_variation": true,
"created_at": "2026-01-10T00:00:00Z",
"last_evaluated_at": "2026-09-19T00:00:00Z"
}
]
}
| Field | Required | Description |
|---|---|---|
schema_version | yes | Always 1 |
source | yes | The vendor name, for example launchdarkly |
exported_at | yes | When you made the export. It must start with a YYYY-MM-DD date. |
flags[].key | yes | The flag key. Each key can occur one time only. |
flags[].state | yes | on, off, rolled_out, archived or experiment |
flags[].serves_single_variation | no | true when every user gets the same variation |
flags[].created_at | no | Creation date, as the vendor gives it |
flags[].last_evaluated_at | no | Last evaluation date, as the vendor gives it |
Fallow rejects an unknown field, an unknown state, a duplicate key and a file larger than 16 MiB. The command then exits 2 with the error code FALLOW_FLAG_STATE_INVALID.
How the export matches the code:
- Only SDK flags match the export. Environment variables and config objects do not.
- When
sourcenames an SDK in the project, such aslaunchdarklyforLaunchDarkly, only the flags of that SDK match. Thus a LaunchDarkly export does not mark your Statsig flags asmissing-in-vendor. - The
flags.vendorKeyPrefixconfig key removes a prefix from each vendor key before the match. For example,"web."makesweb.new-checkoutmatch the code flagnew-checkout. - The SDK check reads every SDK site of the project. Thus a run with
--changed-sinceor--workspacegives a flag the same vendor reasons as a full run. vendor-onlyrows need a whole-project run. A run with--changed-sinceor--workspaceadds none. These rows do not count insummary.distinct_flags, so a key that exists only in the vendor does not change the flag count of the code, and it does not trip the regression gate.- When the export file is outside the project root, for example in a CI temp directory, the evidence and the SARIF and CodeClimate locations show its file name only. All output paths stay relative to the project root.
The human output warns when the export is more than 30 days old. The JSON output gives the age in retirement.vendor_state.export_age_days.
jq recipes
Each recipe turns the JSON of the vendor REST API into the schema above. The field names follow the vendor APIs at the time of writing, so compare them with your export. Set state to rolled_out or serves_single_variation to true when your vendor data shows that a flag serves one variation, because that gives the strongest reason.
The list endpoints of most vendors return one page of flags per request. An export with only the first page marks every flag on the other pages as missing-in-vendor, and it adds no vendor-only row for those keys. Get every page before you run a recipe:
- LaunchDarkly returns 20 flags per page by default. Set
limit, and follow_links.nextuntil it is absent.totalCountgives the number of flags. - GrowthBook pages with
limitandoffset. Request pages untilhasMoreisfalse. - PostHog returns about 100 flags per page. Follow the
nextURL until it isnull. - For another vendor, read its API docs for pagination.
Then merge the pages into one file with the same shape as one page, and give that file to the recipe. For example, for LaunchDarkly pages saved as page-1.json, page-2.json and so on:
jq -s '{items: [.[].items[]]}' page-*.json > launchdarkly-flags.jsonFor GrowthBook use .features instead of .items, and for PostHog use .results. Compare the number of keys in flag-state.json with the flag count in your vendor before you trust missing-in-vendor.
LaunchDarkly
# GET /api/v2/flags/{projectKey}?env=production
jq --arg env production '{
schema_version: 1,
source: "launchdarkly",
exported_at: (now | todate),
flags: [.items[] | {
key,
state: (if .archived then "archived"
elif .environments[$env].on then "on"
else "off" end),
created_at: (.creationDate / 1000 | floor | todate)
}]
}' launchdarkly-flags.json > flag-state.json
Unleash
# GET /api/admin/features
jq '{
schema_version: 1,
source: "unleash",
exported_at: (now | todate),
flags: [.features[] | {
key: .name,
state: (if .enabled then "on" else "off" end),
created_at: .createdAt,
last_evaluated_at: .lastSeenAt
}]
}' unleash-features.json > flag-state.json
GrowthBook
# GET /api/v1/features
jq --arg env production '{
schema_version: 1,
source: "growthbook",
exported_at: (now | todate),
flags: [.features[] | {
key: .id,
state: (if .archived then "archived"
elif .environments[$env].enabled then "on"
else "off" end),
created_at: .dateCreated
}]
}' growthbook-features.json > flag-state.json
PostHog
# GET /api/projects/{project_id}/feature_flags
jq '{
schema_version: 1,
source: "posthog",
exported_at: (now | todate),
flags: [.results[] | {
key,
state: (if .deleted then "archived"
elif (.active and ((.filters.groups // []) | length > 0)
and all(.filters.groups[]; .rollout_percentage == 100
and ((.properties // []) | length == 0)))
then "rolled_out"
elif .active then "on"
else "off" end),
created_at
}]
}' posthog-flags.json > flag-state.json
For another vendor, or for an in-house flag service, write the same schema from its data. Set source to a name that is not an SDK label, such as in-house, to match every SDK flag.
Retirement output formats
Every format of fallow flags supports --retirement. The per-site output does not change, and the candidates come after it. A row without a reason is not a candidate and is not in these formats.
| Format | Retirement output |
|---|---|
human | A "Retirement candidates" section with one line per flag. --top limits the candidates. |
json | The retirement object. --top limits the rows. |
compact | One flag-retire:<reason>:<path>:<line>:<name> line for each reason |
sarif | The rule fallow/flag-retirement-candidate at level note, with one result per candidate at its first read site |
codeclimate | One fallow/flag-retirement issue with severity info for each reason. The fingerprint comes from the flag identity and the reason, not from the line. |
markdown | A "Retirement candidates" table with the flag, age, read sites and reasons |
Configuration
Fallow has built-in detectors for the common providers and env-var conventions, so most projects need no configuration.
If fallow flags reports No feature flags detected but your project uses feature flags, your SDK or env prefix is probably not in the defaults. Add it in the flags section of .fallowrc.json, .fallowrc.jsonc, fallow.toml, or .fallow.toml.
When you run fallow flags with all defaults and fallow finds nothing, the CLI lists the built-in env prefixes and SDKs that it checked. Use this list to tell a true negative from a missing detector.
The configuration reference documents the flags fields (sdkPatterns, envPrefixes, configObjectHeuristics, vendorKeyPrefix). Fallow merges custom sdkPatterns and envPrefixes with the built-in sets. They never replace the built-in sets.
Fallow applies the custom patterns when it parses a file, and the parse cache keys on them. After a change to the flags section, the next run parses every file again.
{
"flags": {
"sdkPatterns": [
{ "function": "useMyFlag", "nameArg": 0, "provider": "InHouse" }
],
"envPrefixes": ["NEXT_PUBLIC_FEATURE_", "MYAPP_ENABLE_"],
"configObjectHeuristics": true
}
}
Examples
# Detect all feature flags
fallow flags
# Show only the top 10 flags
fallow flags --top 10JSON output
{
"kind": "feature-flags",
"schema_version": 8,
"version": "3.30.0",
"elapsed_ms": 116,
"feature_flags": [],
"total_flags": 0
}
Key fields
| Field | Type | Description |
|---|---|---|
kind | string | Always feature-flags |
schema_version | integer | JSON schema version |
version | string | The fallow version that produced the output |
elapsed_ms | integer | Analysis duration in milliseconds |
feature_flags | array | Detected feature flag patterns |
total_flags | integer | Total number of detected feature flags |
With --retirement, the output also has a top-level retirement object. Without the option, the key is not there and the output does not change.
{
"kind": "feature-flags",
"schema_version": 8,
"feature_flags": ["..."],
"total_flags": 14,
"retirement": {
"generated_at_clock": "2026-09-25T00:00:00Z",
"age_mode": "blame",
"summary": {
"distinct_flags": 9,
"candidates": 2,
"by_reason": { "single-read-site": 2, "test-only": 1 }
},
"flags": [
{
"flag_name": "FEATURE_OLD_CHECKOUT",
"kind": "environment_variable",
"sites": [
{ "path": "src/checkout.test.ts", "line": 12, "col": 6, "role": "read", "in_test": true }
],
"read_sites": 1,
"test_only": true,
"first_seen": null,
"oldest_surviving_site": { "commit": "abc1234def56", "date": "2026-02-01" },
"last_touched": { "commit": "abc1234def56", "date": "2026-02-01" },
"age_days": 236,
"reasons": ["single-read-site", "test-only"],
"evidence": [
{ "reason": "single-read-site", "path": "src/checkout.test.ts", "line": 12, "detail": "the flag has one read site" },
{ "reason": "test-only", "path": "src/checkout.test.ts", "line": 12, "detail": "the only read site is in a test, story or mock file" }
],
"actions": [
{ "type": "review-retirement", "auto_fixable": false, "description": "Review this flag for retirement. The evidence lists the reasons." }
]
}
]
}
}
| Field | Description |
|---|---|
retirement.summary | Totals for every flag in scope, before --reason and --min-age. distinct_flags counts the flags in the code only; by_reason["vendor-only"] counts the keys that only the export holds. |
retirement.flags[].sites[].role | read, or definition for a site that defines the flag. A definition is not a read site. |
retirement.flags[].workspace | Workspace root of the flag, when the project has workspaces |
retirement.flags[].reasons | Reason codes. The set is open, so ignore a code that you do not know. A row with an empty array is not a candidate. |
retirement.flags[].age_days | Days from the oldest known commit to the analysis clock, or null |
retirement.flags[].vendor | With --flag-state: the vendor key, state and the optional vendor fields of the flag |
retirement.vendor_state | With --flag-state: source, exported_at, export_age_days and the number of flags in the export |
retirement.regression | With --fail-on-regression: status (pass, exceeded or skipped), tolerance, tolerance_kind, metrics[] (metric, baseline, current, delta, exceeded) and exceeded |
retirement.max_flag_age | With --max-flag-age: status (pass, exceeded or skipped), max_days, exceeded, unmeasured (flags in scope without an age), reason (only when skipped) and the flags[] that are older, oldest first |
With --changed-since, the output also has a root request_outcomes object with the changed-since entry. When fallow cannot resolve the ref, the entry says so, and the report covers the whole project.
MCP tool
The feature_flags MCP tool wraps fallow flags --format json --quiet --explain:
{
"tool": "feature_flags",
"arguments": {
"top": 10
}
}
Set retirement: true to add the retirement object. flag_state is the path of a vendor export (a relative path resolves against root), and flag_age is blame, pickaxe or off. The regression gates are CLI only.
The tool returns the same JSON envelope as the CLI. For setup instructions, see MCP integration.