刚接手一个两年前的老项目时我做的第一件事不是读 README也不是跑原型而是全局搜了一遍TODO、FIXME、HACK这些标记。那个感觉就像推开一间久没人住的老房子墙上是前一任房客随手贴的便利贴有的写着“这里水龙头会漏等我换个接头”有的写着“这面墙先别拆里面有电线”还有的写着“回头再说”。问题是谁也不知道哪些便利贴已经失效哪些下面藏着真正的雷。这个痛点陪我度过了好几年的接盘与重构最后我用一个叫caveman的小工具解决了大半。它做的事情极其简单把整个代码仓库里所有遗留标记扫描出来按文件、按类型、按严重程度列成一张表让我在十分钟之内知道这个项目埋了多少债、债集中在哪里。整个工具只有一百来行核心代码但它在团队里用了一年多成了我们技术债复盘和 CI 质量门槛的一部分。这篇文章不打算写成工具文档而是把我从零设计这个扫描器的完整思路、踩过的假阳性坑、以及如何把它从“一个脚本”变成“代码健康入口”的过程拆开讲一遍。适合那些经常接手旧项目、或者想在团队里推技术债清理的人参考。1. 缘起为什么我写了一个叫 caveman 的标记扫描器1.1 代码库里的“考古现场”先说清楚这个工具的出发点。几乎每个上了规模的代码库里都会堆积大量遗留标记。大家工作流里常见的TODO、FIXME、HACK、XXX、BUG本质上就是一种“临时对话”是开发者写给未来自己或同事的便条。问题在于这些便条散落在注释里、字符串里、甚至正常的变量命名里时间一长就变成了一种“背景噪音”。我在一个维护了三年的服务端仓库里做过一次粗略搜索光TODO就有四百多处分布在两百多个文件里。其中至少一半是三四年前写的对应的代码模块早就重写了好几遍但那句“这里要改成异步”还静静躺在注释里仿佛时间没走过。最让人头疼的不是量多而是没人敢忽略它们。你不知道哪些FIXME背后是已知的线上隐患哪些是写作者随手留的路标。我见过一次生产事故故障根因就是一段带HACK标记的代码被后续重构者当成了“设计如此”因为没有一份清单告诉他那个标记意味着什么。所以我需要的不是“搜出这些词”而是把散落的信息变成一张可管理、可追踪、可分发的清单——这就是 caveman 最初的轮廓。1.2 起名 caveman 的用意工具就该像穴居人一样简单能打工具的名字是我刻意取的。caveman有“原始人、穴居人”的意思我想要的就是这种气质不搞花活不搞大而全的平台用最朴素的招式解决最具体的问题。很多类似的工具都会努力做成 IDE 插件、做成带数据库的趋势平台、做成能自动分类 AI 摘要的重型服务。我不是反对这些方向但对一个“帮我看清楚仓库里有多少债”的需求来说两层重量可能就够一层是扫描逻辑一层是结果展示。再加东西维护成本就比工具本身带来的收益还高了。起名 caveman 的第二层意思是提醒自己保持“原始直觉做判断”。遇到一个FIXME大多数时候不需要去理解上下文、分析调用链先把它归到对应文件、对应模块统计出来决策留给使用者。工具做的是“发现并盘点”不是“理解并处置”。能够长期被团队使用的工具往往就是这种把判断主动权留给人的工具。1.3 我的核心目标与适用场景写这个工具之前我给自己定了几条硬性目标后来也成为整个设计过程中的取舍依据能在任意主流语言的项目里直接跑不需要额外编译环境。扫描结果要稳定可复现同一份代码跑两遍结果必须一致。规则可配置团队可以自定义哪些标记算紧急、哪些算提示。能接入 CI失败时能明确告诉开发者是哪个文件哪一行出的问题。单文件脚本级别随便拷贝到新环境就能用。基于这些目标它最适用的场景有三类。第一类是接手旧项目时的快速尽调跑一遍就能知道哪些模块技术债最重第二类是迭代过程中的常态化检查配合 CI 不让新的临时标记悄悄混过去第三类是技术债清零活动用报告数据倒推模块负责人、排优先级。当然它也有不适用的时候比如你需要分析代码语义、理解标记所在逻辑的完整上下 chain这时候需要的是 AST 级别工具或人工 review。caveman 定位是“第一道筛子”不是“结论生成器”。2. 扫描引擎的核心设计从“正则打天下”到“语法感知”2.1 第一版全局正则的痛快与翻车caveman 最早的雏形非常原始就是遍历仓库里所有文本文件对每一行跑一个大正则。核心逻辑不超过二十行读取文件逐行匹配\b(TODO|FIXME|HACK|XXX|BUG)\b命中就记录文件名和行号。那一版跑起来是真的快三秒钟扫完一个几千文件的中型仓库。投入使用的第一个小时看起来也很美好输出了一份像模像样的报告。但紧接着假阳性就开始疯狂刷屏。最典型的一类情况是README 里写着“check the TODO list in this repo”这行文本会被当成仓库里存在一个 TODO 标记还有代码里有人写了个getHackById()的方法HACK会被误认为遗留标记更常见的是测试用例里的字符串比如response[todo] draft因为我的第一个版本用的IGNORECASE小写todo也全被捞出来了。那天的报告里真实标记可能有一百多个但报告列出来三百多条。人眼扫过去本来想快速定位问题结果被噪音淹没。这件事给我上了很实在的一课全局正则看起来简单但“看起来像标记”和“真的是开发者留下的标记”之间有巨大的距离。从那次之后我把设计目标从“找出所有提到这个词的地方”修正为“找出开发者真正作为标记写下的位置”。2.2 用 tokenize 拿掉一半假阳性修正的第一步是利用 Python 自带的tokenize模块做语法感知扫描。tokenize能把 Python 源码拆成一个个 token并且每个 token 都带着类型比如COMMENT、STRING、NAME、NEWLINE。用它做扫描的好处是我不用再自己判断某段文本到底在注释里还是代码里也不需要处理跨行字符串这种正则容易翻车的边界。我用tokenize实现的扫描逻辑是遍历所有 token只关注COMMENT类 token 和STRING类 token。原因很直接绝大多数遗留标记写在注释里这是开发者默认的“便签区”。一部分标记会写在字符串里典型如logger.warning(FIXME: auth timeout)或者测试里的说明文案。变量名、函数名、类型名里的“TODO/HACK”这类词原则上不属于遗留标记语义应当忽略。这种设计一下子过滤掉了大量假阳性而且过滤的是“机制层面”的噪音不是靠维护一长串黑名单稳得多。当然tokenize只对 Python 文件有效对于 JS、Java、Go 这些文件我的方案是“Python 文件走 tokenize其他文件退回按注释符号做行级预过滤再对注释文本做标记匹配”。也就是先看一行里//、#、/*之前有没有非空白代码再用正则判断标记。这种方式虽然不如 tokenize 精确但配合“忽略大小写 要求词边界”也能达到够用的准确度。2.3 “宽容优于严格”的取舍哲学假阳性压下去之后我差点犯另一个方向的错误为了把准确率拉到 99%开始不断加规则比如要求标记后面必须跟冒号、标记前面的空格数、标记所在文件不能是 test 开头……结果规则变得越来越复杂新成员根本搞不懂某个标记为什么没被扫出来。后来我反思了一下重新立了一条原则漏报比误报危险得多。一个FIXME真实存在于代码里却因为某条过滤规则没被扫出来它的风险就被完全隐藏了反过来一条误报最多让人多点一次人工确认。所以 caveman 的默认策略是宁可多报一点把误报留给用户通过配置排除也不要因为聪明过头漏掉真的。实际操作中我保留了两个“宽容”按钮一个是按文件路径排除比如tests、docs、generated目录可以整体过滤另一个是按标记类型调节灵敏度比如TODO默认大小写不敏感因为大家写todo也很常见而HACK和BUG默认大小写敏感且要求词边界避免碰伤hackathon、getHackById这种无辜词。团队用下来准确率基本满意而且规则的维护成本几乎为零。3. 关键代码拆解一百行左右的极简实现3.1 配置规则用 JSON 做一份团队都看得懂的清单我不喜欢把规则写死在代码里尤其是这种团队工具。最终我把规则做成了一份 JSON 配置放在仓库根目录的.caveman.json。它的结构很直白{ root: [src, tests], exclude_paths: [node_modules, dist, .venv, generated], extensions: [.py, .js, .ts, .go, .java, .c, .h], markers: { FIXME: { pattern: \\bFIXME\\b, level: blocker, ignore_case: false }, HACK: { pattern: \\bHACK\\b, level: blocker, ignore_case: false }, TODO: { pattern: \\bTODO\\b, level: info, ignore_case: true }, XXX: { pattern: \\bXXX\\b, level: info, ignore_case: true }, BUG: { pattern: \\bBUG\\b, level: blocker, ignore_case: false } } }level字段是给 CI 用的blocker级别的标记会让扫描失败info级别只展示不阻断。之所以这样设计是因为FIXME和BUG通常意味着“当前代码有问题”而TODO更接近于“未来要做的事”两者的紧急程度天然不同。团队可以根据自己的节奏调整希望严格一点就把 TODO 也设为 blocker。3.2 核心扫描器代码下面是扫描器的核心部分我让它只做“扫描单文件”这一件事目录遍历交给外层函数。这样逻辑最干净也方便以后单独测试某个文件。# caveman.py核心片段 import tokenize from pathlib import Path import re def marker_matches(text: str, rule: dict) - bool: flags re.IGNORECASE if rule.get(ignore_case) else 0 return re.search(rule[pattern], text, flags) is not None def scan_python_file(path: Path, markers: dict): issues [] with path.open(rb) as f: try: tokens tokenize.tokenize(f.readline) except (tokenize.TokenError, IndentationError, UnicodeDecodeError): return issues for tok in tokens: if tok.type not in (tokenize.COMMENT, tokenize.STRING): continue line_no tok.start[0] if tok.type tokenize.COMMENT else tok.end[0] for marker, rule in markers.items(): if marker_matches(tok.string, rule): issues.append({ file: str(path), line: line_no, marker: marker, text: tok.string.strip()[:120], }) break return issues针对非 Python 文件我会先做一步“行级注释预过滤”再走同样的正则匹配。这一步在scan_generic_file里实现逻辑大约是把每一行在注释标记之前的代码去掉再取注释部分做匹配。虽然牺牲了一些精度但换来了跨语言能力对一个小工具来说这笔交易划算。目录遍历部分我的做法是先按extensions过滤文件后缀再按exclude_paths跳过不需要扫的目录。这两个条件都来自上面那份 JSON任何团队成员想加一个排除目录改配置就行不需要碰代码。3.3 报告输出与退出码设计扫描器最终输出一份人类可读的报告同时预留 JSON 输出能力供后续统计和其他工具消费。终端报告长这样 caveman report scan root: ~/work/legacy-app files scanned: 2084 issues found: 487 top markers: TODO 341 FIXME 87 HACK 45 XXX 12 BUG 3 top files: src/legacy/parser.py 12 src/utils/http.py 9 tests/e2e/order.py 8 --- details --- src/legacy/parser.py:87 [FIXME] 这里处理不了嵌套括号后续重写 src/utils/http.py:212 [HACK] 临时绕过上游超时需要换长连接池 tests/e2e/order.py:12 [TODO] 补充鉴权用例退出码的逻辑和 CI 对接直接相关默认无blocker级别标记时退出码为 0有则退出码为 1。--json参数会输出一份结构化的 JSON 报告包含所有问题的文件、行号、标记类型和原文摘要。这个 JSON 格式从第一天起就没怎么变过因为下游统计脚本、趋势图工具都依赖它稳定比花哨重要。4. 真实项目实测一轮扫描之后发生了什么4.1 一次扫描的结果解读工具成熟后的第一次大规模使用是在一个同时包含 Python 服务端和 TS 前端的仓库上总共 2084 个源码文件。跑完一轮结果和团队此前的“感觉”差异很大。标记类型数量典型分布TODO341集中在工具模块和 API 层FIXME87集中在旧的解析器和支付回调HACK45集中在临时兼容层XXX12分散各处BUG3两处历史遗留一处刚引入这份表格最有价值的地方不是总数而是“集中度”。按文件排名之后我们看到排前几名的文件都来自同一个模块——“旧版订单解析器”。也就是说绝大部分的代码债并不是均匀分布在整个仓库里的而是集中在几个被反复打补丁的“黑洞文件”里。这个结论直接影响了后面的排期我们并没有发起“全仓 TODO 清零”这种让人望而生畏的活动而是锁定那 5 个高密度文件做专项重构。重构完再看这几个文件的统计FIXME 和 HACK 从几十条直接掉到几条。这是我最想看到的效果扫描器把能量导到了最需要的地方而不是让团队在低价值区域空耗。4.2 假阳性翻车记录与规则修正当然实测过程中不可能不翻车。我记录了几次典型的假阳性案例大家可以对照自己的仓库情况做预防。场景现象处理办法README 里写“TODO list”整个文档被当作标记扫出来默认不扫.md文件代码里方法名getHackById被当成 HACK 标记HACK 规则改为大小写敏感并开启词边界日志文案“todo: draft”被当成 TODO 标记保留大小写不敏感但增加排除词功能注释里的示例代码// example: TODO_util()连带_util被记录通过exclude_patterns支持忽略特定前后缀处理这些假阳性时我的原则是优先调整规则结构而不是往报告里塞过滤清单。比如getHackById这种问题通过“大小写敏感”这个开关就能解决TODO_util这种问题我在配置里加了exclude_patterns字段允许用户写一组正则当标记匹配文本整体命中排除规则时直接跳过。这样既不用改代码又能覆盖团队自己的命名习惯。4.3 接进 CI 之前要考虑的三件事把 caveman 加入 CI 是一个分水岭。在那之前它只是一个“锦上添花”的报告工具接入之后它有了实际约束力。但在这一步上我有三个教训值得先说清楚第一不要第一次接入就全量阻断。如果因为历史存量让 CI 直接失败整个团队会怨声载道最后大概率是把工具停掉。我的做法是先设为non-blocking在流水线里输出报告但不影响合并跑两周让大家习惯看到报告之后再逐步把FIXME、HACK设为 blocker。第二尽量只扫变更文件。一个已经有三四百条历史标记的仓库如果全量扫描并阻断等于新代码一进来就要为老代码背锅。我们最终的方案是默认只对本次 MR 涉及的文件做扫描历史存量由单独的周度任务去跟踪。这样新增的问题能被拦住历史问题也不会阻碍迭代。第三blocker 标记要给一个解释出口。总会有开发者确实需要临时提交一个带FIXME的改动这时候扫不到就有点僵硬。我们规定可以在 MR 描述里写清楚原因由 reviewer 人工确认后临时放行。简单工具的约束力恰恰来自它留给人的灵活性。5. 从一个“标记扫描器”扩展成“代码健康入口”5.1 用报告数据画一张清理趋势图功能稳定之后我开始玩一点花样。最直接的一个用法把每周扫描的 JSON 报告存档然后写一段十几行的脚本汇总数据输出一份按周统计的标记数量变化图。看着趋势图其实是很有成就感的。最初那个月数量波动很大偶尔因为某个老模块被完全删除“待办”总数肉眼可见地下降后来又因为新项目启动TODO 数量快速上行。时间一长这张图成了团队例会上聊“我们是不是越欠越多了”的客观依据。讨论技术债不再靠感觉而有了一条真实的曲线。5.2 把结果喂给其他工具JSON 输出除了画图还可以接各种下游。我试过两种比较实用的联动方式第一种是给 IDE 用。把报告转成编辑器能识别的格式比如 VS Code 的“问题面板”那种格式开发者打开文件就能在对应行看到标记。这个方案尤其适合刚接手一个模块的人相当于每个标记成了嵌入代码里的“书签”。第二种是给聊天机器人和周报用。我写过一个小脚本每个月把 JSON 汇总后生成一段文字摘要内容大致是“本月新增 TODO 143 条FIXME 22 条集中在 x 模块”直接放进团队周报里。不需要人肉去数信息自己会流动起来。5.3 守住边界为什么我不把它做成完整插件聊到这里肯定有人会问这功能这么好为什么不干脆做成一个完整的 IDE 插件、配上云端平台、再搞个 AI 自动分析呢我的回答可能有点反主流正是因为它没做那么多它才能活这么久。做成插件意味着要适配各种编辑器版本、操作系统、快捷键体系维护成本会指数上升做成平台意味着要设计用户体系、权限模型、数据存储工具本身就不再是“随手抄走就能用”的东西了。而 caveman 的核心价值一个是极低的上手成本一个是没有负担的部署方式这两点在演进过程中始终没有被破坏。当然也有一些正向的适度扩展我会考虑加比如把标记自动关联到 Git 提交记录看看某条标记是什么时候、由哪个提交引入的——这能让清理排序更有依据。但每一步新增能力之前我都会先问一句这个功能会不会让“拿过来就能跑”变得复杂如果会就先放一放。最后分享一个小技巧每次在团队里推广这种“清理类工具”我都会用一句“先扫后改”来做铺垫先让每个人在自己负责的模块跑一遍 caveman输出一份本地报告花半天时间把里面真正紧急的FIXME处理掉剩下的TODO留着以后排期。这轮操作下来最常见的反馈不是“我们清了多少债”而是“原来我每天路过的那个文件里埋着这么个雷”。工具本身不解决问题但它能帮助人重新看见问题这已经算是性价比很高的投入了。如果你的仓库还从来没有整理过这些便签我建议下周一上班先跑一遍把结果往团队桌上一放看看大家的反应。