tsma – 遗留代码的回归防线

没有测试的代码,怎么重构?

你接手了一个十万行的遗留代码库。没有测试。想重构,但动了不知道会坏什么。写测试需要理解代码,理解代码需要文档——文档也没有。

没人敢碰。继续腐烂。

全世界的遗留代码都陷在这个僵局里。财富 500 强企业 IT 预算的 60~80% 花在遗留系统维护上。开发者 42% 的时间消耗在技术债务处理上。

如果 LLM 能代写测试呢?


把测试交给 LLM 会出什么问题

让 LLM “为这个函数写测试”,确实会给你一些东西。问题有三个。

第一,不知道从哪里开始。 当项目有 527 个函数时,从第 1 个开始按顺序写?还是先写最重要的?没有标准。

第二,无法验证测试质量。 LLM 写的测试 pass 了。但它真的在验证函数的行为吗?还是只调用了函数、没有 assert 的空壳?只能人工逐一审阅才知道。

第三,没有反馈,LLM 的测试停在 60~70%。 仅凭"测试这个函数"无法达到 100% 分支覆盖率。必须告诉它哪些分支遗漏了,它才能补全。

不是 LLM 不会写测试。问题是缺少一个结构来告诉 LLM 该写什么、写得有多好。


tsma:一条命令驱动的测试轨道

tsma 是一个 CLI 工具,索引项目中的所有函数,检测测试有无,测量覆盖率,并向 LLM 代理提供精确反馈。

代理只需要知道一个命令:

$ tsma next

这一条命令驱动整个循环:

$ tsma next          # 显示下一个没有测试的函数
  → 编写测试
$ tsma next          # 检测新测试,运行,测量覆盖率
  → 100%?PASS,跳到下一个函数
  → <100%?显示未覆盖分支及行号
$ tsma next          # 重新测量修改后的测试
  → 无论是否改善,标记为 DONE 并继续

重复,直到出现 “All functions complete!"。


在 527 个函数上验证

tsma 应用于一个真实的 Go 项目(527 个函数)。

结果数量比例
PASS(100% 分支覆盖率)24646.7%
DONE(best-effort)28153.3%
TODO(未处理)00%

246 个函数达到了 100% 分支覆盖率。其余 281 个虽未达到 100%,但已在可能范围内编写了测试。

为什么有些函数无法达到 100%?


能达到 100% 的函数与不能的函数

一个函数能否达到 100% 分支覆盖率,取决于它如何接收依赖

接口(mockable)– 可以达到 100%:

type Handler struct {
    svc AuthSvc              // interface -- 可用 mock 替换
}

测试中注入 mock,就能控制所有路径:

svc := mocks.NewMockAuthSvc(ctrl)
svc.EXPECT().Login(...).Return(result, nil)   // 成功路径
svc.EXPECT().Login(...).Return(nil, err)      // 失败路径

具体类型(not mockable)– 无法达到 100%:

type Handler struct {
    svc *service.SMSImportService    // struct 指针 -- 无法替换
}

真实实现带着数据库、外部 API 等内部依赖运行。你无法强制触发特定错误或返回特定结果。依赖这些结果的分支,单元测试无法到达。

tsma 的应对: 反馈未覆盖分支后再试一次。如果仍然无法到达,标记为 DONE。这不是工具的局限——它反映的是代码的可测试性。引入接口(DI)就能达到 100%,但那意味着修改原始代码。


反馈让 LLM 测试发生质的飞跃

tsma 的核心价值不是索引,也不是覆盖率测量。而是精确到行号地告诉 LLM 哪些分支未被覆盖

没有反馈时:

"为 ListContracts 函数写测试"
→ LLM 只测试 happy path
→ 覆盖率 60~70%

有反馈时:

"为 ListContracts 函数写测试"
→ 覆盖率 65%(11/17)
→ UNCOVERED:
    line 41 -- if params.Status != nil
    line 44 -- if params.BuildingId != nil
    line 70 -- if err != nil (CountSummary)
→ LLM 精确添加覆盖这些分支的测试
→ 覆盖率 100%

同一个 LLM。唯一的区别是有没有反馈。三行行号,把 60% 和 100% 分开。


代理崩溃,进度不丢

LLM 代理会崩溃。token 上限、网络错误、会话断开。527 个函数不可能在一个会话中全部处理完。

tsma 将进度持久化到 .tsma/session.json

$ tsma status

527 functions
PASS:  246 (46.7%)
DONE:  281 (53.3%)
TODO:    0 (0.0%)

代理在第 200 个函数时崩溃了?新代理运行 tsma next,从第 201 个继续。session.json 就是检查点。

多个代理轮流工作也不会冲突。每个函数是原子性的。


Session 是缓存,源文件才是真相

tsma 的设计原则之一:session 是缓存,源文件是 source of truth。

如果你删除了测试文件,即使 session.json 记录为 PASS,该函数也会回退为 TODO。Session 永远不会与现实脱节。

原则:
  即使 session.json 显示 "PASS"
  如果测试文件不存在 → TODO
  如果源文件发生了变化 → 重新测量目标

给 LLM 代理的指令

代理只需要 6 行指令:

1. 运行 tsma next
2. 如果是 TODO -- 阅读函数并编写测试
3. 如果测试失败 -- 阅读错误并修复测试
4. 如果显示未覆盖分支 -- 添加覆盖这些分支的测试
5. 如果是 PASS/DONE -- 下一个函数会自动显示
6. 重复,直到出现 "All functions complete!"

代理只需要知道 tsma next 一个命令。其余由 CLI 约束。


火车与轨道

Vibe coding 是火车。很快。但没有轨道就会脱轨。

所有 AI 编程工具都在专注于让火车更快。更大的模型、更聪明的代理、更好的提示词。但火车越快,脱轨的代价越大。

tsma 是轨道。LLM 生成测试(Neural),CLI 定义"到此为止”(Symbolic Constraint)。LLM 的创造力完整保留,但结果的质量由机器强制保证。

之前tsma
测试编写人工(慢)或 LLM(混乱)LLM 编写,CLI 验证
从哪里开始?人工判断CLI 决定顺序
质量检查人工审查CLI 测量覆盖率
反馈未覆盖分支行号
进度跟踪session.json 自动

LLM 自由生成。但只在 tsma next 这条轨道上运行。


语言支持

语言索引器测试运行器覆盖率
Gogo/astgo testgo test -coverprofile
TypeScriptregexnpx vitest / npx jestc8 / istanbul
Pythonregexpytestcoverage.py

Go 使用 AST 解析器精确提取函数。TypeScript 和 Python 使用正则表达式。

生成文件(*_gen.go*.pb.go)、测试文件、vendor/node_modules 会自动排除在索引之外。


安装与使用

make install
cd your-legacy-project
tsma next

就这些。

MIT License. github.com/park-jun-woo/tsma