构建AI代码质量检测工具:ESLint插件与CLI实践 1. 项目概述从“AI代码渣滓”到开发者工具最近在社区里看到一个挺有意思的讨论说现在AI生成的代码越来越多了但质量嘛就有点“薛定谔的猫”——时好时坏有时候甚至会把一些明显的坏味道、过时的模式或者不安全的写法给带进来。我自己在Review团队代码时也深有体会经常能看到一些“一眼AI”的代码片段比如过度冗长的注释、奇怪的变量命名tempVar1,dataHolder、或者用了已经被弃用的API。这些代码虽然能跑但就像混进米饭里的沙子读起来硌牙维护起来更是头疼。于是我就想能不能做个工具专门来抓这些“AI代码渣滓”AI-Generated Code Slop这个想法催生了我最近做的一个小项目一个ESLint插件外加一个零配置的CLI工具。简单说它就是一套代码质量守门员专门针对AI生成代码中常见的不良模式进行静态检查。让我没想到的是项目开源没几天在npm上的周下载量就冲到了1,137次。看来这个问题戳中了不少开发者的痛点。这篇文章我就来拆解一下这个工具是怎么做的背后有哪些思考以及如果你也想构建类似的开发者工具可以从哪里入手。2. 核心设计思路如何定义并检测“代码渣滓”2.1 从现象到规则归纳AI代码的“坏味道”构建检测工具的第一步也是最重要的一步就是明确我们要检测什么。你不能光说“这段代码看着像AI写的”必须把它转化为可执行的、具体的规则。我通过分析大量ChatGPT、GitHub Copilot等工具生成的代码样本包括一些公开数据集和自己模拟的提示词产出总结出了几类高频的“渣滓”模式冗余与过度工程这是最典型的一类。AI为了显得“全面”或“安全”经常生成不必要的代码。过度防御性编程在根本不可能为null或undefined的值前面添加空值检查例如对一个刚用字面量初始化的数组进行if (array array.length 0)判断。无效或过时的注释生成描述“what”这段代码在做什么而不是“why”为什么这么做的注释甚至注释和代码逻辑完全不符。或者注释里还留着// TODO: Implement this function但函数体已经是完整的了。不必要的变量包装为了一个简单的操作创建中间变量例如const result calculate(); return result;而不是直接return calculate();。模式化与缺乏创意AI学习自海量代码容易陷入固定套路。刻板的错误处理无论错误类型一律console.error(error); throw error;缺乏更精细的错误分类或恢复逻辑。模板化的函数结构每个函数都遵循完全相同的JSDoc注释格式、相同的参数校验模式即使参数很简单导致代码看起来千篇一律。通用且无意义的标识符大量使用data,item,obj,response,temp这类信息量极低的变量名或函数名。知识滞后与最佳实践违背AI的训练数据有截止日期可能不知道最新的语言特性或库的最佳实践。使用旧API在支持现代ES6的环境下仍使用var声明变量或用function关键字而非箭头函数定义回调。忽略现代语言特性能用可选链操作符?.或空值合并操作符??简化逻辑的地方仍然使用冗长的链或三元表达式。不安全或不推荐的模式例如在React组件中直接修改statethis.state.foo ‘bar‘或者使用已知存在安全风险的第三方库旧版本模式。注意定义规则时要避免“误伤”。我们的目标不是禁止AI写代码而是提升AI生成代码的质量。因此规则应聚焦于那些明确会降低代码可读性、可维护性或性能的模式而不是那些仅仅是风格上的差异。2.2 技术选型为什么是ESLint插件CLI确定了要抓什么接下来就是选择技术实现路径。为什么是ESLint插件生态无缝集成ESLint是现代JavaScript/TypeScript开发的事实标准。作为插件它可以轻松融入任何已经使用ESLint的项目无需改变开发者的工作流。无论是VS Code的实时检查还是CI/CD管道中的预提交钩子都能直接生效。强大的AST分析能力ESLint的核心是基于抽象语法树进行静态分析。这给了我们极其精确的能力来定位代码中的特定模式。我们可以编写规则来匹配特定的AST节点结构这比简单的字符串匹配或正则表达式要可靠和强大得多。丰富的上下文信息在规则执行时ESLint提供了丰富的作用域信息、变量引用信息等。这让我们能做出更智能的判断。例如我们可以判断一个变量是否真的可能为null而不是对所有变量都警告。修复能力ESLint支持自动修复fix。对于一些简单的“渣滓”比如把var改成const我们的工具不仅可以报错还可以一键修复极大提升体验。那为什么还要做一个独立的CLI工具零配置快速上手不是所有项目都配置了ESLint或者配置起来可能比较麻烦。一个零配置的CLI工具让开发者只需运行一条命令如npx catch-ai-slop ./src就能立即开始扫描降低了使用门槛。专注单一场景这个CLI工具只做一件事——检测AI代码渣滓。它内置了预设的规则集和配置用户无需关心.eslintrc.js文件怎么写。这对于想要快速评估一个项目或一段代码的开发者来说非常方便。作为推广和入口CLI工具体验良好用户可能会想“如果能集成到我的IDE里实时检查就更好了”。这时他们自然会去了解背后的ESLint插件从而完成从临时工具到深度集成的转化。3. 核心规则实现深度解析3.1 规则一检测“无意义的中间变量”这是一个非常典型的“渣滓”。AI经常会把简单的表达式拆散引入不必要的变量。AST视角分析 我们需要在AST中寻找一种模式一个变量声明VariableDeclarator它的初始化值init是一个简单的表达式比如一个函数调用CallExpression或一个标识符Identifier然后这个变量在后续的作用域中只被使用了一次并且就是作为return语句的值或另一个表达式的直接参数。实现步骤遍历在ESLint规则中我们可以监听VariableDeclaration节点。检查对于每个变量声明检查其声明的变量数量通常只关心单个声明如const result getData()。分析引用使用ESLint提供的scopeManager获取该变量的作用域并追踪它的所有引用references。判断如果该变量只有一次引用并且这次引用满足以下条件之一位于ReturnStatement中return result;。位于一个CallExpression中作为参数且没有其他操作process(result)。位于一个二元表达式中作为一部分且没有其他副作用。建议修复如果满足条件则报告一个问题。自动修复fix的逻辑是用变量的初始化值直接替换掉对它的引用然后删除这个变量声明。// 示例规则代码框架 module.exports { meta: { type: ‘suggestion‘ }, create(context) { return { VariableDeclaration(node) { if (node.declarations.length ! 1) return; const declarator node.declarations[0]; const variableName declarator.id.name; const init declarator.init; if (!init) return; // 如 let x; 则跳过 // 获取变量在作用域中的引用 const variable context.getScope().variables.find(v v.name variableName); if (!variable || variable.references.length ! 1) return; const ref variable.references[0]; // 检查这个唯一的引用是否在可简化的位置 if (isRedundantReference(ref.identifier)) { context.report({ node: declarator.id, message: Unnecessary intermediate variable ‘${variableName}‘. Use the expression directly., fix(fixer) { // 构建修复用 init 的源代码替换引用处并删除声明语句 const sourceCode context.getSourceCode(); const refText sourceCode.getText(ref.identifier); const initText sourceCode.getText(init); const fixes [fixer.replaceText(ref.identifier, initText)]; // 小心处理如果整个声明语句只有这一个变量则删除整行 const varToken sourceCode.getFirstToken(node); const semiToken sourceCode.getTokenAfter(node); if (semiToken semiToken.value ‘;‘) { fixes.push(fixer.removeRange([varToken.range[0], semiToken.range[1]])); } else { fixes.push(fixer.remove(node)); } return fixes; } }); } } }; } };实操心得作用域是关键一定要利用好ESLint的scopeManager它能准确告诉你变量在哪里被使用。自己用正则去匹配会非常容易出错比如遇到嵌套函数或重名变量。修复要谨慎自动修复功能很强大但必须考虑边界情况。比如如果变量声明时有JSDoc注释删除声明时要不要保留注释如果初始化表达式有副作用比如const x foo() bar()直接替换引用是否安全我们的策略是对于初始化表达式是简单的标识符或没有副作用的调用才提供自动修复对于复杂的表达式只报告问题让开发者手动决定。3.2 规则二识别“模板化且信息量低的标识符”AI喜欢用data,response,item这类词。我们需要一个规则来标记它们并建议更具体的名字。实现思路建立“黑名单”定义一个列表包含那些过于通用、在大多数上下文中都信息量不足的单词如data,info,temp,obj,value,result,thing,element。模式匹配在AST中监听Identifier节点变量名、函数名、属性名。上下文过滤不是所有叫data的变量都是坏的。我们需要过滤。忽略属性名user.data中的data可能没问题。忽略解构const { data } response;中的data是API返回的字段名通常无法改变。关注声明主要检查VariableDeclarator,FunctionDeclaration,ArrowFunctionExpression等声明语句中的标识符。考虑作用域大小一个在5行函数内使用的临时变量item可能可以接受但一个在模块顶部声明的、贯穿全局的data对象就应该被警告。提供建议报告问题时可以尝试根据上下文提供建议。例如如果变量data被用来存储用户列表可以建议users或userList。这需要一些简单的启发式规则比如检查变量是否被用于数组迭代.map(item ...)或者被访问了某些属性data.userName。// 简化的标识符检查逻辑 const GENERIC_NAMES new Set([‘data‘, ‘info‘, ‘temp‘, ‘obj‘, ‘value‘, ‘result‘, ‘item‘, ‘thing‘]); module.exports { meta: { type: ‘suggestion‘ }, create(context) { return { ‘Identifier:exit‘(node) { if (!GENERIC_NAMES.has(node.name)) return; const parent node.parent; // 忽略作为对象属性名的情况 if (parent.type ‘MemberExpression‘ parent.property node) return; // 忽略解构模式中的情况 if (parent.type ‘Property‘ parent.key node parent.parent.type ‘ObjectPattern‘) return; // 主要检查变量和函数声明 const isInDeclaration parent.type ‘VariableDeclarator‘ parent.id node || parent.type ‘FunctionDeclaration‘ parent.id node || parent.type ‘ArrowFunctionExpression‘ parent.params.includes(node) || parent.type ‘FunctionExpression‘ parent.id node; if (isInDeclaration) { // 进一步检查作用域如果是很小的局部作用域可以降低警告级别或忽略 const scope context.getScope(); const isWideScope scope.type ‘global‘ || scope.type ‘module‘; if (isWideScope) { context.report({ node, message: Avoid using overly generic name ‘${node.name}‘ in a wide scope. Try to use a more descriptive name., // 这里可以尝试生成建议但比较复杂 }); } } } }; } };注意事项误报平衡这条规则很容易误报。必须通过精细的上下文过滤来平衡。我们的目标是捕捉那些真正因为偷懒或AI习惯而产生的糟糕命名而不是所有叫data的变量。无法自动修复重命名是一个语义操作牵一发而动全身自动修复风险极高。因此这条规则通常只做警告suggestion不提供自动修复。3.3 规则三发现“陈旧或冗余的语法模式”这条规则旨在推动代码使用现代、简洁的语法。检测示例var声明在非全局作用域、非for循环特殊情况下使用var应建议改为const或let。冗长的空值检查将value ! null ? value : defaultValue模式建议改为value ?? defaultValue。将value value.property建议改为value?.property。function关键字函数在可以使用箭头函数的上下文中如回调函数使用function关键字。实现策略 对于这类规则AST模式匹配非常直观。例如检测varmodule.exports { meta: { type: ‘suggestion‘, fixable: ‘code‘ }, create(context) { return { VariableDeclaration(node) { if (node.kind ‘var‘) { // 检查是否在全局作用域或for循环初始值中这些地方var可能有特殊用途 const isInForLoopInit node.parent.type ‘ForStatement‘ node.parent.init node; const isGlobalScope context.getScope().type ‘global‘; if (!isInForLoopInit !isGlobalScope) { context.report({ node, message: ‘Use const or let instead of var.‘, fix(fixer) { // 简单的替换将‘var‘替换为‘const‘更保守的选择 const varToken sourceCode.getFirstToken(node); return fixer.replaceText(varToken, ‘const‘); } }); } } } }; } };对于可选链和空值合并的转换逻辑稍复杂需要匹配特定的AST结构条件表达式ConditionalExpression或逻辑与LogicalExpression并判断其是否等价于新语法然后进行替换。4. 零配置CLI工具的实现CLI工具的目标是开箱即用。它的核心就是一个封装好的ESLint引擎预装了我们的插件和一套推荐的规则配置。4.1 技术架构入口文件使用#!/usr/bin/env node声明并通过package.json中的bin字段关联。参数解析使用commander或yargs库来解析命令行参数如要扫描的目录./src、输出格式--format json、是否自动修复--fix等。动态配置在内存中构建一个ESLint配置对象。这个配置plugins: [‘catch-ai-slop‘]extends: [‘plugin:catch-ai-slop/recommended‘]我们在插件包里发布了一个推荐配置根据CLI参数设置rules的严重级别如将所有规则设为‘error‘或‘warn‘。调用ESLint API不通过命令行调用eslint而是使用ESLint的Node.js API (ESLint类)。这让我们能更灵活地传递配置和处理结果。结果输出与格式化使用ESLint内置的格式化器如stylish,compact,json或自定义格式化器来输出结果。对于CI环境JSON格式可能更有用。4.2 关键代码片段#!/usr/bin/env node const { ESLint } require(‘eslint‘); const path require(‘path‘); const { program } require(‘commander‘); program .name(‘catch-ai-slop‘) .description(‘Detect sloppy patterns in AI-generated code‘) .argument(‘[paths...]‘, ‘paths to lint‘, [‘.‘]) // 默认扫描当前目录 .option(‘-f, --fix‘, ‘automatically fix problems‘) .option(‘--format type‘, ‘output format‘, ‘stylish‘) .parse(); const options program.opts(); const lintPaths program.args; (async function main() { // 1. 创建预配置的ESLint实例 const eslint new ESLint({ useEslintrc: false, // 忽略项目本地配置 resolvePluginsRelativeTo: __dirname, // 从CLI工具所在目录解析插件 baseConfig: { // 内置基础配置 plugins: [‘catch-ai-slop‘], extends: [‘plugin:catch-ai-slop/recommended‘], parserOptions: { ecmaVersion: ‘latest‘, sourceType: ‘module‘ }, env: { node: true, es6: true } }, fix: options.fix, cwd: process.cwd() // 以用户运行命令的目录为基准 }); // 2. 执行代码检查 const results await eslint.lintFiles(lintPaths); // 3. 应用自动修复如果指定了--fix if (options.fix) { await ESLint.outputFixes(results); } // 4. 格式化并输出结果 const formatter await eslint.loadFormatter(options.format); const resultText formatter.format(results); console.log(resultText); // 5. 根据是否有错误设置退出码便于CI集成 const hasError results.some(r r.errorCount 0); process.exitCode hasError ? 1 : 0; })().catch(error { console.error(‘Linting failed:‘, error); process.exitCode 1; });4.3 打包与发布为了让CLI工具真正做到“零配置”必须把ESLint插件一起打包进去。作为依赖打包在CLI工具的package.json中将我们的ESLint插件eslint-plugin-catch-ai-slop列为dependencies而不是peerDependencies。这样用户安装CLI时插件会自动安装。发布推荐配置在插件的包内创建一个index.js导出所有规则同时创建一个configs/recommended.js文件在这里面启用所有我们认为有价值的规则并设置好默认的警告级别。// 在 eslint-plugin-catch-ai-slop 包内 module.exports { rules: { /* 所有规则定义 */ }, configs: { recommended: { plugins: [‘catch-ai-slop‘], rules: { ‘catch-ai-slop/no-generic-names‘: ‘warn‘, ‘catch-ai-slop/no-unnecessary-intermediate-variable‘: ‘warn‘, ‘catch-ai-slop/prefer-modern-syntax‘: ‘warn‘, // ... 其他规则 } } } };CLI工具引用CLI工具中的baseConfig直接引用‘plugin:catch-ai-slop/recommended‘即可。这样用户运行npx catch-ai-slop时背后就是一个完全配置好的、独立的代码检查环境。5. 集成与推广从工具到工作流工具做出来了怎么让开发者用起来光是检测出来还不够得融入他们的工作流。5.1 集成到现有ESLint配置对于已经使用ESLint的成熟项目推荐直接安装插件并扩展配置。npm install --save-dev eslint-plugin-catch-ai-slop然后在.eslintrc.js中module.exports { extends: [ ‘eslint:recommended‘, ‘plugin:catch-ai-slop/recommended‘ // 直接使用推荐配置 ], // 或者手动选择规则 // plugins: [‘catch-ai-slop‘], // rules: { // ‘catch-ai-slop/no-generic-names‘: ‘warn‘ // } };5.2 集成到IDEVS Code在插件的package.json中声明为ESLint插件后只要用户的VS Code安装了ESLint扩展并且项目配置加载了这个插件那么问题就会实时显示在编辑器中带有波浪下划线和问题面板提示。5.3 集成到Git钩子Husky lint-staged这是保证代码库清洁的关键一步。在提交前自动检查暂存区的文件。// package.json { scripts: { lint:ai: eslint --ext .js,.jsx,.ts,.tsx --plugin catch-ai-slop --rule ‘catch-ai-slop/recommended‘ }, lint-staged: { *.{js,jsx,ts,tsx}: [ npm run lint:ai -- --fix, npm run lint:ai // 第二次检查是否还有未修复的问题 ] } }配合Husky在pre-commit钩子中运行npx lint-staged。5.4 集成到CI/CD管道在GitHub Actions、GitLab CI等环境中添加一个检查步骤确保合并到主分支的代码没有新的“AI渣滓”被引入。# .github/workflows/lint.yml name: Lint on: [push, pull_request] jobs: lint-ai: runs-on: ubuntu-latest steps: - uses: actions/checkoutv3 - uses: actions/setup-nodev3 - run: npm ci - run: npx catch-ai-slop ./src --format compact # 或者使用项目集成的ESLint # - run: npm run lint:ai6. 遇到的挑战与解决方案6.1 误报与精确度问题这是所有静态分析工具的核心挑战。我们的规则不能太“吵”。挑战早期版本中“无意义的中间变量”规则会把一些用于调试的临时变量如const debugValue complexCalculation(); console.log(debugValue);也报出来。虽然从最终代码看是冗余的但在开发阶段这是合理做法。解决方案添加例外我们修改规则如果变量名以特定前缀开头如_,debug,temp且其唯一的引用是在console.log,debugger语句或仅在开发环境下使用的代码块中则忽略该警告。可配置性在规则配置中暴露选项允许用户自定义“可接受的通用名词列表”或设置白名单。注释禁用完全遵循ESLint生态支持使用// eslint-disable-next-line catch-ai-slop/no-generic-names来临时禁用某行代码的检查。把决定权部分交还给开发者。6.2 性能考量添加一堆AST遍历规则会不会拖慢ESLint的速度挑战复杂的规则尤其是那些需要跨作用域追踪变量引用的规则确实会增加计算开销。解决方案优化遍历器在ESLint规则中选择最精确的节点选择器。只在必要的节点类型上添加监听器避免全局遍历。缓存机制对于需要频繁查询的信息如作用域引用在规则上下文内进行缓存。按需启用在CLI的推荐配置中我们将所有规则默认设置为‘warn‘而非‘error‘。并且明确文档说明每条规则的计算复杂度让用户在性能敏感的项目中可以选择性关闭某些重型规则。实测在一个中型项目约1000个文件上测试启用全套规则后ESLint运行时间增加了约15%这在可接受范围内。大部分开销来自作用域分析这是ESLint本身的基础成本。6.3 规则冲突与优先级我们的规则可能会和Prettier、TypeScript ESLint或其他风格指南规则冲突。挑战例如我们的“偏好现代语法”规则建议使用箭头函数但团队可能有一套关于何时使用function声明的特定风格指南。解决方案明确立场在文档中声明本插件聚焦于“代码质量与可维护性反模式”而非“代码风格”。如果与风格指南冲突应以项目风格指南为准。提供可关闭的规则所有规则都是可配置、可关闭的。使用ESLint的--fix兼容性确保我们的自动修复不会与Prettier的格式化产生冲突。通常的做法是先运行Prettier再运行ESLint with--fix。6.4 维护与规则更新AI在进化AI生成的代码模式也在变化。挑战今天有效的规则明天可能因为AI模型更新而变得过时或产生大量误报。解决方案建立反馈渠道在GitHub仓库鼓励用户提交Issue报告误报或漏报的案例。这些案例是优化规则的宝贵素材。数据驱动更新考虑在匿名且合规的前提下收集一些匿名化的、触发规则的代码片段用于分析新模式。版本化与渐进式规则对规则集进行版本管理。引入新的实验性规则时先放在一个‘experimental‘配置集中默认不启用让愿意尝鲜的用户试用并提供反馈稳定后再纳入‘recommended‘。7. 效果评估与开发者反馈项目上线后我通过几种方式评估其效果下载量与使用情况npm的下载量是一个粗略指标。1,137次的周下载表明有相当多的开发者感兴趣并尝试了。GitHub Star与Issue开源仓库的Star数、Fork数以及提交的Issue和PR是衡量项目活力和实用性的关键。我收到了不少有价值的反馈比如对某条规则误报的抱怨或者建议检测新的模式如“AI生成的过于详细的错误处理try-catch块”。实际代码改进案例最有成就感的是看到用户提交的Issue中附上了使用工具前后代码的对比图或者在其他开源项目的PR中有人引用我们的规则作为代码优化的依据。一些有趣的反馈“这个工具帮我抓到了团队里一个新人在用Copilot时引入的十几个var声明教育意义大于实际意义。”“no-generic-names规则对我启发很大我现在写提示词时会特意强调‘使用具体的变量名’生成的代码质量果然提高了。”“能不能增加对Python/Go的支持”这是一个常见的扩展请求但目前精力有限聚焦JS/TS生态已经很有价值。8. 总结与展望构建这个工具的过程是一个将模糊的“代码味道”感觉转化为精确、可执行规则的过程。它不仅仅是一个检查工具更是一个关于“什么是好代码”的、持续更新的共识集合。AI辅助编程是大势所趋我们的目标不应该是排斥它而是学会如何更好地与它协作引导它产出更高质量的结果。这个项目的成功也印证了在开发者工具领域解决一个具体、明确、有痛点的场景远比做一个大而全的框架更容易获得关注和采纳。它不需要改变用户习惯而是无缝嵌入现有工作流并提供即时、可见的价值。对于想尝试类似项目的开发者我的建议是从一个让你自己非常恼火的、具体的“坏味道”开始深入分析它的模式用AST把它准确地描述出来然后做成一条ESLint规则。先解决一个问题再慢慢扩展。开源出去听听别人的反馈你会发现很多你没想到的用例和边界情况这会让你的工具变得越来越好。最后工具是死的人是活的。再好的静态检查工具也不能替代严谨的代码审查和工程师的判断力。它应该是一个“副驾驶”在你疏忽时提醒你而不是一个“独裁者”剥夺你写代码的乐趣和灵活性。