Knip:死代码与无用依赖检测
概述
Knip 是一个面向 TypeScript monorepo 的死代码检测工具。它会从每个包的入口文件出发,追踪整个 import 图,自动发现:
- 未使用的文件 —— 源码文件没有被任何地方 import
- 未使用的 exports —— 函数/类型/变量 被导出但无消费者
- 未使用的 npm 依赖 ——
package.json中声明但代码未 import 的包 - 孤立的 catalog 条目 ——
pnpm-workspace.yaml中列了但无package.json引用的依赖
本项目已集成 Knip 6.29.0,配置文件位于仓库根目录 knip.config.ts。
快速使用
# 全仓扫描(含 apps + packages)
pnpm knip
# 只扫共享包(跨平台 + tooling)
pnpm knip:packages
# 只扫应用
pnpm knip:apps
# 自动清理 unused exports
pnpm knip:fix实际命令为
cross-env NODE_OPTIONS=--max-old-space-size=8192 knip ...(防 OOM + Windows 兼容)。knip:packages使用--workspace ./packages/*/*——此前写的packages/*匹配不到platform-any/*这类两层目录,knip 静默空操作,当时声称的"零发现"是假象;2026-08-08 修复为两层通配后才是真实结果。
预期结果
| 命令 | 预期 | 说明 |
|---|---|---|
pnpm knip:packages | exit 1,有发现(2026-11-02 起) | 补上 project + includeEntryExports 之后,共享包第一次真的进了分析面,于是它的公共 API 面开始报出来(判据与数据见下一节)。它此前那句「零发现」是假绿:不是干净,是没被分析 |
pnpm knip(全仓) | exit 1,有发现 | 按「两类」读:apps/** 是有意维持的公共面与动态入口(模板装饰器 API 面、apps/{cli,worker}/src/main.ts、migrations/**),packages/** 是「入口没人走」与少量真死候选 —— 分法见下一节之后新增的「删桶之后的复测」 |
配置设计
knip 的核心原理是从入口文件出发,沿 import 链追踪。因此每个 workspace 包都需要在 knip.config.ts 中声明 entry files。
共享包(packages/*)
"packages/platform-any/contract": {
entry: ["src/index.ts"], // barrel export → 追踪所有子模块
},共享包的 import 链完全显式(import { xxx } from '@walnut/contract'),knip 可 100% 精确追踪。
共享包进入分析面:project + includeEntryExports(2026-11-02,批次 2 · R2)
只写 entry 时,knip 只分析 entry 领进来的那批文件 —— 非 entry 的深层文件、以及 barrel 里 「导出了但没人消费」的符号,都不在它的视野里。给每个共享包补两行才能补上:
"packages/platform-any/contract": {
entry: ["src/index.ts"],
// ① 谁算「程序的一部分」:不写就只算 entry 领进来的文件
project: ["src/**/*.ts"],
// ② 唯一能让非 entry 文件的死导出现身的开关(默认 false)
includeEntryExports: true,
},实测(2026-11-02 口径,改前 / 改后各跑一次 pnpm knip):
| Unused exports | Unused exported types | 说明 | |
|---|---|---|---|
改前(五个包都只有 entry) | 50 | 24 | 这 50 条全部落在 apps/**;packages/** 在改前只有 4 条(都在 packages/tooling/eslint-config),其余整块不在分析面里 |
只加 project | 50 | 24 | 0 变化 —— project 不是主因 |
只删 entry(让 knip 从 package.json 的 exports 推) | 50 | 24 | 0 变化 —— 摘了也会从 exports 推回来,「把 barrel 从 entry 摘出去」不是可执行的动作 |
project + includeEntryExports | 139 | 49 | +89 / +25,新增全部落在 packages/** |
新增 89 条按文件的分布(pnpm knip 全表逐条解析):packages/platform-web/client/ 的 barrel 53 条、 packages/platform-any/utils-core/ 的 barrel 22 条、packages/platform-web/http/ 的 barrel 7 条(这三处都是 barrel 本身,即各包的 src/index.ts;其中 client / http 两处已于 2026-11 批次 4 按决策 D8 删除),其余 7 条散在 client 的深层文件(browser/cookie.ts、 browser/shared.ts、browser/crypto/shared.ts、file/download.ts、hooks/vueuse/useDraggableElement.ts、 store/createWalnutStore.ts)。也就是说这一轮的主要产出就是「barrel 里被 re-export、但全仓没人用」 的符号第一次现身。
但 barrel 的可见性并不稳定 —— 这是已知盲区,别当结论用:
探针(各追加一行,跑完 git checkout -- 还原) | 结果 |
|---|---|
export const __PROBE = 1 追加到 packages/platform-web/http/ 的 barrel(当时是 src/index.ts,批次 4 已删) | 报出来了(…/src/index.ts:25:14) |
同一行追加到 packages/platform-any/contract/src/index.ts | 没报(同一份配置、同一次运行) |
export const __PROBE = 1 追加到非 entry 的深层文件(contract/src/role.ts) | 报出来了 |
结论:barrel 级死导出目前「因包而异」,不是稳定判据;contract 那个例外原因未查清(它是唯一 用 packages/platform-any/contract/scripts/build-barrel.ts 生成 barrel 的包,怀疑与 entry 推导有关,未验证)。要真正守这一面, 只能另立一个专用脚本(读 src/index.ts 的具名导出 → 全仓 grep 消费方),调 knip 配置解决不了。
删桶之后的复测:噪音确实随删桶塌掉(批次 9 · C3)
批次 4 按 D7 / D8 处理掉三个 barrel(packages/platform-web/client 与 packages/platform-web/http 的 入口 src/index.ts 删除;packages/platform-any/utils-core 的入口收缩成只剩「双端同源」的两个 模块)之后,重跑一次 pnpm knip,结论只有一句话:噪音塌了,而且塌掉的正是「一桶 N 行」那一类。
怎么判「塌的是哪一类」(不要背数字,照判据看):
| 看什么 | 判据 |
|---|---|
| 读数有没有掉 | 拿上面那张 2026-11-02 的表当历史快照对比 Unused exports / Unused exported types。它记的是「进分析面」那一刻,桶一删就必然过期 —— 别当现状引用 |
| 掉的是哪一类 | 看还剩几行落在包的 src/index.ts(= 桶本身)。桶越少这类行越少;apps/** 与深层文件那部分基本不动(它们与桶无关) |
| 剩下的怎么看 | ① 分两段:packages/** 是公共面问题,apps/** 是模板 / 动态入口问题。② packages/** 每一条按「入口有没有消费」二分:grep -rn "from '@walnut/<pkg>'" apps packages 为 0 ⇒ 它说的是「入口没人走」,不是死代码(消费方走深路径);入口与深路径都 0 才是真死候选。③ apps/** 那一批有意维持:模板装饰器 API 面、apps/{cli,worker}/src/main.ts 与 migrations/** 是命令行执行的入口(knip 看不见) |
为什么这一节只写判据、不写读数:这一面的数字已经腐烂过一次 —— 上一节那张表是 2026-11-02 的快照, 桶一删就全过期。要现状就跑 pnpm knip;要引用数字就得写明是哪天的 pnpm knip(历史那份带日期的记录已随 2026-11-04 收尾归档至 git 历史)。
别为了变绿加 ignoreIssues:桶已经删了,剩下每一条都该能说出「它为什么还在」;抹掉等于把这一面 重新变回盲区。真要接进门禁,先按上面三步分类,只给「真死」开红。
前端应用(apps/admin)
"apps/admin": {
entry: [
"src/main.ts", // 应用入口
"src/router/index.ts", // 路由(import 所有页面)
"src/router/routes/**/*.ts", // 路由配置
],
project: ["src/**/*.{ts,vue}"],
vite: false, // 禁用 Vite 插件(见下文)
ignore: [
"src/components/**", // 组件自动注册(unplugin-vue-components)
"src/composables/**", // 组件自动注册
"src/hooks/**", // 组件自动注册
"src/api/**", // barrel + store 动态引用
"src/store/**", // Pinia 动态注册
"src/socket/**", // 动态 import
"src/types/**", // ambient 引用
"src/const/**", // 同上
"src/enums/**", // 同上
"build/**", // 构建脚本
],
},后端应用(apps/server)
"apps/server": {
entry: ["apps/api/src/main.ts"],
project: ["apps/**/*.ts", "libs/**/*.ts"],
ignore: [
"**/*.module.ts", // @Module() 装饰器隐式组装
"**/*.controller.ts", // @Controller() 同上
"**/*.service.ts", // @Injectable() DI
"**/*.dto.ts", // 装饰器引用
"**/*.schema.ts", // Mongoose 动态加载
"**/*.guard.ts", // DI 组装
"**/*.interceptor.ts", // 同上
"**/*.pipe.ts", // 同上
"**/*.filter.ts", // 同上
"**/*.decorator.ts", // 同上
"**/*.strategy.ts", // 同上
"**/*.middleware.ts", // 同上
"libs/*/src/index.ts", // tsconfig paths 引用
"infra/**", // SWC 构建配置
"**/*.e2e-spec.ts", // E2E 测试
"**/vitest.config.*.ts", // 测试配置
],
},为什么 apps 有大量文件被忽略
knip 是静态 import 追踪工具。以下框架机制对 knip 不可见,必须手动排除:
| 框架 | 机制 | knip 能追踪? |
|---|---|---|
| Vue 3 | unplugin-vue-components(自动注册组件) | ❌ |
| Vue 3 | Pinia store 动态注册 | ❌ |
| NestJS | @Module() 装饰器隐式组装 | ❌ |
| NestJS | @Controller() / @Injectable() 依赖注入 | ❌ |
| NestJS | Mongoose schema 动态加载 | ❌ |
| Admin | vite.config.ts 依赖 loadEnv / 回调执行(useBuildEnv() 内 JSON.parse(env.VITE_PROXY)),knip 的 jiti 无法安全加载 | ❌ |
对策:这些文件类型在 knip.config.ts 中通过 ignore 排除在 unused files 检测之外。knip 仍会对它们做 export 级别的检测。
已知技术局限
barrel 自身的死导出(部分盲区,未解决)
includeEntryExports 让一部分 barrel 的死导出现身(platform-web/client / utils-core / platform-web/http 三处 barrel 曾在同一次运行里一起报出一批),但不是所有包都报 —— 同样的 探针在 packages/platform-any/contract/src/index.ts 上不出现。判据不稳定 ⇒ 按部分盲区对待。
别引用那个「一次报出多少条」的数字:它来自 2026-11-02 的快照,而其中两处 barrel (platform-web/{client,http} 的入口)批次 4 已删、utils-core 的入口也已收缩(见上节 「删桶之后的复测」)。要看现状就跑 pnpm knip,要看形态就按上面「怎么判」那三步走。
~build/* 路径别名(已解决)
admin 中曾有文件 import ~build/package 和 ~build/time,旧版 knip 无法解析——vite-tsconfig-paths 在构建时动态解析这些别名,knip 无此上下文,输出 Unresolved imports。
已解决(2026-08-08):knip.config.ts 中的 paths 块已删除,knip 不再做别名解析,对应问题不再出现。
vite.config.ts 加载失败
admin 的 vite.config.ts 在 useBuildEnv() 函数内调用 JSON.parse(env.VITE_PROXY)(由 loadEnv 提供 env),knip 的 jiti 加载器无法安全执行 → 加载抛异常。
对策:在 apps/admin workspace 配置中设置 vite: false,改为手动指定 entry files。
Vite 插件 devDependencies
30+ 个 Vite 插件(如 @vitejs/plugin-vue、vite-plugin-checker)在 devDependencies 中声明但不会在源码中 import——它们只在 vite.config.ts 中引用。knip 无法解析 vite.config.ts,所以报告为 "unused devDependencies"。
对策:已加入 ignoreDependencies 列表。
如何解读输出
# 2026-08-08 快照,仅示输出形态:括号里是当时的读数,别当现状引用(现状跑 pnpm knip)
Unused files (7) ← 值得排查:可能真的有死文件
Unused dependencies (4) ← 值得排查:可能真的有没用到的包
Unlisted binaries (1) ← CLI 工具在 scripts 中引用但未列在 dependencies
Unresolved imports ← 路径别名问题,已知局限(见上文)
Unused exports (40+) ← 导出但无消费者,可按需清理
Unused catalog entries ← pnpm-workspace.yaml 中的孤立条目
Configuration hints (69)← knip 建议清理的冗余配置项(可忽略)以上为全仓实测数字(2026-08-08),pnpm knip 当前退出码为 1——作为门禁属预期行为(见下文 CI 建议)。
日常维护
添加新 workspace 包
在 knip.config.ts 的 workspaces 中添加:
"packages/platform-any/新包名": {
entry: ["src/index.ts"],
project: ["src/**/*.ts"], // 不写 = 只分析 entry 领进来的文件
includeEntryExports: true, // 不写 = 非 entry 文件的死导出永远不报
},两行都别省(理由与实测见上文「共享包进入分析面」一节)。
添加新 app
参照现有 apps/admin 或 apps/server 的配置,根据框架类型选择合适的 entry 和 ignore 策略。
CI 集成建议
暂时不要把 pnpm knip:packages 直接当硬门禁。2026-11-02 之前它以 exit 0 通过,原因是 packages/** 整块不在分析面里(假绿);补上 project + includeEntryExports 之后它开始 报共享包的公共 API 面,而本仓对那批符号的既定判断仍是「模板留给使用者取用的能力,不等于死代码」 (见 knip.config.ts 顶部注释)。要真接进门禁,先按「有意的公共面 / 真死代码」分类,只让后者亮红。
若直接调 knip,务必用 --workspace ./packages/*/* 的两层通配写法——packages/* 匹配不到 platform-any/* 这类两层目录,会静默空操作。全仓 pnpm knip 由于存在已知误报,建议作为 信息性检查而非硬门禁(当前实测 exit 1,属预期)。
# .github/workflows/ci.yml 示例片段
- name: Dead code check (packages)
run: pnpm knip:packages相关资源
- Knip 官方文档
- Knip Configuration 参考
- Knip Known Issues
- Knip GitHub 仓库(11.6k+ stars,周下载 770 万)