一個輔助函式從返回毫秒改成返回秒,參數沒變,返回型別也仍是數字。重新整理判斷不再按時觸發,排序結果卻保持原樣,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 從最老排到最新,且不改動傳入的陣列。
專案設定好相應語言伺服器後,也可以用引用查詢輔助跳至。引用清單提供的是閱讀線索;別名、函式注入、註冊和執行階段設定仍需繼續檢視,不能把清單當成所有部署場景下消費者的完整清單。
跟著函式進入registry
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傳入的函式繼續追,包括別名和註冊的工廠。對每個候選消費者,指出哪段原始碼依賴具體單位,哪段只依賴排序;告訴我需要檢查哪個執行階段選擇、用什麼輸入驗證後果。把尚未執行的呼叫方與已經執行的結果分開。
回到原始碼核對它提出的路徑,再自己執行命令。