接手一個陌生專案,報表設定裡寫著 limit: 6,跑出來卻只有四條。專案裡有命令列入口、設定載入器、幾個服務和一堆測試。全部讀一遍當然能找到原因,但沒必要:盯住決定輸出條數的那個值,順著它往下找就行。

先把問題問具體

「搞懂報表模組」沒有終點。換成一個能回答的問題:

設定檔案寫的是 limit: 6,環境變數是 REPORT_LIMIT=4,結果輸出四條。到底是誰生效了?如果 REPORT_LIMIT 是空字串,又會怎樣?

順手記下執行的命令、所在目錄和程式碼版本。還要確認源資料多於四條:如果資料本來就只有四條,輸出四條什麼也說明不了。

手裡只有一條線索,先找到起始檔案

最先找到的檔案可能是啟動指令碼、輸出排版函式,也可能是一份測試,還不是選擇條數的函式。先用能重現的線索走到那裡,並記清每一步是怎麼確認的。

下載報表入口練習,解壓後進入 report-entry-lab。這個小專案是為教程從零構造的,給下文相同的優先順序規則補上了真實命令、處理函式註冊和本機檔案載入。需要 Node.js 22 或更新版本、npm,以及 ripgrep(rg);不用安裝依賴或連線模型。下面的 Shell 命令適用於 macOS 和 Linux。

REPORT_LIMIT=4 npm run --silent report
Report rows: 4
Aster
Birch
Clover
Dahlia

設定樣例寫著 limit: 6,資料樣例有八條。第一行是標題,後面才是四條資料。因此條數差異確實需要解釋,不是因為輸入太少。程式唯讀取包內的本機樣例並輸出到終端;測試會啟動本機 Node 子程序,不請求網路,也不寫檔案。

線索 查詢起點 還需核對
命令 package 指令碼或實際可執行檔案 哪個參數選中了哪個處理函式
輸出 拼出這段文字的程式碼 命中的是產生位置、樣例還是斷言;誰呼叫了產生函式
測試 測試的 import 和實際呼叫 它啟動了真實命令,還是自己傳入替代依賴
設定 讀取並轉換這個值的地方 哪個呼叫方會走到這裡,設定由哪個載入器提供

在 Yenpo 中閱讀同一個練習

帶著「哪一個入口輸出了這四行?」開啟剛下載的 report-entry-lab。這次閱讀的成果是一份簡短記錄:啟動命令、選中的處理函式、實際輸入檔案和輸出呼叫。

  1. 在 Yenpo 開啟解壓後的 report-entry-lab 資料夾,選擇包含 package.json 的根目錄。按 Command-P(前往檔案),依次開啟 package.json、src/cli.mjs 和 src/commands.mjs,核對啟動指令碼及 report 註冊的處理函式。
  2. 在側欄的全文搜尋中搜尋 Report rows:,關閉正規表示式。對照 src/format-report.mjs 與測試中的匹配:前者構造輸出,後者斷言預期結果。繼續檢查 src/report.mjs 的 import 和呼叫,才能把格式化函式連到這條命令上。
  3. 把 src/report.mjs、src/limits.mjs 和相關測試留在標籤頁中,記錄每一步的檔案與行號,再用下文的 Terminal 檢查區分「直接呼叫函式的測試」與「經過真實 CLI 的執行」。

檔案瀏覽和文字搜尋不需要連線模型或安裝 JavaScript 語言伺服器。想跟做,可以獲取 Mac 版 Yenpo並開啟同一個解壓目錄;閱讀說明說明了兩種搜尋工具的區別。ZIP 包含原始碼檔案,不含 Git 歷史,因此這裡閱讀的是這些檔案,不是固定提交比較。

已知終端命令

先讀啟動指令碼,不要只憑目錄名猜入口:

npm pkg get scripts.report
rg -n 'process.argv|commands\[command\]' src/cli.mjs
rg -n 'report:|runReport|report-config.json' src/commands.mjs

第一條輸出 "node src/cli.mjs report"。在 cli.mjs 中,process.argv.slice(2) 讓 report 成為命令名,剩下的參數交給選中的處理函式。commands[command] 是一次查表,找到啟動檔案還不夠:繼續開啟 commands.mjs,確認 report 屬性呼叫 runReport,並給它提供讀取 report-config.json 和 rows.json 的載入器。旁邊的 help 屬性只返回說明文字。

接著透過同一條啟動路徑,只改變一個輸入:

REPORT_LIMIT=4 npm run --silent report -- --limit 2

輸出變成 Report rows: 2,後面是 Aster 和 Birch。npm 把雙短橫線分隔符後面的參數交給指令碼。先讀查表和註冊程式碼,確認選中了誰;再用變化後的輸出檢查這條命令確實走到了條數處理。僅憑輸出,還不能確認中間每一層呼叫。

只有一段輸出文字

rg -n -F 'Report rows:' src tests
rg -n 'renderReport|runReport' src

