一個輔助函式從返回毫秒改成返回秒,參數沒變,返回型別也仍是數字。重新整理判斷不再按時觸發,排序結果卻保持原樣,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 傳入的函式繼續追,包括別名和註冊的工廠。對每個候選消費者,指出哪段原始碼依賴具體單位,哪段只依賴排序;告訴我需要檢查哪個執行階段選擇、用什麼輸入驗證後果。把尚未執行的呼叫方與已經執行的結果分開。

回到原始碼核對它提出的路徑,再自己執行命令。