
博主最近在整理团队内部的工程质量体系时发现一个很有意思的现象市面上各种代码检查工具、静态分析器、规范校验插件已经多到数不过来但真正能在一个项目里跑得顺畅、没什么噪音、能让大家愿意天天用的其实少之又少。很多工具要么规则死板误报满天飞要么配置复杂到只有写工具的人自己看得懂。于是我用业余时间自己动手做了一个名为“impeccable”的命令行体检工具——它的目标不是把事情搞复杂而是恰好相反用最少的规则、最干净的输出、最合理的退出机制把“代码检查”这件事做到无可挑剔。这篇文章会从设计思路、核心实现、实操配置到踩坑记录完整复盘这个项目。1. 项目概述不是又造一个轮子“impeccable”这个名字听起来有点“自恋”其实背后想表达的意思很朴素让代码检查的结果做到无可挑剔。它不是一个全新的静态分析语言也不是要替代已有的重量级工具而是定位在“工程化流水线上最后一道舒服的闸门”。1.1 从一次线上故障说起某天下午线上服务突然告警追查到最后发现根因特别蠢——一个枚举值写错但因为没有做参数校验错误一路穿透到数据库层才爆发。修复大概花了十分钟但复盘时大家沉默了这类问题其实在提交代码阶段就能被机器拦住但当时的流水线里虽然挂了多个检查工具它们各查各的没有人真正对“跨文件、跨模块的约定一致性”负责。某个开发者在本地改了接口签名调用方的参数顺序还是旧的编译器不报错lint也不管于是线上就炸了。这次事故让我下决心做一个真正能“读懂项目上下文”的检查工具。市面上已有的工具大多擅长模式匹配但对项目内部自定义的约定——比如“所有对外暴露的接口入参不能超过三个”“新增的枚举必须带描述”“禁止在业务层直接依赖底层缓存客户端”——几乎没有响应能力。impeccable 的定位就是补充那些“通用工具管不到、代码评审又容易漏掉”的项目级自定义规则。1.2 设计目标宁缺毋滥开始动手前我给自己定了三条铁律后面所有开发都围绕这三条走不追求检查项的数量追求每一条规则的精准度和低误报率输出必须清爽一条问题一行带上文件、行号、规则名和一句话原因描述退出码要严格区分“严重”“警告”和“通过”方便直接对接CI门禁这三条听起来简单做起来非常考验取舍。比如早期我加入过一条“禁止使用 console.log”的规则但项目里有些工具脚本确实需要用 console 输出结果误报率高得惊人被团队吐槽了很久。后来我把这类规则改成“只检查 src 目录下的业务代码不检查 scripts 目录”误报率一下降到零。1.3 使用者画像与适配场景适合使用这个工具的人我总结下来有三类中小型团队的技术负责人希望在不引入一套重型质量平台的前提下快速建立项目级规范独立开发者想在发布前自己把关一遍代码里那些“约定俗成”的事项对现有 lint 工具的配置感到痛不欲生想要一个“开箱即用、规则可裁剪”的轻量方案的朋友应用场景也不限于某个特定语言。impeccable 本身是个解释器加规则引擎的壳子通过插件可以对接不同的语言解析器。目前我主要适配了 JavaScript/TypeScript 与 Python 两个生态后续正打算补上 Go 的支持。2. 核心架构拆解“检查器-报告器”两段式设计老话说得好工具不怕小就怕结构乱。impeccable 虽然看起来是个命令行小工具但内部我把整个流程拆成了两个独立的阶段检查阶段和报告阶段。这样做的好处是未来哪怕要接 IDE 插件、Web 面板或者自定义通知机器人只需要复用报告阶段即可检查逻辑完全不用动。2.1 模块结构总览整个项目分五个核心模块rule-registry规则注册中心负责加载所有内置规则与用户自定义规则scan-reducer文件扫描器负责把项目文件按语言分类并做基础上下文收集exec-engine执行引擎遍历规则并处理规则间的依赖关系report-formatter报告格式化器支持文本、JSON、JUnit 样式输出exit-resolver退出码决策器按问题级别计算最终退出状态这五个模块之间通过非常简单的事件总线通信不搞复杂的依赖注入。从一开始我就明确了一个原则可以用 200 行代码解决的绝不为了架构好听而上框架。2.2 规则注册中心一切皆规则在 earlier 版本里规则是硬编码写死在执行循环里的。每加一条规则就得改核心代码非常痛苦。后来我重构出了 rule-registry把“规则”抽象成统一接口dataclass class RuleContext: file_path: str file_content: str project_root: Path language: str config: dict class BaseRule: rule_name severity warning # error / warning / info target_languages [] def check(self, ctx: RuleContext) - list[Issue]: raise NotImplementedError每条规则只需要实现check方法返回一个Issue列表。Issue里包含行号、列号、消息和自动生成的处理建议。规则之间如果存在依赖关系例如“先检测接口签名再检测调用方”可以在规则里声明depends_on字段执行引擎会自动做拓扑排序。2.3 元信息收集器高质量提示的地基很多静态分析工具输出问题只看局部几行代码给出的提示经常是“这行可能有问题”这种废话。impeccable 有个比较独特的设计在真正执行规则之前会先跑一遍元信息收集阶段。这个阶段会做几件事提取文件的导入/依赖关系识别是否处于测试目录对函数和类建立轻量符号表标记“导出”与“内部使用”符号这些元信息会挂载在RuleContext上。所以规则在检查时可以回答非常具体的问题例如“这个文件的导出函数的入参是否在别处被引用过”“这个工具函数的调用点是否都经过类型守卫”。有了这些上下文输出的提示就能从“这行可能有问题”变成“这里有个潜在错误枚举状态 SYNCING 已被人为改为 PAUSED可能导致状态机无法恢复”。2.4 进度反馈慢不是原罪没反馈才是经验告诉我一个扫描工具哪怕跑 30 秒都是可以接受的但如果在 30 秒内不输出任何进度信息用户的第一反应就是“卡死了”。所以从第一个版本起我就加入了进度与预估输出。具体做法是scan-reducer 先快速遍历所有文件并计算总行数然后每处理完 1000 行就刷新一次进度百分比同时在 exec-engine 里打印当前正在执行的规则名。输出走标准错误流这样正常的结果输出到 stdout 时不会被进度信息污染。3. 实操一步一步搭建一个可复用的体检工具这章是整篇文章里最“抄作业”的部分。我不光讲 impeccable 是怎么写的还会带你把一个简化版从零跑起来。你不需要照搬我的全部代码只要理解关键环节的设计意图就能按自己的需求改出一套趁手的工具。3.1 创建项目结构与依赖整洁的项目结构是一个工具长期可维护性的基础。impeccable 采用传统 src 布局核心代码可被其他模块复用impeccable/ ├── src/ │ └── impeccable/ │ ├── __init__.py │ ├── cli.py │ ├── rules/ │ │ ├── __init__.py │ │ └── builtin_rules.py │ ├── scanner/ │ │ ├── __init__.py │ │ └── reducer.py │ ├── engine/ │ │ ├── __init__.py │ │ └── executor.py │ ├── report/ │ │ ├── __init__.py │ │ └── formatter.py │ └── utils/ │ ├── __init__.py │ └── exit_codes.py ├── tests/ │ ├── fixtures/ │ └── test_rules.py ├── pyproject.toml └── README.md依赖方面只用了三个库typer命令行参数解析rich终端格式化输出tree-sitter多语言代码解析的底层引擎tree-sitter 是个好东西它诞生于某编辑器项目的语法解析需求支持增量解析性能上比正则表达式匹配可靠得多。当然如果只是扫描非常轻量的项目自己写简单的括号匹配加正则也能凑合。但一旦要做符号表或接口签名识别正则方案会非常痛苦强烈建议直接上 tree-sitter。3.2 实现第一版内置规则第一版我挑选了三条最有代表性、又不依赖复杂语言特性的内置规则未使用变量检查、TODO 标记检查、死代码引用检查。先看未使用变量检查。用 tree-sitter 解析源码后遍历声明节点再维护一个“是否被引用”的集合。实现时要注意一个小细节函数参数默认不算未使用因为很多接口为了保持签名一致会故意保留未使用的参数。再看 TODO 标记检查。虽然这个规则看起来很简单就是匹配注释文本但很容易出现一个尴尬的情况大型项目里写 TODO 的人多、清理 TODO 的人少每次扫描都刷出一大片最后没人再看报告。我的处理是给这条规则加了配置项支持设定“允许存在”的截止日期——只要 TODO 注释里写了超过设定日期的日期才报告避免报告被无关紧要的旧记录淹没。最后看死代码引用检查。这条规则会查询符号表如果一个函数只被测试文件引用且不在__all__导出列表里就会提示 “标记为仅测试引用”并建议确认是否需要导出。一开始这条规则疏漏很多后来我发现关键不在于查引用次数而在于正确区分“测试代码中的引用”和“业务代码中的引用”在元信息阶段打上测试目录标签后准确率才上来。3.3 报告输出与退出码设计细节报告输出我采用了分段式设计。普通文本模式用于本地开发JSON 模式用于 CI 解析JUnit 格式用于已有的测试报告面板。实现上通过 report-formatter 模块的一个简单的工厂函数分发def get_formatter(fmt: str, stream: IO[str]) - BaseFormatter: if fmt json: return JsonFormatter(stream) if fmt junit: return JUnitFormatter(stream) return PlainTextFormatter(stream)退出码决策是我特别想强调的部分。最初我把所有问题都归成一类发现 CI 上只要有一条 warning 就红开发者被逼得直接禁用工具。后来我设计了四级退出码机制退出码含义使用时机0通过无问题全部检查通过0通过但存在 info 级别提示仅有提示性信息1发现 warning有警告级别问题不影响门禁2发现 error有严重级别问题块级门禁这样设计后CI 上默认以退出码 2 作为门禁warning 会展示给开发者但不强制阻断。等团队适应后可以把门禁逐步收紧到退出码 1。这套灰度方案在实际落地时非常顺滑没有再出现“直接禁用工具”的极端情况。3.4 实现可用的 CLI 交互作为命令行工具交互体验决定了开发者是否愿意天天使用。impeccable 的 CLI 默认执行全部检查也支持通过--rule参数选择单条规则运行例如调试阶段只关心接口签名检查。一个更实用的功能是新增了--diff模式它读取 Git 的 diff 信息后只检查本次改动涉及的文件。这在大型仓库中极其实用因为全量扫描一次可能需要完整跑几分钟但增量检查在几百毫秒内就能完成。实现上并不复杂读取git diff --name-only --diff-filterAM的输出过滤出支持的文件后缀交给 scan-reducer 处理即可。CLI 还做了色彩适配。终端支持时就输出彩色级别标签管道重定向时自动降级为纯文本避免写入日志文件时掺杂 ANSI 转义序列。我用 typer 内置的 echo 方法结合颜色检测函数完成了这个小功能代码量也就二十来行。4. 接入 Git 钩子与持续集成流水线一个体检工具如果只在人想起来才跑那价值会大打折扣。真正让 impeccable 融入日常开发流程的关键是把它挂到 Git 钩子和 CI 流水线上做到“提交前提醒、合并前拦截”。4.1 pre-commit 钩子配置我最初是手动往.git/hooks/pre-commit里写脚本的但这有个问题团队成员克隆仓库后钩子不会自动生效。后来改用了团队普遍采用的 pre-commit 框架管理钩子。新建.pre-commit-config.yaml把 impeccable 作为本地钩子注册进去repos: - repo: local hooks: - id: impeccable name: impeccable entry: impeccable check --diff language: system types: [python, javascript] pass_filenames: falsepass_filenames: false很重要因为我们依靠--diff自主决定检查范围而不是由 pre-commit 逐个传入文件。这样还能顺带解决增量场景下“只传了单一文件导致跨文件符号缺失”的问题。4.2 CI 门禁集成范例在 CI 流水线里我一般将检查步骤放在单元测试之前。毕竟编译和测试成本更高先用静态检查拦截掉明显问题可以为整个流水线节省大量算力。一个典型的示例阶段如下- name: Run impeccable checks run: | impeccable check --format junit \ --output reports/impeccable.xml然后在流水线的质量门禁配置里读取该 XML如果发现有 error 级别的问题就阻断合并请求warning 级别的问题只打标签提醒。设好之后我观察了两周合并请求的平均“来回修改”次数从三点几次降到了两点几次这说明工具确实逼着大家在提交前多想了想。4.3 与已有工具的协作关系有个问题被反复问到已经有了 ESLint、Flake8、Pylint为什么还需要 impeccable我的理解是这样的通用工具负责通用规则——语法错误、代码风格、明显的坏味道。impeccable 负责项目专属约定——自定义接口格式、模块边界、数据流约束。它俩不是替代关系更像是“国家法律”和“小区物业规定”的区别。通用法律管所有人小区规定管具体楼栋。所以在配置层面我默认忽略所有通用工具已经覆盖的规则类别避免重复劳动。比如 ESLint 已经检查了未使用变量impeccable 就只在需要做“跨模块一致性”判断时才碰同名未使用的问题。5. 实际使用中遇到的问题与排查实录工具开发过程中我积累了不少实战问题与排查心得。整理成一张速查表给想自己折腾类似工具的朋友一份参考。现象可能原因排查思路解决建议扫描结果与IDE提示不一致tree-sitter版本差异导致解析结果不同检查IDE插件与CLI工具底层解析器版本统一解析器版本并固定依赖锁文件大仓库首次扫描极慢元信息收集阶段全量建立符号表使用--diff增量检查或者只在合并前全量扫描按时段调度全量扫描日常开发只跑增量退出码一直为0自定义规则未声明 severity 或未注册确认规则类是否继承 BaseRule 且 check 方法返回非空增加内置冒烟测试强制每条规则至少触发一次JSON 报告中文出现乱码输出流编码未指定 UTF-8检查 CI 环境默认字符集在 CLI 入口强制 stdout/stderr 重配置编码Git 钩子报错但不阻断提交pre-commit 钩子没有识别到 reporter 错误确保命令返回非零退出码增加钩子脚本日志明确输出阻断原因5.1 误报率偏高怎么办早期某条字符串格式规则误报率一度突破四成后来定位到是规则没考虑多行模板字符串的情况。解决方法是两条腿走路在规则实现里补充对模板节点的处理逻辑在项目配置文件里加入“路径排除”与“行内豁免”两个逃生通道行内豁免很有意思类似其他工具里的 eslint-disable 注释我们采用项目专属指令。例如在代码行尾加上# impeccable-ignore: rule-name规则引擎检测到该注释后跳过当前行。这个设计给了开发者一个体面的申诉渠道大大减少了因为误报而直接卸载工具的冲动。5.2 多语言项目如何统一规则当同一个仓库里既有 TypeScript 又有 Python 时很多检查逻辑其实可以复用。抽象层的关键在于“语法中立化”。我把规则分成两类一类是语法强相关规则比如“Python 里禁止可变默认参数”这类规则只能绑定特定语言。另一类是语义相关规则比如“任何导出的函数必须有注解说明”虽然语法不同但各语言都能解析出“导出函数”这个节点。impeccable 采用规则自声明目标语言的方式执行到不匹配的语言文件时自动跳过。这样在混合仓库中一次扫描就能覆盖全部语言而不会出现“同类约定在这门语言查了、在那门语言漏了”的缺口。5.3 性能调优关键步骤说到扫描速度很多第一次使用这类工具的人会震惊于大项目全量扫描的耗时。按我实测的数据一个约二十万行的 TypeScript 仓库首次全量扫描约三秒加上元信息收集后约八秒。这个性能主要归功于 tree-sitter 的内存效率和并行扫描。并行扫描方面我使用了 Python 内置的concurrent.futures.ProcessPoolExecutor按文件分片并行执行规则充分利用多核。性能调优中最容易忽略的点是避免重复解析同一个文件。我给 scan-reducer 加了简单的按路径的缓存同一个文件在元信息收集和规则执行阶段都复用同一颗语法树实例内存占用直接降了一半。6. 工程化优化方向与使用进阶技巧工具做到能跑、能检查、能出报告之后我开始思考它如何更好地融入开发者的日常操作而不只是一个被动的“守门员”。6.1 配置文件的组织方式impeccable 的健康检查配置支持多级合并命令行参数优先其次是项目配置文件最后是内置默认值。配置文件使用 YAML 格式因为大部分开发环境的编辑器都能自动补全和着色。一个典型的项目配置示例rules: interface-arity: severity: error max_args: 3 no-todo-expired: severity: warning module-boundary-check: enabled: true allow_list: - src/domain/** secret-format-check: severity: error excluded_paths: - tests/fixtures/**文件里还可以配置输出选项、豁免规则、默认扫描目录。我比较推荐把配置文件纳入版本管理这样规则变更时能清楚地看到历史和原因。6.2 自定义规则模板从拷贝改到演化内置规则再丰富也覆盖不了所有团队的奇奇怪怪的约定所以自定义规则是第一优先级。为了让开发者写规则的门槛降到最低我提供了规则模板生成命令impeccable new-rule --name auth-header-check生成的骨架代码会带上完整的示例与测试用例。开发者只需要关注 check 函数里的具体逻辑不需要关心扫描流程和结果上报。这套机制后面成了很多团队搭建自身工程规范的最佳入口点。6.3 更进一步作为在线服务运行需要中心化决策时impeccable 还可以作为服务端工具运行。它会吃进项目仓库上的注册事件自动拉取代码执行健康检查并将结果输出到统一的看板上。个人使用的话完全没必要上服务端但团队大了以后一个统一的“质量仪表盘”确实能有效暴露跨模块的工艺债务帮助管理者做资源调度决策。我自己的建议是先本地好好用三个月确认规则稳定了再谈服务化。7. 实操体验后的几点心得工具写下第一行代码时我的预期只是给自己用没想到后面会被几个小型技术团队采用。回头复盘真正让一个检查工具变得受欢迎的往往不是检查能力有多强而是它是否懂得克制。“克制”体现在几个方面宁可漏报也不要制造大量需要人工确认的噪音宁可规则少一些也不要让维护成本拖垮迭代节奏宁可退出码设计复杂一点也要让 CI 接入方拥有渐进式收紧的空间。另外一个小技巧很有必要分享给规则写单元测试时不要只用“预期错误”的用例一定要加入“不应该检测出问题”的用例。后者才是控制误报率的关键。我大约有三分之一的时间都花在打磨这种负例测试上但收益是长期且显著的。如果你也想做类似的项目我的建议是先花一天时间收集你们团队在评审中反复提到的五类问题把它们写成规则原型跑一周看报告里有多少条是值得看的再决定是否继续扩展。好的工具不是堆出来的是筛出来的。