Feature flags accumulate silently. A codebase that started with 5 flags for a safe launch ends up with 50 a year later — half of them permanently on, never cleaned up, making every code review harder and every new developer more confused.
Paste a directory path and get back every dead flag, where it lives, and the exact refactor needed to remove it safely.
Supports any flag system. Zero external API. Works entirely from your local codebase.
bash
# Option 1: Audit the current directory
/flag-janitor
# Option 2: Specific directory
/flag-janitor src/
# Option 3: With config file
/flag-janitor --config feature-flags.yaml
# Option 4: Specific flag system
/flag-janitor --system launchdarkly
/flag-janitor --system env-var
/flag-janitor --system custom --pattern "isFeatureEnabled\("
# Option 5: Single flag investigation
/flag-janitor --check ENABLE_NEW_CHECKOUT
Identify which flag system(s) are in use by scanning for SDK signatures:
bash
# LaunchDarkly
grep -r "variation\|boolVariation\|LDClient\|ldclient" . \
--include="*.ts" --include="*.js" --include="*.py" --include="*.go" \
--include="*.rb" --include="*.java" -l 2>/dev/null | head -5
# Unleash
grep -r "isEnabled\|getVariant\|UnleashClient\|unleash" . \
--include="*.ts" --include="*.js" --include="*.py" -l 2>/dev/null | head -5
# Flagsmith
grep -r "hasFeature\|getValue\|Flagsmith" . \
--include="*.ts" --include="*.js" --include="*.py" -l 2>/dev/null | head -5
# Environment-variable flags (most common pattern)
grep -rE "process\.env\.[A-Z_]+(_FLAG|_ENABLED|_FEATURE|_TOGGLE)" . \
--include="*.ts" --include="*.js" --include="*.mjs" -h 2>/dev/null | \
grep -oE "[A-Z_]+(_FLAG|_ENABLED|_FEATURE|_TOGGLE)" | sort -u
grep -rE "os\.environ\.get\(['\"][A-Z_]+(_FLAG|_ENABLED|_FEATURE)['\"]" . \
--include="*.py" -h 2>/dev/null | \
grep -oE "[A-Z_]+(_FLAG|_ENABLED|_FEATURE)" | sort -u
# Custom boolean pattern (e.g., flags.isEnabled('my-flag'))
grep -rE "(flags|featureFlags|features)\.(isEnabled|get|check)\(['\"]([a-zA-Z-_]+)['\"]" . \
--include="*.ts" --include="*.js" -h 2>/dev/null | \
grep -oE "['\"]([a-zA-Z-_]+)['\"]" | tr -d "'" | tr -d '"' | sort -u
Collect every flag defined in config files:
bash
# Local flag definitions (if using local config/fallback)
find . -name "*.json" -not -path "*/node_modules/*" | \
xargs grep -l "variations\|flagKey\|rollout" 2>/dev/null | head -10
# SDK initialization file
grep -rn "variation\|allFlagsState" . --include="*.ts" --include="*.js" | \
grep -oE "['\"][a-z][a-z0-9-_]+['\"]" | tr -d "'" | tr -d '"' | sort -u
bash
# From .env files
grep -hE "^[A-Z_]+(_FLAG|_ENABLED|_FEATURE|_TOGGLE)=" .env* 2>/dev/null | \
cut -d= -f1 | sort -u
# From .env.example
grep -hE "^[A-Z_]+(_FLAG|_ENABLED|_FEATURE|_TOGGLE)=" .env.example 2>/dev/null | \
cut -d= -f1 | sort -u
bash
find . -name "flags.yaml" -o -name "feature-flags.json" -o -name "toggles.yaml" \
-not -path "*/node_modules/*" 2>/dev/null | \
xargs grep -hE "^ [a-z][a-z0-9-_]+:" 2>/dev/null | \
sed 's/://g' | tr -d ' ' | sort -u
For each flag found, determine its status:
bash
# Flags forced to always evaluate as true
grep -rn "ENABLE_NEW_UI.*=.*true\|ENABLE_NEW_UI.*===.*true\|ENABLE_NEW_UI.*!==.*false" . \
--include="*.ts" --include="*.js" --include="*.py" | head -20
# Flags where the fallback value IS the flag value (never varies)
# Pattern: boolVariation('flag-key', true) where false branch is dead code
grep -rn "variation.*true.*true\|boolVariation.*,\s*true" . 2>/dev/null | head -10
bash
grep -rn "ENABLE_.*=.*false\|ENABLE_.*===.*false" . \
--include="*.ts" --include="*.js" --include="*.py" 2>/dev/null | head -20
# Also: flags that always return the off-state default
grep -rn "variation.*false.*false\|boolVariation.*,\s*false" . 2>/dev/null | head -10
bash
# Build two sets: flags referenced in code vs flags defined in config
# Then: code_flags - config_flags = referenced but undefined
Cross-reference:
Flags in config but never referenced in source code. Pure dead weight — no code path uses them.
bash
# Flag referenced only in test files
for flag in $ALL_FLAGS; do
PROD_REFS=$(grep -rl "$flag" src/ lib/ app/ 2>/dev/null | grep -v "test\|spec" | wc -l)
TEST_REFS=$(grep -rl "$flag" . 2>/dev/null | grep -E "test|spec" | wc -l)
[ "$PROD_REFS" -eq 0 ] && [ "$TEST_REFS" -gt 0 ] && echo "$flag: test-only"
done
bash
# Don't recommend deleting flags added in last 30 days — they may still be rolling out
git log --since="30 days ago" --diff-filter=A --name-only --format="" -- "*.ts" "*.js" "*.py" | \
grep -E "(flag|feature|toggle)" | head -10
For each non-Class-F flag, determine how long it has been unchanged:
bash
# When was this flag's surrounding code last modified?
git log --follow -n1 --format="%ar" -- "$(grep -rl 'ENABLE_NEW_CHECKOUT' src/ | head -1)"
# How long has the flag been in its current state (on/off)?
git log --all --oneline -- .env | grep "ENABLE_NEW_CHECKOUT" | tail -1
Flag age tiers:
180 days: Dead — safe to remove with test coverage
markdown
## Flag Janitor Report
Project: [name] | $(date) | System: env-var + custom
Flags analyzed: 42 | Dead flags found: 18 | Estimated LOC to remove: ~340
### Summary
| Category | Count | Action |
|----------|-------|--------|
| 🔴 Permanently-ON (hardcoded true) | 7 | Remove flag + keep enabled branch |
| 🔴 Permanently-OFF (hardcoded false) | 3 | Remove flag + delete disabled branch |
| 🟠 Referenced but undefined | 2 | Define in config OR remove from code |
| 🟡 Defined but unreferenced | 4 | Remove from config |
| 🟡 Test-only | 2 | Remove from both tests and config |
| ⚪ Active (< 30 days) | 12 | Skip |
| ✅ Live (varies in production) | 12 | Keep |
---
### 🔴 PERMANENTLY-ON — Remove the flag, keep the behavior
These flags always evaluate to `true`. The feature is always enabled. Remove the flag and
inline the enabled code path.
**`ENABLE_NEW_CHECKOUT`** (185 days old)
- Defined in: `.env.production=true`, `.env.staging=true`, `.env.example=true`
- Referenced in: `src/checkout/CheckoutPage.tsx:14`, `src/cart/CartSummary.tsx:88`
- Status: Always `true` across all environments for 185 days
Safe to remove — inline the `true` branch:
```typescript
// Before:
if (process.env.ENABLE_NEW_CHECKOUT === 'true') {
return <NewCheckout />;
} else {
return <LegacyCheckout />;
}
// After (delete flag + legacy branch):
return <NewCheckout />;
Files to update: src/checkout/CheckoutPage.tsx, src/cart/CartSummary.tsx, .env* files
ENABLE_SEARCH_V2 (211 days old)
src/search/SearchBar.tsx:22, src/pages/Results.tsx:67true in all environments, never been false since day 45ENABLE_BETA_PRICING (290 days old)
false since being disabled 290 days agotrue branch has never run in productionsrc/pricing/PricingPage.tsx:103Safe to remove — delete the flag and the entire true branch (dead code):
typescript
// Before:
if (process.env.ENABLE_BETA_PRICING === 'true') {
return <BetaPricing />; // ← delete this
}
return <StandardPricing />;
// After:
return <StandardPricing />;
ENABLE_SMART_RECOMMENDATIONS
src/recommendations/Engine.ts:45, src/home/HomePage.tsx:112.env, .env.example, or config filesprocess.env.ENABLE_SMART_RECOMMENDATIONS evaluates to undefined, likely behaving as false unintentionallyFix: Add to .env.example with explicit value:
bash
# Recommendations engine (set to 'true' when ready to enable)
ENABLE_SMART_RECOMMENDATIONS=false
These flags exist in config but no code reads them:
FlagDefined InLast ModifiedSafe to Delete?ENABLE_OLD_ANALYTICS``.env.example320 days ago✅ YesFEATURE_DARK_MODE_BETA``flags.yaml:14180 days ago✅ YesENABLE_V1_API_COMPAT``.env.production8 days ago⚠️ Wait (recent)EXPERIMENTAL_EDITOR``flags.yaml:2895 days ago🟡 Review first
bash
# 1. Remove permanently-on flags (keep enabled branch)
# Manual: edit each file listed above
# 2. Remove dead config entries
grep -v "ENABLE_OLD_ANALYTICS\|FEATURE_DARK_MODE_BETA" .env.example > .env.example.tmp
mv .env.example.tmp .env.example
# 3. Remove from flags.yaml
# Edit manually — remove lines 14 and 28
# 4. Confirm after cleanup
grep -rn "ENABLE_OLD_ANALYTICS\|ENABLE_BETA_PRICING\|ENABLE_SEARCH_V2" . \
--include="*.ts" --include="*.js" --include="*.env*" 2>/dev/null
# Should return zero results
MetricBeforeAfterConditional branches from flags4224Lines of dead code~3400Flags to maintain4224.env.example entries3832text
---
## Safe Removal Checklist
Before deleting any flag:
□ Flag is permanently-on or permanently-off for > 90 days □ Git log confirms no recent changes to flag value □ Test suite covers the branch that will remain □ No pending PR branches reference this flag □ Flag is not mentioned in any open issue/ticket □ Remove from: source code, .env files, .env.example, config YAML, test files □ Run tests after removal
text
---
## Quick Mode
Fast count without full analysis:
Flag Janitor Quick Scan: 🔴 7 permanently-on 🔴 3 permanently-off 🟠 2 undefined 🟡 4 dead config entries
Total removable: 16 flags (~280 LOC) Run /flag-janitor for full report with file locations
text
---
## Why Flags Don't Get Cleaned Up
The standard lifecycle is:
1. Engineer adds flag for safe rollout
2. Flag ships to 100% of users
3. **Engineer meant to remove it "next sprint"**
4. "Next sprint" never comes
5. 6 months later, 8 engineers are reading `if (ENABLE_NEW_UI)` wondering if it's safe to delete
This skill automates step 4: it proves a flag is safe to delete (permanently-on/off + old enough) and generates the exact edit needed, reducing the removal from a 30-minute "is it safe?" investigation to a 2-minute confirmation.
---
## Author
**[Canlah AI](https://canlah.ai)** — Run performance marketing without breaking your brand.
- GitHub: [github.com/PHY041](https://github.com/PHY041)
- All Skills: [clawhub.ai/PHY041](https://clawhub.ai/PHY041)