你接手过一个没人敢动的老项目吗?改一个字段名要顺着引用链摸十分钟,删一个看起来没用的导出函数又怕半夜被线上告警吵醒。我在维护一个跑了三年的中型Node服务时,被这种代码熵增折磨到忍无可忍,最后干脆自己写了个插件,用它把项目里沉积已久的“代码遗骸”一点点清了出来。这个插件就是dsh-plugin-noema,一个面向代码整洁度诊断与自动重构的语义级分析工具。
如果你也是个看到重复代码就手痒、发现死代码就难受的开发者,那这东西大概率对你有用。它不负责格式化,也不纠正空格和引号,它做的是更底层的一件事:识别代码里「没有意义的部分」。换句话说,它不是帮你把代码打扮得好看,而是帮你把代码里不必要的脂肪减掉,让肌肉线条真正露出来。
1. “代码整洁”这件事,为什么靠人盯永远盯不住
1.1 普遍存在的“代码遗骸”
我在几个项目里做过一个粗略统计:一个跑了三年的服务端项目,去掉node_modules之后,大约8%到12%的代码属于“永远不会被执行”的状态。这还不包括那种被注释掉的代码块、只被测试引用的生产函数、以及两三个实现同样逻辑的“兄弟函数”。
这些代码是怎么留下来的?最常见的情况是这样的:需求调整了,某个功能不做A方案改做B方案,但A方案的函数没删,只是没人再调用它。或者某次重构把旧的调用链切掉了,但底层工具函数还挂在公共模块里。还有一些更隐蔽的,比如一个函数只在一处被调用,看起来在用,但传入的参数永远是同一个常量,那这函数本质上已经退化成了一个“伪动态”的硬编码。
代码遗骸最大的问题不是占空间,而是给后来的人制造认知负担。每个新接手的人看到这些没用的函数,都要猜一遍:“它是不是准备以后用?”“它是不是被哪个动态调用的地方引用了?”“我删了会不会出事?”这种不确定感会把开发速度拖慢,而且是越拖越慢。
1.2 lint工具的局限
很多人会问:不是有ESLint、SonarQube这些工具吗?它们也查未使用变量、查重复代码啊,为什么还需要noema这种东西?
这里要说清楚一个事实:传统linter的核心工作是“规则匹配”,它擅长检查那些可以被明确定义的语法问题。比如定义了的变量但没用,这是语法层面的未使用,规则能抓到。但一个函数被间接引用、被当作参数传递、被对象属性缓存后调用,这种“语义层”的死代码,普通linter根本管不了,因为从语法上它确实是“被使用”的。
我举个真实场景。你有一个utils.js文件,里面导出了20个函数。其中有一个函数formatPrice,只有order模块里的一个被注释掉的代码块引用过它。ESLint的no-unused-vars根本查不出来,因为从模块导入的角度看,这函数被import了;从语法检查的角度看,引用它的代码块虽然被注释了,但import语句还活着。于是这个函数就成了“语法上活着、语义上死了”的僵尸代码。
1.3 noema的定位差异
dsh-plugin-noema从一开始就没打算跟linter抢活儿干。它的设计目标是回答一个问题:从一个程序的行为来看,这段代码真的在影响最终输出吗?
这个名字里的noema,取自现象学里的一个概念,大致可以理解为“意识所指向的那个对象”。我借这个名字想表达的是:代码真正重要的不是它怎么写,而是它指向什么意图、完成什么行为。noema做的就是把代码从“静态文本”还原成“行为模型”,然后检查这个行为模型里有哪些环节是空转的。
所以你可以把它理解成给代码做了一次“CT扫描”,而不是只照个X光。X光只能看骨头歪没歪,CT能把整个组织、血管、血流情况都重建出来。这正是代码整洁度诊断真正需要的能力。
2. noema到底在诊断什么:语义熵三维度拆解
2.1 冗余维:死代码、抽象泄漏与重复实现
冗余维是noema的第一层检测维度,聚焦“一个行为是否存在多份表达或者零份表达”。在具体实现上,插件会构建出全量调用图、模块依赖图和导出引用图,然后交叉比对。
死代码检测是最基础的,但它和linter的处理方式不一样。noema会追踪“从入口点(比如package.json的scripts、路由注册表、定时任务注册表)出发,是否有可达路径能到达某个函数”。如果一个函数虽然被导出,但从任何入口出发都走不到它,就标记为“不可达”。这个检测能处理很多复杂场景:动态属性访问、装饰器转发、依赖注入容器内的字符串标识符绑定。
抽象泄漏是我后来加进去的一个维度。什么叫抽象泄漏?比如你有一个requestWithRetry函数,它在内部实现了三次重试的指数退避逻辑,但整个项目里只有一处调用,而那处调用根本不需要重试机制。这就是抽象泄漏——你为了一个不存在的需求做了过度设计。noema不会直接说“你删掉它”,而是给出提示:“这个函数的全部调用方都不涉及重试参数,重试逻辑可能是多余的”。
重复实现也是重点。这里的重复不是简单的代码克隆,而是“行为等价但写法不同”。比如A模块里用reduce实现了数组求和,B模块里用for循环又实现了一遍。noema会把这两个函数映射到同一个“行为指纹”上,然后提示你可以合并。
2.2 结构维:圈复杂度、函数臃肿与嵌套地狱
结构维对应的是“一个行为是否被表达得足够直接”。代码整洁不只是没有死代码,还包括没有那些把一个简单事情搞复杂的写法。
noema计算圈复杂度时不是只看if/else的层数,它会把可选链、空值合并、逻辑运算符短路、数组方法的回调都算进去。这样算出来的复杂度才是真实反映“理解这段代码需要同时记住多少分支状态”的数字。我见过一个函数,从语法上看只有三个if,但它的逻辑运算符串联了八个条件,读起来像解一道三元一次方程组。这种函数在noema里会拿到一个很高的复杂性评级。
函数臃肿检测也有效。当一个函数“做的事情”超过三个,且这些事之间没有明确的阶段性边界,它就是臃肿的。我的判断标准不是行数,而是“职责转移次数”:一个函数里调用其他函数、访问外部状态、做类型判断、拼接返回结果,这些行为混在一起,就是该拆的信号。
嵌套地狱这块,noema会给一个专门的“嵌套深度热力图”,用每个函数的缩进深度分布来展示。读到深度四层以上的函数,就应该考虑提取守卫条件或反转判定顺序了。这个指标对新入行的同事特别友好,因为它是绝对量化的,不需要经验就能判断。
2.3 一致维:命名混乱、覆写掩盖与参数空洞
一致维是我在观察了多个团队协作项目之后才加进去的。代码整洁的核心不只是“少”,还有“一致”。不一致的代码会让阅读者不断切换思维模式,消耗比死代码更大的注意力。
命名混乱检测会扫描同一语义的不同命名。说人话就是:同一个东西在你的代码里有几种叫法?比如用户ID,有的地方叫userId,有的地方叫uid,有的地方叫accountId,还有的地方叫who。noema会基于类型信息和调用关系,把这些标识符聚类,然后告诉你“这六个标识符大概率指向同一个实体,建议统一命名”。
覆写掩盖问题更隐蔽一些。它指的是子类或扩展点覆盖了父类方法,但父类方法实际上从未被调用,或者子类方法内部又super回去了但什么都没改。这种代码会误导整个类层次的理解,让人以为这里有策略差异,实际上只是形式主义的继承。我看到很多继承体系里,子类和父类方法逻辑完全一样,只是历史遗留的占位实现。
参数空洞指的是函数签名里某个参数在所有真实调用场景下都传同一个值。这说明“参数”的抽象设计失效了,它已经从真正的变化点退化成了一个常量。noema会列出每个参数在所有调用点的实参分布,如果某个参数100%都传同一个固定值,就会给提示:你这里不需要参数,要么直接删掉,要么说明这个变化点还没被真正使用。
3. 接入与运行:从安装到第一份诊断报告
3.1 安装与初始化配置
说了这么多理论,上点实操。dsh-plugin-noema以npm包形式分发,依赖Node系环境。这里我贴一段最简安装命令:
npm install -D dsh-plugin-noema装完之后需要一个配置文件。这插件的设计理念是“约定优于配置”,大部分规则默认开启,你只需要针对自己的项目调阈值。在项目根目录创建一个dsh.noema.config.cjs:
module.exports = { scan: { entryPoints: ["src/index.js", "src/cli.js"], include: ["src/**/*.js"], exclude: ["src/legacy/**"], enableUnreachableCheck: true, }, structure: { maxComplexity: 12, maxNestedDepth: 4, maxFunctionSize: 60, }, consistency: { namingClusters: true, paramConstancy: true, }, report: { outputDir: ".noema-reports", format: ["terminal", "html"], }, };entryPoints是noema判断“死代码”的基准。这个配置非常关键,它告诉插件:程序真正启动的入口在哪里。不要把测试文件、脚本工具加进去,否则所有被测试引用的函数都会被认为是活代码。在Node项目里,通常就把package.json里scripts跑的入口、路由注册文件、定时任务启动文件列出来。
3.2 跑扫描与报告内容
配置完之后一条命令就能跑:
npx dsh-plugin-noema scan我第一次在自己项目上跑的时候,结果挺震撼的。报告显示有173个函数处于“不可达”状态,其中有些文件看起来每个函数都在被调用,但顺着调用链一路追踪上去,入口处根本不存在那条链路。报告里给出了每一个问题项的完整路径链:函数定义位置、哪些地方引用了它、从哪个入口可以到达它、链路在哪个节点断开了。
终端报告会按照严重程度分颜色标记。我大概花了半小时把每一类标记的含义弄清楚之后,发现这工具真正值钱的地方不是“查出来”那一刻,而是它给出的解释链路足够完整,让我不用再自己翻调用关系去验证。
HTML报告更像一份体检单。顶部是总体评分,下面按文件折叠展开,每个问题都定位到具体行号,还带一行“为什么会被标记”的说明。比如一个死函数下面的说明会写:“从入口src/index.js出发,该函数不存在可达路径;距其最近的调用方位于src/order/helper.js第42行,但该行自身已被标注为不可达。”这种逐层溯源的说明,省去了大量手动排查时间。
3.3 编辑器内实时提示
CLI适合批量扫描,日常开发时更需要实时反馈。noema提供了一个editor mode,可以接入VS Code的Diagnostics接口。在VS Code里装好dsh-noema的官方扩展后,打开文件就会在问题面板看到语义层诊断信息,比如“未使用的导出函数”“该参数在所有调用点中均传同一值”“此函数与src/lib/format.js中的formatToFixed行为重复”。
这个实时提示不干扰我写代码的节奏。它只在文件保存后延迟800毫秒再刷新,避免连续输入时疯狂闪烁。处理误报时可以直接在函数上方加一行标注:
// noema:ignore unreachable带有这个标注的函数会被排除出本轮诊断。我一般在两种情况下使用:一种是真的为了给外部插件动态调用而预留的扩展点;另一种是暂时没有精力处理,先标注下来,等相关的批量重构一起做。
4. 存量代码怎么消化:问诊模式与渐进式整改
4.1 别想一次清完,按模块分批处理
很多人拿到诊断报告后的第一反应是:这么多问题,我今天晚上全清掉。我的经验是,千万别这么干。一次性大规模清理死代码,很可能把自己埋进大海一样的回归测试里。存量项目的整洁化应该像给一栋老房子做水电改造,得按房间来,不能把整栋楼的强电一次全拆了重新拉。
我的做法是让noema生成一份“整改路线图”。这个路线图按文件依赖层级把项目切成若干个“整改批次”,每个批次内部的依赖关系尽量自洽。通俗地说,就是优先处理那些“没有其他模块依赖它”的底层叶子文件,再逐步向上处理被它们依赖的文件。这样每一个批次完成后,项目都处于可运行状态,不用等到全部改完才验证。
配置方法很简单,在配置文件里指定batch字段:
module.exports = { scan: { batches: [ { name: "batch-01", include: ["src/lib/**"], action: "fix" }, { name: "batch-02", include: ["src/utils/**"], action: "report" }, { name: "batch-03", include: ["src/services/**"], action: "report" }, ], }, };batch-01先做lib层,action是fix,表示这层的问题尽量让插件自动修复。如果修复过程中遇到它检测到“某函数虽然不可达,但它内部有副作用(比如修改了外部变量)”,就会跳过自动修复并提示需要人工确认。这是我很看重的一个安全设计:它知道哪些动作可以安全自动化,哪些必须留给人判断。
4.2 自动修复的边界:什么能改,什么不能碰
关于自动修复能力,我需要讲清楚边界。noema的自动修复目前覆盖几类明确场景:删除不可达的纯函数声明、删除空参数且无副作用的导出变量、合并行为指纹完全相同的函数、移除所有调用点都传相同值的参数位。
但有一些它不会碰。带副作用的函数哪怕不可达,它也会保留,只是打上“疑似死代码,请人工确认”的标记。因为副作用的存在意味着该函数可能在某个外部脚本里被独立加载,只是扫描器看不到那个加载入口。类方法它一般也不自动删,除非整方法在类型系统层面都没有被引用的证据。
我举一个自动修复的真实例子。assets模块里有个getAssetUrl函数,参数列表是(url, base),但全项目所有调用点传的base都是同一个默认值。noema先提示参数空洞,我确认后执行了修复,它把base参数从签名中移除,所有调用点同步更新,测试也全部通过。这就是一次典型的“语法上没问题但语义上退化”的清理。
4.3 预期债务:允许部分问题先留下
分批整改过程中还有一类不得不处理的情况:有些问题技术上说该改,但业务上现在不能动。比如某个死函数对应的是即将上线的下一版本功能,代码已经写好了,只是特性开关还没打开。noema对此提供了一种“预期债务”标记机制。
你可以在配置里声明已知的预期债务:
module.exports = { debt: [ { pattern: "src/features/ai-assistant/**", reason: "功能未发布,特性开关关闭期间代码会判定为不可达", dueDate: "2025-12-31", }, ], };凡是匹配到债务模式的文件,会从常规报告中略过,但会在“债务清单”里单独列出。这样团队既能保持整体报告干净,又不会误删那些“其实有用但暂时没开闸”的代码。这个机制让我在推进整改时的沟通成本低了很多,因为只要在配置里写明原因和截止时间,谁都不会觉得这是“把问题藏起来”。
5. 团队协同中容易忽略的四个细节
5.1 配置隔离:不同子项目用不同规则
当一个仓库里既有服务端代码又有前端脚本还有构建工具链时,一套全局配置肯定不够用。前端页面的死代码判定依赖DOM结构和动态import,服务端依赖路由注册和中间件装配,构建脚本则依赖gulp/rollup的执行顺序。用同一套入口点去扫描三层代码,报告会乱成一锅粥。
我建议在子包各自维护自己的配置。noema支持查找距离扫描目录最近的配置文件,子目录里的配置会覆盖父级配置。比如前端目录里的entryPoints指向vite入口,服务端目录里的entryPoints指向API注册文件。这样整体报告虽然还是合在一起输出,但每一层的诊断标准是各自独立的。
5.2 CI门禁:让整洁度成为合并条件
配置lint作为CI门禁是很常见的做法,但lint一般只能拦住“格式丑”,拦不住“结构烂”。我在流水线里加了一个noema检查阶段,把诊断报告里的三类指标作为门禁条件:每千行代码死代码比例、复杂度超标的函数数量、命名聚类异常数。
CI门禁的阈值我建议“先宽后严”。上线第一周阈值设得很松,只拦截事故级的脏代码,比如单文件死代码占比超过30%、圈复杂度超过25的函数新增、命名聚类的冲突数超过5个。跑两周收集基线数据后,再把阈值收紧到正常水平。一开始就设严,会被存量问题淹没,团队也适应不了。
5.3 自定义规则:noema的插件口子
不是所有团队都需要用默认规则,有些团队有自己的定制需求,比如禁止某种特定的工具函数封装模式、要求每个新增公共函数必须附示例、特定目录下不允许出现类继承。noema留了自定义规则扩展接口,规则文件本质是一个导出了visit函数的模块:
module.exports = { meta: { name: "no-bare-array-export", severity: "warning", }, visit(node, context) { if ( node.type === "ExportNamedDeclaration" && node.declaration && node.declaration.type === "VariableDeclaration" && /\[\s*\]/.test(node.declaration.declarations[0].init.src) ) { context.report({ node: node.declaration.declarations[0].init, message: "禁止直接导出空数组初始化的可变状态", }); } }, };自定义规则的本质是用语法树加类型信息做一次额外的推理。由于noema内部已经构建好了调用图和类型绑定,自定义规则能拿到比普通AST更丰富的信息,比如“这个函数被哪些类型绑定了”“这个导出的变量在所有引用点中的读写情况”。写起来不算复杂,但能做出来的检查是linter规则完全做不到的。
5.4 阈值调参:什么样的标准才算健康
我在多个项目上验证得出的参考基线是:死代码比例控制在2%以内;单函数圈复杂度超过15的数量占总函数数不超过1%;每个文件的平均嵌套深度不超过3;参数空洞函数数量不超过10%。这些指标不绝对,但能当个起点。
调阈值时不要盯着“让报告全绿”来调,那是自欺欺人。团队应该先看一次完整报告,理解了分布情况后再决定哪些要改。有些问题的“根因”是底层抽象设计不合理,调整阈值只会让报告变绿但问题还在。正确的做法是:把报告里的问题分类整理,第一类是直接误报,调规则;第二类是真实问题但优先级低,归入预期债务;第三类是当前迭代必须处理的,创建整改任务。
6. 边界情况与我的实操心得
6.1 泛型和动态语言的误报处理
误报话题逃不开。JavaScript生态里大量使用动态属性访问、泛型代码和依赖注入,这些场景很容易让语义分析工具产生误报。我在一个依赖注入容器比较重的项目里,第一次扫描报告里大概有15%的“死代码”其实是误报。
核心问题是容器里的绑定关系按字符串名称匹配。函数本身没有显式引用关系,但运行时通过容器名称把实现和调用方绑定到了一起。处理方案是让noema支持“容器绑定声明”配置文件:
module.exports = { runtimeBindings: { containerFile: "src/di/container.js", bindingPattern: ["container.register(^[a-zA-Z]+)", "container.resolve(^[a-zA-Z]+)"], }, };配置之后,插件会把注册和解析的标识符视为一条隐式引用边。这虽然要花点时间维护,但至少能把误报率降到一个可信的水平。如果你项目里的反射和动态调用比这还复杂,那可能需要认真想想是不是设计上已经走到了一个“静态分析无法理解”的角落。
6.2 大仓库扫描性能与增量模式的取舍
一个只有几万行的小项目,noema扫描大概是几十秒,可以接受。但当仓库膨胀到几十万行、几百个模块时,全量扫描会跑到几分钟。这个体感在本地还能忍,插到CI流水线里就会拖慢发布节奏。
解决方案是noema的增量扫描模式。它会把每次扫描形成的“语义指纹”缓存下来,第二次只扫描变更文件以及依赖了变更文件的闭包节点。实测下来,增量模式在稳定开发期的扫描耗时能从全量模式的数分钟压到十几秒。代价是新增入口点或者切换分支时要多做一次全量扫描,用空间和时间换了个稳妥。
6.3 与格式化工具、文档生成器的顺序配合
最后要提醒一个细节:noema和prettier、eslint的配合顺序不要搞错。我建议先跑prettier固定格式,再跑eslint修语法问题,最后跑noema做语义层诊断。格式化会改动文件结构,如果反过来先做语义诊断后格式化,会让报告里的行号对不上,排查问题时要多绕一圈。
文档生成这块,像jsdoc、typedoc这类工具,它们可能会引用到非入口可达的函数,导致noema把一些实际上“会被文档系统读取”的导出函数标记成死代码。处理方式也很直接:在配置文件里把docs目录加入exclude,或者把文档生成器的API白名单作为入口点之一进行声明。
6.4 一个真实的清理复盘
最后分享一次让我觉得这个插件值回票价的经历。有个老模块叫billing,里面躺着一批从项目初始阶段就存在但从未被真正启用的支付渠道对接函数。那块代码一直没有专人维护,每次依赖升级都会被库的破坏性变更拖累,CI里常年挂着一堆无关报错。
我用noema扫描后确认了其中六成函数在所有入口点均不可达,剩下的四成里又有一部分是参数空洞。清理时先把不可达且无副作用的部分自动删除,然后人工确认了几个边界函数,最后把该模块的测试用例从16个精简到8个,因为删掉的确实是整块整块的空转逻辑。
清理完成后,依赖升级时那个模块再也没有成为阻碍。这事的启发是:代码整洁的直接收益往往是“降低了后续所有操作的摩擦成本”,它不会让业务指标立刻变好,但它会让团队后续的每个改动都更顺。
这阵子使用dsh-plugin-noema养成的习惯是:每个迭代结束前跑一次增量扫描看有无新增问题,把“整洁度不回退”当作跟“功能可运行”同等重要的完成标准。按我的个人经验,插件能帮你发现问题和追踪问题,但真正让代码干净起来的,还是团队愿意把它当成“日常”而不是“一次性的打扫”。如果你下定决心清一次,不妨先从最小的模块入手,跑一份报告看看自己的代码里到底藏着多少“其实根本没用”的逻辑。