The Dead Code Spiral
Every React project follows the same trajectory:
- Week 1: Clean codebase. Every export is used. Every route is live. Every CSS class is referenced.
- Month 3: You refactor a component. The old export stays. Nobody removes it because "something might import it."
- Month 6: The designer removes a section. The CSS classes survive. The route handler stays "just in case."
- Month 12:
deadcode scanfinds 347 findings. You schedule a cleanup sprint. It gets deprioritized. - Month 18: Bundle size is 40% dead weight. New developers can't tell what's used and what isn't.
The problem isn't the scanning — it's the when. Quarterly scans create a debt spiral. You need continuous detection that catches dead code as it appears, before it compounds.
That's what deadcode scan --fail does. It runs in CI, counts findings, and exits with code 1 when your project crosses the threshold. Dead code doesn't accumulate — it gets caught at the PR level.
Quick Start: Your First CI Gate
Step 1: Install DeadCode
# pip
pip install git+https://github.com/Coding-Dev-Tools/deadcode.git
# Scan your project
deadcode scan -p /path/to/react-project
Step 2: Set a Baseline
# Count current dead code
deadcode stats -p /path/to/react-project
# Example output:
# Files scanned: 142
# Unused exports: 23
# Dead routes: 5
# Orphaned CSS: 34
# Unreferenced components: 12
# Total findings: 74
You have 74 findings today. That's your baseline. Set the threshold slightly above it — say 80 — so new dead code fails CI but existing debt doesn't block your team immediately:
Step 3: Configure .deadcode.yml
# .deadcode.yml
ignore:
- "generated/"
- "src/legacy/"
- "**/*.generated.tsx"
categories:
- unused_export
- dead_route
- orphaned_css
- unreferenced_component
# Exit code 1 if findings >= this number
fail_threshold: 80
Step 4: Add to CI
# .github/workflows/deadcode.yml
name: Dead Code Check
on: [pull_request]
jobs:
deadcode:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install DeadCode
run: pip install git+https://github.com/Coding-Dev-Tools/deadcode.git
- name: Scan for dead code
run: deadcode scan -p . --fail 80
Now every PR is checked. If a developer adds 7 new dead exports, the total hits 81 — and CI fails with:
DeadCode Scan — 142 files scanned
Unused Exports (30)
┌──────────────────────┬──────┬─────────────────┬──────────────────────────────┐
│ File │ Line │ Name │ Detail │
├──────────────────────┼──────┼─────────────────┼──────────────────────────────┤
│ src/utils/format.ts │ 12 │ formatDateOld │ Not imported anywhere │
│ src/hooks/useAuth.ts │ 45 │ useAuthLegacy │ Not imported anywhere │
│ ... │ │ │ │
└──────────────────────┴──────┴─────────────────┴──────────────────────────────┘
Dead Routes (5) · Orphaned CSS (36) · Unreferenced Components (10)
Total: 81 findings (54 removable)
❌ FAIL: 81 findings >= threshold of 80
The Four Categories of Dead Code
DeadCode detects four types of dead code, each with its own failure pattern and cleanup strategy:
| Category | What It Catches | Typical Cause | CI Severity |
|---|---|---|---|
unused_export |
Exported functions, types, constants that no file imports | Refactoring left-overs, feature removals | Low — safe to remove |
dead_route |
Route handlers with no navigation path leading to them | Page removals, route renames | High — users see 404s |
orphaned_css |
CSS classes not referenced in any component or template | Design changes, component deletions | Medium — bundle bloat |
unreferenced_component |
React components not rendered in any other component | Page/component removals, feature flags | Medium — bundle bloat |
Five CI/CD Patterns for Dead Code Enforcement
Pattern 1: Threshold Gate (Simple)
Block merges when total findings exceed a number. Good for teams new to dead code detection.
# .github/workflows/deadcode.yml
- name: Dead code gate
run: deadcode scan --fail 50
Start with a generous threshold above your current count. Tighten it over time as you clean up.
Pattern 2: Category-Specific Gates (Targeted)
Some categories are more critical than others. Fail CI on dead routes immediately, but allow some unused exports:
# .github/workflows/deadcode.yml
jobs:
deadcode:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: pip install git+https://github.com/Coding-Dev-Tools/deadcode.git
# Dead routes: zero tolerance
- name: Check dead routes
run: deadcode scan -c dead_route --fail 1
# Unused exports: allow up to 30
- name: Check unused exports
run: deadcode scan -c unused_export --fail 30
# Orphaned CSS: allow up to 20
- name: Check orphaned CSS
run: deadcode scan -c orphaned_css --fail 20
Pattern 3: PR Comment with Findings (Informational)
Don't block the PR — just post the findings as a comment so reviewers can see them:
# .github/workflows/deadcode-comment.yml
name: Dead Code Report
on: [pull_request]
jobs:
report:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: pip install git+https://github.com/Coding-Dev-Tools/deadcode.git
- name: Generate JSON report
run: deadcode scan --json-output > /tmp/deadcode-report.json
- name: Post PR comment
run: |
TOTAL=$(python3 -c "import json; d=json.load(open('/tmp/deadcode-report.json')); print(len(d['findings']))")
if [ "$TOTAL" -gt 0 ]; then
echo "## DeadCode found ${TOTAL} findings" > /tmp/comment.md
echo '```' >> /tmp/comment.md
deadcode scan >> /tmp/comment.md 2>&1 || true
echo '```' >> /tmp/comment.md
gh pr comment ${{ github.event.pull_request.number }} --body-file /tmp/comment.md
fi
Pattern 4: Trend Tracking with Baselines
Save scan results over time and track whether dead code is increasing or decreasing. Run weekly on main:
# .github/workflows/deadcode-trend.yml
name: Dead Code Trend
on:
schedule:
- cron: '0 9 * * 1' # Monday 9 AM
jobs:
trend:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: pip install git+https://github.com/Coding-Dev-Tools/deadcode.git
- name: Save weekly baseline
run: |
deadcode scan --json-output > deadcode-baseline-$(date +%Y-%m-%d).json
git add deadcode-baseline-*.json
git commit -m "chore: weekly deadcode baseline" || true
git push
- name: Compare with last week
run: |
LAST=$(ls -t deadcode-baseline-*.json | sed -n '2p')
CURRENT=$(ls -t deadcode-baseline-*.json | head -1)
if [ -n "$LAST" ]; then
PREV_COUNT=$(python3 -c "import json; print(len(json.load(open('$LAST'))['findings']))")
CURR_COUNT=$(python3 -c "import json; print(len(json.load(open('$CURRENT'))['findings']))")
echo "Last week: ${PREV_COUNT} findings"
echo "This week: ${CURR_COUNT} findings"
DIFF=$((CURR_COUNT - PREV_COUNT))
if [ $DIFF -gt 0 ]; then
echo "::warning::Dead code increased by ${DIFF} findings this week"
elif [ $DIFF -lt 0 ]; then
echo "::notice::Dead code decreased by $((-DIFF)) findings this week"
else
echo "No change in dead code count"
fi
fi
Pattern 5: Progressive Threshold Tightening
Start with a generous threshold, then tighten it automatically as the team cleans up. Each time the count drops below the threshold minus 10, lower the threshold:
# .github/workflows/deadcode-progressive.yml
name: Dead Code Gate (Progressive)
on: [pull_request]
jobs:
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: pip install git+https://github.com/Coding-Dev-Tools/deadcode.git
- name: Load current threshold
id: threshold
run: |
THRESHOLD=$(python3 -c "import yaml; print(yaml.safe_load(open('.deadcode.yml')).get('fail_threshold', 100))")
echo "threshold=$THRESHOLD" >> $GITHUB_OUTPUT
- name: Check dead code
id: scan
run: |
deadcode scan --fail ${{ steps.threshold.outputs.threshold }}
# If we reach here, the scan passed
TOTAL=$(deadcode scan --json-output | python3 -c "import sys,json; print(len(json.load(sys.stdin)['findings']))")
echo "count=$TOTAL" >> $GITHUB_OUTPUT
- name: Auto-tighten threshold
if: steps.scan.outputs.count < steps.threshold.outputs.threshold - 10
run: |
NEW=$(( steps.scan.outputs.count + 5 ))
echo "Dead code at ${{ steps.scan.outputs.count }}, tightening threshold to $NEW"
python3 -c "
import yaml
with open('.deadcode.yml') as f: config = yaml.safe_load(f)
config['fail_threshold'] = $NEW
with open('.deadcode.yml', 'w') as f: yaml.dump(config, f)
"
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add .deadcode.yml
git commit -m "chore: tighten deadcode threshold to $NEW"
git push
The --fail Flag: How It Works
The --fail flag is the CI workhorse. It sets a numeric threshold — if the scan finds that many findings or more, it exits with code 1:
# Exit 1 if 10+ findings
deadcode scan --fail 10
# Exit 1 if ANY dead routes found
deadcode scan -c dead_route --fail 1
# Override .deadcode.yml threshold for this run
deadcode scan --fail 0 # Zero tolerance
The flag overrides the fail_threshold in .deadcode.yml, so you can have different thresholds for different branches or workflows.
Exit code behavior: When findings ≥ threshold, DeadCode prints the full table output plus a FAIL message, then exits with code 1. When findings < threshold, it exits with code 0. This works with any CI system — GitHub Actions, GitLab CI, Jenkins, CircleCI — because they all check exit codes.
JSON Output for Custom Pipelines
The --json-output flag produces machine-readable JSON for custom processing:
deadcode scan --json-output
Output structure:
{
"files_scanned": 142,
"findings": [
{
"file": "src/utils/format.ts",
"line": 12,
"name": "formatDateOld",
"category": "unused_export",
"detail": "Not imported anywhere",
"removable": true
},
{
"file": "src/pages/LegacyPage.tsx",
"line": 8,
"name": "/legacy",
"category": "dead_route",
"detail": "No navigation path leads to this route",
"removable": true
},
{
"file": "src/styles/legacy.css",
"line": 45,
"name": ".old-button-primary",
"category": "orphaned_css",
"detail": "Not referenced in any component",
"removable": true
},
{
"file": "src/components/OldModal.tsx",
"line": 3,
"name": "OldModal",
"category": "unreferenced_component",
"detail": "Not rendered in any other component",
"removable": false
}
],
"errors": []
}
Each finding has a removable flag. Removable findings can be auto-removed with deadcode remove. Non-removable findings require manual review (e.g., components used via dynamic imports or string-based references that static analysis can't trace).
Configuration: .deadcode.yml
The config file lives in your project root and persists your team's dead code policy:
# .deadcode.yml
# Paths to ignore (gitignore-style patterns)
ignore:
- "generated/"
- "**/*.generated.tsx"
- "src/legacy/" # Legacy code — deal with it later
- "src/stories/" # Storybook files
# Categories to scan (omit to scan all four)
categories:
- unused_export
- dead_route
- orphaned_css
- unreferenced_component
# CI gate threshold
fail_threshold: 50
CLI flags override the config file. This means:
deadcode scan— usesfail_threshold: 50from configdeadcode scan --fail 30— overrides to 30 for this rundeadcode scan -c dead_route— scans only dead routes, ignores other categoriesdeadcode scan -i "src/migrations/"— addssrc/migrations/to ignore patterns
Combining Scan and Remove in CI
For a two-phase approach: scan in CI to catch new dead code, then run remove --dry-run to generate a cleanup plan:
# .github/workflows/deadcode-cleanup.yml
name: Weekly Dead Code Cleanup
on:
schedule:
- cron: '0 10 * * 1' # Monday 10 AM
workflow_dispatch:
jobs:
cleanup:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: pip install git+https://github.com/Coding-Dev-Tools/deadcode.git
- name: Generate cleanup plan
run: |
echo "## Dead Code Cleanup Plan" > /tmp/cleanup.md
echo "" >> /tmp/cleanup.md
echo '```' >> /tmp/cleanup.md
deadcode remove --dry-run >> /tmp/cleanup.md 2>&1
echo '```' >> /tmp/cleanup.md
- name: Create cleanup issue
run: |
gh issue create \
--title "Weekly dead code cleanup $(date +%Y-%m-%d)" \
--body-file /tmp/cleanup.md \
--label "tech-debt,cleanup"
This creates a GitHub issue every Monday with the dry-run removal plan. The team can review it, run deadcode remove for safe categories, and manually review non-removable findings.
Safety first: Always run deadcode remove --dry-run before the real thing. And always commit your code before running without --dry-run — the remove command modifies files in place and gives you a 3-second countdown to abort.
Category-Specific CI Strategies
Dead Routes: Zero Tolerance
Dead routes are the most dangerous category — they mean users are hitting 404s. Set a zero-tolerance policy:
# Fail if ANY dead routes found
deadcode scan -c dead_route --fail 1
Unused Exports: Progressive Tightening
Unused exports accumulate slowly and are usually safe to remove. Start with a generous threshold and tighten over time:
# Month 1: Allow up to 40
deadcode scan -c unused_export --fail 40
# Month 2: Tighten to 30
deadcode scan -c unused_export --fail 30
# Month 3: Tighten to 20
deadcode scan -c unused_export --fail 20
Orphaned CSS: Batch Cleanup
CSS classes are often safe to remove (unused classes don't break functionality). Run a batch cleanup periodically:
# Check orphaned CSS count
deadcode scan -c orphaned_css
# Remove all orphaned CSS (safe category)
deadcode remove -c orphaned_css --dry-run
deadcode remove -c orphaned_css
Real-World Scenario: Migrating from Legacy Components
Your team is replacing a legacy OldModal component with a new Dialog component. You're halfway through the migration — some pages still use OldModal. Here's how to use DeadCode in CI without blocking the in-progress migration:
# .deadcode.yml
ignore:
- "src/components/legacy/" # Legacy components still in migration
- "src/styles/legacy.css" # Legacy styles still referenced
categories:
- dead_route # Always check dead routes
- orphaned_css # Check CSS (excluding legacy/)
- unused_export # Check exports (excluding legacy/)
fail_threshold: 20
When the migration is complete and OldModal is no longer imported anywhere, remove the ignore patterns. DeadCode will detect it as an unreferenced component — and your CI gate will catch it if the count spikes.
Install DeadCode
# pip
pip install git+https://github.com/Coding-Dev-Tools/deadcode.git
# Scan your project
deadcode scan
# Quick stats
deadcode stats
Star DeadCode on GitHub
Related Reading
- Clean Up Your React/Next.js Codebase with DeadCode — practical tutorial
- DeadCode Under the Hood — static analysis internals
- Block Deployments on Config Drift — ConfigDrift CI gating
- Review Infrastructure Changes Before Apply — DeployDiff preview workflow