Skip to content

Commit 8208a24

Browse files
feat: Add Knip for unused code analysis with CI reporting (#1954)
## Summary Adds [Knip](https://knip.dev) to the monorepo to detect unused files, dependencies, and exports. The goal is to reduce dead code over time and prevent new unused code from accumulating. **What's included:** - Root-level `knip.json` configured for all three workspaces (`packages/app`, `packages/api`, `packages/common-utils`) - `yarn knip` and `yarn knip:ci` scripts for local and CI usage - GitHub Action (`.github/workflows/knip.yml`) that runs on every PR to `main`, compares results against the base branch, and posts a summary comment showing any increase or decrease in unused code - Removed the previous app-only `packages/app/knip.json` in favor of the monorepo-wide config **How the CI workflow works:** 1. Runs Knip on the PR branch 2. Checks out `main` and runs Knip there 3. Compares issue counts per category and posts/updates a PR comment with a diff table This is additive — Knip runs as an informational check and does not block PRs. Co-authored-by: peter-leonov-ch <209667683+peter-leonov-ch@users.noreply.github.com>
1 parent b0135cb commit 8208a24

5 files changed

Lines changed: 753 additions & 12 deletions

File tree

.github/workflows/knip.yml

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
name: Knip - Unused Code Analysis
2+
on:
3+
pull_request:
4+
branches: [main]
5+
concurrency:
6+
group: ${{ github.workflow }}-${{ github.ref }}
7+
cancel-in-progress: true
8+
jobs:
9+
knip:
10+
timeout-minutes: 10
11+
runs-on: ubuntu-24.04
12+
permissions:
13+
contents: read
14+
pull-requests: write
15+
steps:
16+
- name: Checkout PR branch
17+
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
18+
19+
- name: Setup node
20+
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
21+
with:
22+
node-version-file: '.nvmrc'
23+
cache-dependency-path: 'yarn.lock'
24+
cache: 'yarn'
25+
26+
- name: Install dependencies
27+
run: yarn install
28+
29+
- name: Run Knip on PR branch
30+
run: yarn knip --reporter json > /tmp/knip-pr.json 2>/dev/null || true
31+
32+
- name: Checkout main branch
33+
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
34+
with:
35+
ref: main
36+
clean: false
37+
38+
- name: Install dependencies (main)
39+
run: yarn install
40+
41+
- name: Run Knip on main branch
42+
run: yarn knip --reporter json > /tmp/knip-main.json 2>/dev/null || true
43+
44+
- name: Compare results and comment
45+
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0
46+
with:
47+
script: |
48+
const fs = require('fs');
49+
50+
function countIssues(filePath) {
51+
try {
52+
const raw = fs.readFileSync(filePath, 'utf8');
53+
const data = JSON.parse(raw);
54+
const counts = {};
55+
const issueCategories = [
56+
'files', 'dependencies', 'devDependencies', 'unlisted',
57+
'unresolved', 'binaries', 'exports', 'types',
58+
'enumMembers', 'duplicates'
59+
];
60+
for (const issue of data.issues || []) {
61+
for (const cat of issueCategories) {
62+
if (Array.isArray(issue[cat]) && issue[cat].length > 0) {
63+
counts[cat] = (counts[cat] || 0) + issue[cat].length;
64+
}
65+
}
66+
}
67+
let total = 0;
68+
for (const v of Object.values(counts)) total += v;
69+
return { counts, total };
70+
} catch (e) {
71+
console.log(`Failed to parse ${filePath}: ${e.message}`);
72+
return { counts: {}, total: 0 };
73+
}
74+
}
75+
76+
const pr = countIssues('/tmp/knip-pr.json');
77+
const main = countIssues('/tmp/knip-main.json');
78+
79+
const categories = [
80+
['files', 'Unused files'],
81+
['dependencies', 'Unused dependencies'],
82+
['devDependencies', 'Unused devDependencies'],
83+
['unlisted', 'Unlisted dependencies'],
84+
['unresolved', 'Unresolved imports'],
85+
['binaries', 'Unlisted binaries'],
86+
['exports', 'Unused exports'],
87+
['types', 'Unused exported types'],
88+
['enumMembers', 'Unused enum members'],
89+
['duplicates', 'Duplicate exports'],
90+
];
91+
92+
const diff = pr.total - main.total;
93+
const emoji = diff > 0 ? '🔴' : diff < 0 ? '🟢' : '⚪';
94+
const sign = diff > 0 ? '+' : '';
95+
96+
let body = `## Knip - Unused Code Analysis\n\n`;
97+
body += `${emoji} **${sign}${diff}** change in total issues (${main.total} on main → ${pr.total} on PR)\n\n`;
98+
body += `| Category | main | PR | Diff |\n`;
99+
body += `|----------|-----:|---:|-----:|\n`;
100+
101+
for (const [key, label] of categories) {
102+
const mainCount = main.counts[key] || 0;
103+
const prCount = pr.counts[key] || 0;
104+
const catDiff = prCount - mainCount;
105+
if (mainCount === 0 && prCount === 0) continue;
106+
const catSign = catDiff > 0 ? '+' : '';
107+
const catEmoji = catDiff > 0 ? ' 🔴' : catDiff < 0 ? ' 🟢' : '';
108+
body += `| ${label} | ${mainCount} | ${prCount} | ${catSign}${catDiff}${catEmoji} |\n`;
109+
}
110+
111+
body += `\n<details><summary>What is this?</summary>\n\n`;
112+
body += `[Knip](https://knip.dev) finds unused files, dependencies, and exports in your codebase.\n`;
113+
body += `This comment compares the PR branch against \`main\` to detect regressions.\n\n`;
114+
body += `Run \`yarn knip\` locally to see full details.\n`;
115+
body += `</details>\n`;
116+
117+
// Find existing comment
118+
const { data: comments } = await github.rest.issues.listComments({
119+
owner: context.repo.owner,
120+
repo: context.repo.repo,
121+
issue_number: context.issue.number,
122+
});
123+
const existing = comments.find(c =>
124+
c.user.type === 'Bot' && c.body.includes('Knip - Unused Code Analysis')
125+
);
126+
127+
if (existing) {
128+
await github.rest.issues.updateComment({
129+
owner: context.repo.owner,
130+
repo: context.repo.repo,
131+
comment_id: existing.id,
132+
body,
133+
});
134+
} else {
135+
await github.rest.issues.createComment({
136+
owner: context.repo.owner,
137+
repo: context.repo.repo,
138+
issue_number: context.issue.number,
139+
body,
140+
});
141+
}

knip.json

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
{
2+
"$schema": "https://unpkg.com/knip@6/schema.json",
3+
"workspaces": {
4+
"packages/app": {
5+
"entry": ["pages/**/*.{ts,tsx}", "scripts/*.js", "tests/e2e/**/*.ts"],
6+
"project": ["src/**/*.{ts,tsx}", "pages/**/*.{ts,tsx}"],
7+
"ignore": [".storybook/public/**"],
8+
"ignoreDependencies": [
9+
"eslint-config-next",
10+
"@chromatic-com/storybook",
11+
"@storybook/addon-themes"
12+
]
13+
},
14+
"packages/api": {
15+
"entry": ["src/index.ts", "src/tasks/index.ts", "scripts/*.ts"],
16+
"project": ["src/**/*.ts"],
17+
"ignoreDependencies": ["pino-pretty", "aws4"]
18+
},
19+
"packages/common-utils": {
20+
"entry": ["src/**/*.ts", "!src/__tests__/**", "!src/**/*.test.*"],
21+
"project": ["src/**/*.ts"]
22+
}
23+
},
24+
"ignoreBinaries": ["make", "migrate", "playwright"],
25+
"ignoreDependencies": [
26+
"concurrently",
27+
"dotenv",
28+
"babel-plugin-react-compiler"
29+
]
30+
}

package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
"eslint-plugin-security": "^3.0.1",
2929
"eslint-plugin-simple-import-sort": "^12.1.1",
3030
"husky": "^8.0.3",
31+
"knip": "^6.0.1",
3132
"lint-staged": "^13.1.2",
3233
"nx": "21.3.11",
3334
"prettier": "3.3.3"
@@ -45,6 +46,8 @@
4546
"dev:local": "IS_LOCAL_APP_MODE='DANGEROUSLY_is_local_app_mode💀' yarn dev",
4647
"dev:down": "docker compose -f docker-compose.dev.yml down",
4748
"dev:compose": "docker compose -f docker-compose.dev.yml",
49+
"knip": "knip",
50+
"knip:ci": "knip --reporter json",
4851
"lint": "npx nx run-many -t ci:lint",
4952
"version": "make version",
5053
"release": "npx changeset tag && npx changeset publish"

packages/app/knip.json

Lines changed: 0 additions & 4 deletions
This file was deleted.

0 commit comments

Comments
 (0)