安全重构
本页提供重构前安全检查清单、四种原子重构操作的安全评级与提交粒度、行为保持验证的代码示例(snapshot vs golden file),以及 5 个不安全重构的 before/after 反例。
SKILL 禁止「一次 diff 重写模块」:要求列出重构前的不变量(公开 API、持久化格式、消息契约),并在每步后运行相关测试与静态检查。
对缺乏测试的遗留代码,先补最窄的特征测试或合约测试,再动结构;Agent 应标出哪些行为是推断的、需人工确认。
原则与边界
工具链建议:IDE 自动重构优先于手写替换;跨文件改名用语言服务器或 codemod,减少漏改。合并策略上偏好多个小 PR 而非巨型分支。
- 重构 PR 不夹带行为变更与功能需求,除非显式拆分。
- 性能敏感路径重构需前后基准或 profiling 对比。
- 数据库与 API 变更走扩张—收缩,不单靠「一起发版」。
重构前安全检查清单
重构前必须检查(每项不通过则不允许开始重构):
覆盖率门禁:
[ ] 当前模块单测覆盖率 ≥ 70%(运行:npm test -- --coverage --collectCoverageFrom='src/module/**')
[ ] 若覆盖率不足,先补特征测试(黑盒 I/O 测试),再重构
回滚能力:
[ ] 所有改动在独立分支上(git branch refactor/xxx)
[ ] 每步原子提交,可 git revert 到任意中间状态
[ ] 若涉及数据库迁移,确认 migration 可 down(执行:make db-rollback 验证)
依赖范围:
[ ] 确认有多少文件依赖此模块(运行:grep -r "from.*module-name" src/ | wc -l)
[ ] 对外公开 API 的变更列表已经确认(function signatures、error codes)
[ ] 消费方服务是否需要同步升级(查 CODEOWNERS 通知相关团队)
四种原子重构操作及安全评级:
操作 | 安全级别 | 每步提交粒度
-------------|----------|--------------------------------------------
rename | ★★★★★ | 一次只改一个符号,单独 commit
| | git commit -m "refactor: rename getUserData to fetchUserProfile"
extract | ★★★★☆ | 先提取函数(保留原调用),测试绿后删原代码
| | git commit -m "refactor: extract validateCoupon from checkout handler"
move | ★★★☆☆ | 先加 re-export 保持路径稳定,下一步再删 re-export
| | commit 1: "refactor: add re-export for backward compat"
| | commit 2: "refactor: update all imports to new path"
| | commit 3: "refactor: remove re-export after import migration"
inline | ★★☆☆☆ | 内联前确认函数无副作用、无被多处调用
| | git commit -m "refactor: inline single-use helper formatDate"
行为保持验证:snapshot 测试 vs golden file 对比
// 方案 1:Jest/Vitest Snapshot 测试(适合 UI 组件和序列化输出)
it('checkout summary renders correctly', () => {
const { container } = render(<OrderSummary order={mockOrder} />);
expect(container).toMatchSnapshot(); // 重构前运行生成快照
});
// 重构完成后,若快照变了,运行 jest --updateSnapshot 并仔细 review diff
// 方案 2:Golden File 对比(适合 API 响应、数据转换、报表生成)
// golden-files/order-summary.json (重构前生成并提交到 git)
{
"orderId": "ORD-001",
"total": 99.50,
"items": [{"name": "商品A", "qty": 2, "price": 49.75}]
}
// 测试代码
import expectedOutput from './golden-files/order-summary.json';
it('transformOrder output matches golden file', () => {
const result = transformOrder(rawApiResponse);
expect(result).toEqual(expectedOutput); // 重构前后输出必须完全一致
});
// 什么时候用哪个:
// snapshot → 组件树、HTML 输出、对象序列化(快速但 diff 不易阅读)
// golden file → 数据转换、报表、API 响应(diff 更清晰,适合代码评审)
5 个不安全重构反例(Before / After)
❌ 反例 1:重命名时漏改反射/字符串引用
Before: function getUserData(id) { ... }
After: function fetchUserProfile(id) { ... }
问题:routes.ts 里有 app.get('/user', handlers['getUserData']) ← 字符串引用漏改
修复:用 IDE 全局重构,而不是手动 find & replace
❌ 反例 2:提取函数时改变了默认参数行为
Before: function formatDate(date, format = 'YYYY-MM-DD') { ... }
After: function formatDate(date, format = 'MM/DD/YYYY') { ... } ← 默认值被悄悄改了
修复:提取函数时不改任何默认值,用特征测试锁定默认行为
❌ 反例 3:搬移文件时更新了部分 import 但未全部更新
Before: import { helper } from '../utils/helper'
After: import { helper } from '../shared/helper' ← 只改了部分文件
问题:老 ../utils/helper 被删了,其他引用的文件编译失败
修复:先加 re-export(utils/helper.ts → export * from '../shared/helper'),
再批量更新 import,最后删 re-export
❌ 反例 4:inline 函数时引入了重复副作用
Before:
function logAndReturn(val) { metrics.count++; return val; }
const a = logAndReturn(x);
const b = logAndReturn(y);
After(inline):
const a = (metrics.count++, x); // ← metrics.count 计数改变了
const b = (metrics.count++, y);
修复:有副作用的函数不要 inline
❌ 反例 5:重构顺序倒置(先删旧代码再补测试)
Before: 直接删除老实现,新实现没有测试
问题:发现新实现有 bug 时,没有测试作为 safety net,回滚成本极高
修复:顺序必须是 补测试 → 实现新逻辑 → 验证测试绿 → 删旧代码
不变量与契约
不变量是「改完结构之后仍然必须为真」的陈述。动笔前先写成清单,便于审查与回滚时对照;对 Agent 应要求逐条引用代码或测试位置,而不是泛泛承诺「行为不变」。
- 公开 API:函数签名、错误码、幂等语义;若需破坏兼容,单独 PR 与发布说明。
- 持久化:表字段含义、序列化格式、迁移可逆性;版本号或 feature flag 的读写双方。
- 消息与集成:队列主题、事件 schema、gRPC/REST 契约;消费者是否可灰度。
- 可观测性:关键日志字段、指标名、告警查询;重构后查询语句仍应有效。
对无法验证的不变量(例如「依赖未文档化的第三方行为」),在 PR 描述里标为假设,并附验证计划或人工签收。
先测再搬
「搬移」指移动文件、改模块边界、调整依赖方向等易漏改的操作。顺序应是:行为已有测试锚点 → 再动目录与 import → 最后清理别名与兼容层。
- 理想情况:单元或集成测试已覆盖当前行为;若没有,先加最窄的特征测试(黑盒输入输出)或合约测试。
- 搬移时优先保持符号路径稳定:必要时先加 re-export 或适配层,下一步再删,避免一步改十个引用点。
- 每完成一次「只搬不移除逻辑」的提交,应绿构建;不要攒到「全部搬完再测」。
扩张—收缩(API 与数据)
对对外契约或共享存储的变更,用两(多)阶段发布降低耦合:扩张阶段新旧并存、向后兼容;收缩阶段在确认无调用方后删除旧路径。
- 扩张:新增字段/端点/表列,默认值与老客户端可读;老代码路径仍工作。
- 迁移:双写或回填脚本;监控错误率与数据一致性。
- 切换:读路径切到新实现(可配开关),观察一段时间。
- 收缩:下线旧 API、删列、移除死代码;此时才允许「破坏性」提交。
Agent 应显式写出当前处于哪一阶段;禁止在单 commit 里同时「加新契约又删旧契约」除非团队约定可原子发布。
小步提交流水线
每一步产出应是可审查、可 revert 的单元;理想情况下每一步都对应一条清晰的提交说明(见下文「提交信息一行」工具)。
[ 列出不变量 + 风险面 ]
│
▼
[ 补测试 / 特征测试 锁定行为 ]
│
▼
[ 小步改动:IDE 重构 / 单意图编辑 ]
│
▼
┌──────────┴──────────┐
▼ ▼
[ 本地:相关测试 + lint ] [ 可选:类型检查 / 格式化 ]
│ │
└──────────┬──────────┘
▼
[ 提交:单意图、可 revert ]
│
▼
[ 推送 → CI 全绿再下一步 ]
CI 门禁步骤
重构分支上的 CI 应与功能开发一致或更严:任何一步失败都阻止合并,避免「只在本地绿」。可按仓库体量裁剪,但顺序逻辑建议如下。
- 检出与依赖:锁定文件校验、安装可复现;避免「我机器能上」。
- 静态分析:lint、format 检查(或 format 机器人提交)。
- 类型检查:若有 TypeScript / mypy / 等,与构建同优先级。
- 单元与集成测试:与改动模块相关的套件;大仓可用 affected 策略。
- 构建产物:应用或库能完整构建;多包仓库验证依赖图未被破坏。
- (可选)契约 / E2E:动到 API 或关键路径时拉齐;数据库变更跑迁移干跑。
Agent 在描述「本步完成」时应引用具体命令或 CI job 名称,而不是仅称「测试过了」。
反模式
提交信息一行(前缀 + 摘要)
用前缀区分意图,便于日志筛选与 revert:refactor: 纯结构、test: 仅测试、chore: 工具与配置。下面选择前缀并填写英文或中文摘要,生成一整行;仅保存在本页浏览器(localStorage),键名与其他页面不共用。
---
name: refactoring-safety
description: 安全检查清单、原子重构操作、行为保持验证与不安全反例
---
# 步骤 1:重构前安全检查
执行: npm test -- --coverage --collectCoverageFrom='src/<module>/**'
门禁: 覆盖率 ≥ 70%,否则先补特征测试
确认: git branch refactor/<desc> 已创建,可随时 revert
# 步骤 2:列出不变量
公开 API: 函数签名、错误码、幂等语义
持久化: 表字段含义、序列化格式
消息契约: 队列主题、事件 schema
观测性: 日志字段名、指标名
# 步骤 3:选择原子重构操作(按安全评级)
rename → 最安全,一次一个符号,IDE 自动重构
extract → 先提取不删原代码,测试绿后再清理
move → 3 步:加 re-export → 更新 imports → 删 re-export
inline → 确认无副作用、无多处调用后执行
# 步骤 4:补行为保持测试
选择方式:
a. Jest snapshot: expect(component).toMatchSnapshot()
b. Golden file: 保存期望输出到 golden-files/*.json,测试对比
原则: 测试覆盖所有公开入口的 happy path + 关键错误路径
# 步骤 5:原子提交(每步一个 commit)
格式: refactor(<scope>): <具体操作>
示例:
refactor(auth): rename getUserData to fetchUserProfile
refactor(checkout): extract validateCoupon helper
refactor(utils): move formatDate to shared/formatting
每个 commit 必须独立通过 CI(npm test && tsc --noEmit)
# 步骤 6:推送前验证
运行完整测试套件: npm test
类型检查: npx tsc --noEmit
比对不变量清单:逐条确认仍满足
查 PR diff:确认没有意外的行为变更(如默认参数改变)