接手一個陌生專案,報表設定裡寫著 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。這次閱讀的成果是一份簡短記錄:啟動命令、選中的處理函式、實際輸入檔案和輸出呼叫。
- 在 Yenpo 開啟解壓後的
report-entry-lab資料夾,選擇包含package.json的根目錄。按 Command-P(前往檔案),依次開啟package.json、src/cli.mjs和src/commands.mjs,核對啟動指令碼及report註冊的處理函式。 - 在側欄的全文搜尋中搜尋
Report rows:,關閉正規表示式。對照src/format-report.mjs與測試中的匹配:前者構造輸出,後者斷言預期結果。繼續檢查src/report.mjs的 import 和呼叫,才能把格式化函式連到這條命令上。 - 把
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 的地方追到它被使用的地方,附上行號。」這樣拿到的是一條能逐步核對的路徑,而不是又一份專案概述。
接下來可以按手頭的問題選擇閱讀路線:
- 服務端響應不符合預期:從 HTTP 路由追到資料查詢。
- AI 改完程式碼後測試全綠:檢查改過的斷言是否仍保護原來的行為。
- 一處校驗看起來多餘:用 Git 歷史和回歸測試查清程式碼為什麼存在。
- 準備修改一個函式:檢查哪些消費者損壞、哪些結果不變,以及哪些仍需驗證。
- 未測試程式碼需要重構:先觀察並儲存目前行為,再改變內部結構。