第一條會找到 format-report.mjs,也會找到測試裡的斷言。前者拼標題,後者比較預期文字,兩種命中都不能直接當成命令入口。在 report.mjs 中同時檢查 renderReport 的 import 和實際呼叫,確認傳入的是篩選後的名稱。再沿 runReport 回到 commands.mjs 的 report 註冊處,最後核對 cli.mjs 選擇處理函式和寫入 stdout 的位置。

這樣就有了一條可以用前面命令驗證的路徑。真實專案的輸出如果由多段文字拼成,可以先搜穩定的片段,再讀周圍表示式怎樣組合它。共用排版函式若有多個呼叫方,先把它們當作候選;等啟動參數或針對性測試選中一個,再繼續追,不要把所有呼叫方混成一條執行路徑。

已知一條測試名稱

rg -n -F 'environment wins over config' tests
node --test --test-name-pattern='environment wins over config' tests/report.test.mjs

這條測試直接匯入 runReport,自己傳入環境變數物件、設定載入器和資料載入器。它能確認函式內「四覆蓋六」,但沒有啟動 cli.mjs,也沒有讀取設定檔案,所以不能說明真實命令到底載入哪份檔案。

沿 import 找到行為後,還要讀實際的 commands.report 繫結。另一條測試會走過缺少的這一步:

node --test --test-name-pattern='CLI uses REPORT_LIMIT' tests/cli.test.mjs

它啟動真實 CLI 子程序,傳入 REPORT_LIMIT=4,核對退出狀態和 stdout。兩條選定的測試都能透過。筆記裡應區分:前一條自己選依賴,後一條走真實啟動和檔案載入。直接呼叫函式的測試透過,不能代替後一種檢查。

只知道設定名

rg -n -F 'REPORT_LIMIT' src tests
rg -n 'resolveLimit|loadConfig|report-config.json' src

原始碼中讀取變數的地方在 limits.mjs,其餘命中是在準備測試輸入。沿 report.mjs 中的 resolveLimit 呼叫往回讀,再確認 commands.report 傳入的 loadConfig。其中的 new URL(..., import.meta.url) 從模組位置定位包內樣例;僅僅搜到檔名,還不能說明這次處理函式會用它。

到這裡,已經找到了繼續追設定值的起點:命令、選中的處理函式、實際輸入檔案和輸出位置都已核對,可以暫時放下 help 和無關測試。如果真實命令使用另一份檢出、包裝指令碼或環境來源,先解釋這個差異;否則下一步就查值的優先順序,不用把範圍擴大到整個儲存庫。

進入選中的處理函式

練習裡的 report.mjs 依次解析參數、載入設定、選擇條數、讀取資料,再呼叫 renderReport 加上標題,由 CLI 列印返回的字串。下面保留的早期單檔案例子只返回資料行,方便先看值的流向。兩個版本都把載入器作為參數傳入,測試就能觀察執行順序,不必真的讀檔案:

export async function runReport({ args, env, loadConfig, loadRows }) {
  const { values } = parseArgs({
    args,
    options: { limit: { type: 'string' } },
    strict: true,
  });
  const config = await loadConfig();
  const limit = resolveLimit(values, env, config);
  const rows = await loadRows();
  return rows
    .slice(0, limit)
    .map((row) => row.name)
    .join('\n');
}

還沒點開任何輔助函式,這段程式碼已經交代了執行順序:解析參數、載入設定、選出條數、讀取資料、輸出。還有一個容易漏掉的細節:只有 loadConfig() 成功了,resolveLimit() 才會執行。後面會用到這一點。

在真實專案裡,要順著 import 找到實作。叫 loadConfig 的函式,可能唯讀一個 JSON,可能合併好幾個檔案,也可能去請求遠端服務,光看名字判斷不了。

條數是怎麼選出來的

export function resolveLimit(cli, env, config) {
  const raw = cli.limit ?? env.REPORT_LIMIT ?? config.limit ?? 10;
  const limit = Number(raw);
  if (!Number.isSafeInteger(limit) || limit < 1 || limit > 100) {
    throw new Error('limit must be an integer from 1 to 100');
  }
  return limit;
}

這裡做了三件事:選來源、轉數字、查範圍。筆記裡最好分開記。只寫一句「環境變數覆蓋設定檔案」,很容易腦補出一條程式碼裡沒有的規則:環境變數不合法時會退回設定檔案。實際上不會。

命令列 --limit REPORT_LIMIT 檔案中的 limit 實際結果
2 4 6 使用命令列的 2
未提供 4 6 使用環境變數的 4
未提供 未提供 6 使用檔案的 6
未提供 未提供 未提供 使用預設值 10
未提供 空字串 6 校驗失敗
0 4 6 校驗失敗

?? 只跳過 null 和 undefined。空字串也算有值,所以 REPORT_LIMIT='' 照樣會被選中。Number('') 得到 0,範圍檢查直接拋錯,函式不會再去看設定檔案。

