一个辅助函数从返回毫秒改成返回秒,参数没变,返回类型也仍是数字。刷新判断不再按时触发,排序结果却保持原样,CSV 里的一列数值则悄悄缩小了一千倍。找到多少处引用,还回答不了这些后果究竟有没有发生。
假设一个小应用要列出会话、判断何时刷新,并导出会话已持续的时间。这是为教学构造的例子;下面的结果来自实际运行 JavaScript,输入固定,前后使用同一组断言。可以下载完整示例自行复现。
先说清函数改了什么
下载包在 elapsed.mjs 中保留同一个辅助函数的前后两版,方便让相同的消费者分别使用它们:
export function elapsedBefore(nowMs, startedMs) {
return nowMs - startedMs;
}
export function elapsedAfter(nowMs, startedMs) {
return (nowMs - startedMs) / 1000;
}
两个输入仍是毫秒时间戳。新版返回秒,并保留小数:2_500 毫秒变成 2.5 秒,没有取整成 2。当 nowMs = 60_000、startedMs = 15_000 时,返回值从 45_000 变成 45。
现在可以问一个具体问题:哪些消费者依赖毫秒这个单位,哪些只依赖换算后仍成立的性质?如果类型检查只看到前后都是数字,它回答不了这个问题。
不清楚输入从哪里来时,可以先沿着一个值读懂陌生代码库。输入单位变化和输出单位变化是两件事,这个例子只改输出。
用搜索和引用列出候选调用方
在编辑器中搜索 elapsed,或者运行:
rg -n 'elapsed' --glob '*.mjs' .
打开 consumers.mjs,先看到两个调用方:
export function needsRefresh(elapsed, nowMs, lastSyncMs) {
return elapsed(nowMs, lastSyncMs) >= 30_000;
}
export function oldestFirst(elapsed, nowMs, sessions) {
return [...sessions]
.sort((a, b) => elapsed(nowMs, b.startedMs) - elapsed(nowMs, a.startedMs))
.map((session) => session.id);
}
needsRefresh 把返回值与固定阈值比较;oldestFirst 则比较同一个函数算出的两个值。先把两者记为待检查的候选,再写清各自要保持什么结果:达到三十秒时需要刷新;会话 ID 从最老排到最新,且不改动传入的数组。
项目配置好相应语言服务器后,也可以用引用查找辅助跳转。引用列表提供的是阅读线索;别名、函数注入、注册和运行时配置仍需继续查看,不能把列表当成所有部署场景下消费者的完整清单。
跟着函数进入注册表
app.mjs 还把这个函数交给了其他地方。它按配置选择一个工厂函数,再用另一个参数名把辅助函数传进去:
const factory = registry.get(config.exporter);
if (!factory) throw new Error(`Unknown exporter: ${config.exporter}`);
const exporter = factory({ measureAge: elapsed });
往前追同一文件里的 register(registry)。在 before 和 after 模式下,它调用的是 registerAgeColumn,实现在 age-column.mjs:
export function registerAgeColumn(registry) {
registry.set('age-column', ({ measureAge }) => ({
kind: 'age-column',
rows(nowMs, sessions) {
return [
'id,age_seconds',
...sessions.map(
(session) =>
`${session.id},${measureAge(nowMs, session.startedMs) / 1000}`,
),
].join('\n');
},
}));
}
CSV 表头写的是秒。旧函数返回毫秒,所以这里除以 1000;新版函数自己已经换算过一次,这里就会再除一次。
只搜 elapsed,看不到这个文件里的 measureAge(...) 调用。发现别名和注册键以后,再扩大搜索:
rg -n 'measureAge|age-column|exporter' --glob '*.mjs' .
测试应用明确传入 { exporter: 'age-column' },并检查 selectedExporter。这样才把“表里注册了谁”和“本次实际选中了谁”连起来。只找到注册代码,还不能确定它有没有参与运行。在服务端项目里,类似的追踪可能从选择请求路径的路由与配置开始。
用相同的预期结果跑前后两版
解压后进入 code-change-impact-analysis 文件夹,不需要安装第三方依赖。本次记录使用 Node.js 22.22.1;需要 Node.js 22 或更新版本,在终端分别执行:
node run.mjs before
node run.mjs after
node run.mjs migrated
after 预期以退出码 1 结束。不要用 && 串联,否则会在预期失败处停住,跑不到迁移后的版本。三种模式加载的都是 tests.mjs 中同一组断言。
| 版本 | 通过 | 失败 | 退出码 |
|---|---|---|---|
before:返回毫秒,消费者保持原样 |
7 | 0 | 0 |
after:返回秒,消费者保持原样 |
4 | 3 | 1,预期失败 |
migrated:返回秒,修改依赖单位的消费者 |
7 | 0 | 0 |
after 失败的三项分别是经过 30_000、31_000 毫秒时的刷新判断,以及 CSV 输出。刚好低于刷新阈值的检查仍通过;两项排序测试也通过,未知导出器名称仍会被拒绝。
想把具体数值与断言对照,可以再运行:
node demo.mjs
这里 nowMs = 60_000,会话 alpha 从 15_000 开始,beta 从 57_500 开始。打印结果中包含:
| 观察项 | before |
after |
migrated |
|---|---|---|---|
| 经过 30 秒是否需要刷新 | true |
false |
true |
| 从老到新的 ID | alpha, beta |
alpha, beta |
alpha, beta |
CSV 中 alpha 的秒数 |
45 |
0.045 |
45 |
CSV 中 beta 的秒数 |
2.5 |
0.0025 |
2.5 |
demo.mjs 负责打印,tests.mjs 负责判断结果是否符合预期。默认的 node --test run.mjs 只跑 before;默认命令全绿,并没有验证这次改动。
区分实际损坏、所测范围内排除和未知
把结果写成下面这样,比列出引用数量更有用:
| 消费者或范围 | 结论 | 依据 |
|---|---|---|
needsRefresh |
已确认受影响 | 返回秒以后,未改动的三十秒阈值测试失败。 |
oldestFirst |
在所测输入内排除回归 | 混合时长、小数秒、相同时长、一毫秒差值、空列表和单元素列表的排序符合预期,输入数组保持不变。 |
本次选中的 age-column 导出器 |
在此配置下已确认受影响 | 测试选中该工厂并比较完整 CSV;新版的两个时长都缩小了一千倍。 |
| 下载包之外的部署配置或消费者 | 未知 | 没有取得或执行它们。 |
排序保持原样也有原因:比较器用两个时长的差决定顺序。对这些输入,两个时长同除一个正数,差值的正负不变。排序需要的是相对大小,刷新阈值和导出器需要的则是具体单位。
这个解释可以帮助选择测试,但不能据此声称所有数字都测过了。这里使用较小的有限整数毫秒值,没有覆盖极端浮点值、非法输入、异步刷新调度或真实的下游 CSV 读取程序。下载包只含一个导出器,也没有提供生产环境的插件清单。
迁移依赖单位的两个消费者
保留 elapsedAfter。在 consumers.mjs 中,迁移后的刷新函数用秒与秒比较:
export function needsRefreshSeconds(elapsed, nowMs, lastSyncMs) {
return elapsed(nowMs, lastSyncMs) >= 30;
}
在 age-column.mjs 中,registerAgeColumnSeconds 直接输出 measureAge(...),去掉第二次除法。app.mjs 在 migrated 模式选择这两个迁移后的实现,oldestFirst 保持不变。
测试仍要求相同的外部结果:到三十秒需要刷新、ID 按原顺序排列、CSV 输出秒。最后七项通过,说明本例所测结果恢复了。如果实际补丁同时修改了预期结果,要把断言的变化和实现一起审,再判断新的绿色结果能说明什么。
让 AI 提议下一处该读的代码
阅读时可以选择向 AI 提一个较小的问题:
沿着作为
elapsed传入的函数继续追,包括别名和注册的工厂。对每个候选消费者,指出哪段源码依赖具体单位,哪段只依赖排序;告诉我需要检查哪个运行时选择、用什么输入验证后果。把尚未执行的调用方与已经运行的结果分开。
回到源码核对它提出的路径,再自己执行命令。