把 ?? 換成 ||,空字串就會繼續往下找,但 0 也會被當成「沒設定」。這等於改了規則,不是順手的小重構。動手之前先想清楚:有人明確傳了一個非法值,應該錯誤,還是悄悄回退?

追到值被用掉的地方

條數選對了,輸出不一定就對。順著 limit 找到 rows.slice(0, limit),再看 rows 從哪來。

示例裡的 loadRows() 會把所有行都讀出來,處理函式再取前幾條。資料來源那一側沒有排序、過濾,也沒有分頁。所以這個限制只管列印多少條,管不了讀了多少資料、佔了多少記憶體。

如果拿到的問題是「明明加了 --limit 2,報表還是把記憶體撐爆了」,那優先順序規則就不是該查的地方,應該進 loadRows 看看限制要不要下推到資料來源。同一個處理函式,問題變了,下一步要看的檔案也跟著變。

故意弄壞一樣東西

現在把設定檔案弄壞,同時傳 --limit 2。命令列優先順序最高,程式應該還能跑吧?

跑不了。設定在選條數之前載入,loadConfig() 一拋錯,後面都不會執行。優先順序只決定用哪個值,管不了選值之前有哪些步驟必須先成功。

反過來也值得測一下:設定載入成功,但選出來的條數不合法,這時資料根本不該被讀取。數一下讀取次數就能確認:

let reads = 0;
await assert.rejects(
  runReport({
    args: [],
    env: { REPORT_LIMIT: '' },
    loadConfig: async () => ({ limit: 6 }),
    loadRows: async () => {
      reads++;
      return [];
    },
  }),
  /integer/,
);
assert.equal(reads, 0);

如果只斷言「最後拋錯了」,一個先把資料全讀完、再拒絕參數的實作也能透過。reads === 0 才真正卡住了執行順序。

跑一下示例

下載 configuration-flow.mjs,用 Node.js 22 或更新版本執行,不需要裝任何依賴:

node --test configuration-flow.mjs

原來的單檔案下載繼續保留,其中六個測試覆蓋了優先順序順序、空值和非法值、最終輸出、非法輸入時不讀資料,以及設定損壞的情況。這些測試只針對示例裡的函式;放到自己的專案裡,還得看真實入口怎麼讀環境變數、怎麼顯示錯誤。

新的入口練習還覆蓋了真實命令的連線。在它的目錄裡執行:

node --test tests/*.test.mjs
REPORT_LIMIT='' npm run --silent report

九個測試全部透過。第二條命令會故意以狀態碼 1 退出,在 stderr 輸出 limit must be an integer from 1 to 100,stdout 為空。函式測試還統計了資料讀取次數,用空值案例核對上文的執行順序。ZIP 附有 SHA-256 清單,壓縮包及解壓後的測試都已核驗。

留一份筆記

幾行就夠。下次再碰到這個問題,讀它比重讀程式碼快得多:

runReport
  parseArgs → loadConfig → resolveLimit → loadRows → slice → output

limit:命令行 > 环境变量 > 配置文件 > 10
选中的值非法:报错,不回退,不读取数据
配置加载失败:即使有 --limit 也会停止
限制范围:输出条数,不是数据加载器的工作量

真實專案裡,在每一步旁邊補上檔案和行號,再把還沒確認的事寫下來:loadConfig 讀的是哪個檔案、父程序有沒有設定 REPORT_LIMIT、命令列怎麼展示錯誤。能解釋清楚為什麼是四條,也知道哪些還沒查,第一輪就可以收工了,不必先把整個專案讀懂。

把閱讀結果交給下一位維護者核驗

下一位維護者如果不用翻你的聊天記錄,就能重現筆記中的事實,這份記錄才更便於交接。可以試用海報投稿盒交接練習 ZIP,先讀簡短交接簡報。程式碼、功能承諾和交接情境均為獨立構造的離線教學材料,不是真實 AI 產生專案或真實交接事故。

接收者按同一份固定原始碼核對四項承諾:新程序能讀回本機卡片;相同內容按順序重試不重複;回執只是 fixture 預覽;部署替換後能否保留資料仍未知。implemented 只覆蓋所測條件,順序重試不代表併發安全;mock 沒有傳送,unknown 則缺少實際部署儲存證據。

按簡報執行,把 RECEIVER.md 複製到解壓包外填寫,記錄自己的命令、結果、原始碼依據和待查項。保留包內原模板,填完後再次校驗原包。

ZIP SHA-256
c39d19b210c05f4667cd74db4934459a079408d14c5e7678c24cd087e1df5185

在 Yenpo 裡,可以用前往檔案和工作區搜尋把處理函式、選值函式和測試並排開啟。問 AI Reader 時,也只問一個值:「從讀取 REPORT_LIMIT 的地方追到它被使用的地方,附上行號。」這樣拿到的是一條能逐步核對的路徑,而不是又一份專案概述。

接下來可以按手頭的問題選擇閱讀路線: