24 天內併入六個組織的七筆 PR:維護者教我的七件事
目錄
- 先看數字
- 一、CERT/CC SSVC #1225:`df == None` 在 DataFrame 上不是你以為的意思
- 二、hotosm drone-tm #882:具名參數綁走了值,`**kwargs` 是空的
- 三、NIST ai-metrology-submissions #10:只跑 Linux 的 CI 看不到 Windows 的 traceback
- 四、FINOS architecture-as-code #3009:`path.relative` 回傳的是主機的分隔符
- 五、UK AISI inspect_k8s_sandbox #236:Helm 模板把清單印成沒有逗號的字串
- 六、casact chainladder-python #1205:我說錯了,維護者當場查證
- 七、casact chainladder-python #1275:一個字元,775 列全錯
- 三個共同病灶
- 給想開始貢獻的人
2026 年 8 月 12 日到 9 月 5 日,24 天內,我開的七筆 PR 被六個組織的維護者合併:美國產險精算學會(casact)、人道 OpenStreetMap(hotosm)、美國國家標準暨技術研究院(NIST)、卡內基美隆大學的 CERT/CC、金融科技開源基金會(FINOS)、英國 AI 安全研究院(UK AISI),其中精算學會收了兩筆。全部由對方的維護者按下合併,沒有一筆是自己合併的。
這篇不是慶功文。我想做的是把七筆攤開來對:bug 到底在哪一行、既有測試為什麼沒抓到、審查過程中維護者糾正了我什麼。看完你會發現,六個互不相干的專案,病灶只有三種。
先看數字
| 組織 | PR | 開 PR | 合併 | 改動 | 合併者 |
|---|---|---|---|---|---|
| casact | chainladder-python #1205 | 08-12 | 08-15 | +100 / -8 | henrydingliu |
| CERT/CC | SSVC #1225 | 08-13 | 08-24 | +78 / -2 | ahouseholder |
| UK AISI | inspect_k8s_sandbox #236 | 08-12 | 08-19 | +27 / -1 | art-dsit |
| FINOS | architecture-as-code #3009 | 08-20 | 08-23 | +90 / -4 | LeighFinegold |
| NIST | ai-metrology-submissions #10 | 08-23 | 08-26 | +19 / -5 | marionlb |
| hotosm | drone-tm #882 | 09-01 | 09-02 | +70 / -1 | spwoodcock |
| casact | chainladder-python #1275 | 09-03 | 09-05 | +27 / -9 | henrydingliu |
七筆加起來 441 行變動,落在正式程式碼的不到 100 行(其中 51 行是 #1205 一筆),其餘全是測試與設定。這個比例本身就是第一件事:維護者合併的不是修正,是「證明修正是對的」那段。
一、CERT/CC SSVC #1225:`df == None` 在 DataFrame 上不是你以為的意思
SSVC 是 CERT/CC 的漏洞分級決策工具。ascii_tree(dt, df=None) 的第二個參數只要傳值就一定拋 ValueError,因為程式用 == 拿 DataFrame 跟 None 比:
if df == None:
df = decision_table_to_longform_df(dt)
在 pandas 裡 == 是逐元素運算,回傳的是同形狀的布林表,if 再對整張表問真假,pandas 從 __bool__ 拋例外,這是它的設計。預設路徑活著只因為 None == None 是一個純量 True。整個 repo 加兩個 README 範例沒有一處傳過 df,所以沒人踩到。
修法一行:is None。但我另外改了一處不在 issue 裡的東西:函式原本用 inplace=True 把 row 欄位從傳進來的表上刪掉。參數不能用的時候這無所謂,表永遠是函式自己造的;參數一能用,呼叫端自己的物件就會被偷偷改。我把「這一項我最想要第二雙眼睛」寫在 PR 裡,附上改動前後呼叫端欄位的實測。維護者 sei-vsarvepalli 核准時只寫了一句「Thanks for this patch」。
教訓:哨兵值的比較要用 is,這不是風格問題,是 pandas 會拋例外的問題。 而修一個從沒被用過的參數,等於把後面所有沒被用過的副作用一起打開,要一起看。
二、hotosm drone-tm #882:具名參數綁走了值,`**kwargs` 是空的
人道 OpenStreetMap 的無人機任務管理系統,災後空拍影像靠它收集。上傳函式簽章裡有 content_type,文件也寫了,但呼叫 minio 的 put_object 時只傳了四個位置參數加 **kwargs。content_type 是簽章裡的具名參數,Python 會把呼叫端的值綁在它身上,kwargs 裡沒有它,所以它永遠到不了 SDK。每一張災區空拍照都被存成 application/octet-stream,瀏覽器不顯示,只會下載。
同一個函式的 metadata 沒事,因為它不在簽章裡,真的走 **kwargs。十二行上面的兄弟函式 add_file_to_bucket 早就正確地寫了 content_type=content_type。
維護者 spwoodcock 問了一個好問題:這個參數不是會被 kwargs 接住嗎?不會。這是 Python 參數綁定的規則,不是這個專案的 bug。我把這件事寫成獨立一篇:Python 的 **kwargs 接不到具名參數。
測試的寫法值得說。我沒有 mock 一個假的 put_object 然後斷言「有被呼叫」,而是用 inspect.signature(Minio.put_object).bind(...) 把呼叫端的參數綁到真正的 SDK 簽章上,斷言的是 SDK 真的會送上線的東西,不是包裝函式自以為送出的東西。
教訓:mock 要綁真簽章。 「函式有被呼叫」和「函式收到了對的參數」是兩件事,前者的測試永遠是綠的。
三、NIST ai-metrology-submissions #10:只跑 Linux 的 CI 看不到 Windows 的 traceback
NIST 這個 repo 收 AI 量測方法的投稿,README 要求投稿者在開 PR 前先在本機跑驗證腳本。腳本 finish() 把報告印到標準輸出,每一份報告都帶 emoji 狀態標記。在編碼不是 UTF-8 的主控台,也就是 Windows 的預設,印那個字元會拋 UnicodeEncodeError,traceback 蓋掉報告。通過的路徑也會炸:一個什麼都做對的投稿者看到的是 traceback 和 exit 1,不是 Passed。
CI 只有 ubuntu-latest,所以上游看不到。我沒有寫「在 Windows 上會壞」就交差,而是在測試套件加了一個案例,用 PYTHONIOENCODING=cp1252 跑驗證器,讓這個失敗在任何平台都能重現。維護者 marionlb 的回覆我留著:
Reproducing a Windows-only failure on any platform with PYTHONIOENCODING is the part I appreciate most. It turned something we had no way to test into something we can now check on every push.
審查中她指出我對子行程編碼的說明有一處說反了。我回:你是對的,第二點我不是不精確,是整個弄反了。改掉,rebase,合併。
教訓:一個只在別的平台出現的 bug,你的工作不是報告它,是把它變成每次 push 都會跑的測試。
四、FINOS architecture-as-code #3009:`path.relative` 回傳的是主機的分隔符
FINOS 是金融科技開源基金會,architecture-as-code 是它旗下的架構文件工具。path.relative 回傳主機的路徑分隔符,所以在 Windows 產生的文件會把路徑寫成反斜線,POSIX 讀者把整串當成一個檔名,什麼都指不到。三個地方會把路徑寫進產出物:front matter、時間軸文件、bundle 清單。
維護者 LeighFinegold 看完說:我們在 #1357 和 #1358 踩過同一個坑,CI 只跑 Linux,這類 bug 從來不會在建置裡出現。然後問我要不要順手修 timeline.ts:80。我掃了一輪,多找到一處:bundle.ts:184 寫進清單的路徑會被 resolveFilePath 讀回來,同一個「這裡寫、那裡讀」的形狀。
兩個測試上的坑:既有的 bundle.spec.ts 有兩個斷言本來就期望正斜線,在 Windows 上一直是壞的;timeline.spec.ts 用 path.join 算期望值,等於把主機分隔符釘進期望值,所以在只有 Linux 的 CI 上永遠是綠的。新測試把 path mock 成 win32,讓 Windows 行為在任何主機上都跑得到。「輸出裡沒有反斜線」這種斷言在 Linux 上不管有沒有修都會過,證明不了任何事。
還有一件事跟程式碼無關。LeighFinegold 提到上次辦公時間貢獻者討論過要減少不必要的 AI 冗詞,請我把 commit 訊息和 PR 描述縮短。我重寫了,兩則 commit 訊息砍短,行內註解各留一行。這件事我另外記成了紀律:對外的文字砍到三分之一,證據留給自己,結論給維護者。
五、UK AISI inspect_k8s_sandbox #236:Helm 模板把清單印成沒有逗號的字串
英國 AI 安全研究院的 inspect 評測框架,k8s sandbox 這個 chart 的 templates/services.yaml 寫著 args: {{ $service.args }}。Go 的預設字串化把清單印成 [setarch -R /bin/echo hello],YAML 的流式序列靠逗號分隔,這串沒有逗號,解析回來是一個元素:一整個用空白接起來的字串。容器執行的是一個 argv token,不是呼叫端寫的四個。
影響面經過 compose/_converter.py,它把 compose 的 command: 對到 Helm 的 args,所以任何用清單型 command: 的 compose 檔都中。entrypoint: 沒事,因為它對到的 command 三行上面早就用了 toYaml。
修法就是補 toYaml。我把整個 chart 掃了一遍:其他裸插值全是純量,這是唯一一個沒經過 toYaml 的清單欄位,一處就是全部。測試斷言的是解析後的清單,不是子字串:"setarch" in str(args) 在修前修後都成立,這種斷言什麼都證明不了。我先對未修改的模板跑一次確認它會失敗,再對修好的跑一次確認它會過,順序不能反。
教訓:斷言要對「解析後的結構」,不要對「字串裡有沒有某個字」。 後者的測試會為了錯的理由通過。
六、casact chainladder-python #1205:我說錯了,維護者當場查證
美國產險精算學會的損失準備金函式庫。ParallelogramOLF 在四種原始期間粒度中有兩種一用就崩:季度和半年。兩個獨立的成因,一個是把已經字串化的標籤拿回來解析閏年旗標,格式只認年和月,2016Q1 解析不了;另一個是 pandas 沒有 to_period("S"),S 會被讀成秒。
這筆值得寫的不是修法,是我在 PR 描述裡對 to_period("2Q") 的解釋是錯的。我寫「pandas 會丟掉倍數」,維護者 henrydingliu 自己跑了一遍,指出倍數其實有被尊重,一個 2Q 期間確實是六個月長;真正的問題是每個期間錨定在觀測值所在的季度,不是固定的半年邊界,所以連續的視窗會重疊,兩年給出八個重疊視窗而不是四個半年。
我回覆的第一句是:你是對的,我的解釋是錯的,謝謝你去查證而不是相信我的說法。然後把 PR 描述改成正確的版本,數值用他自己在 #524 提出的倒數均值檢查驗證。另一位維護者 kennethshsu 正式核准,提出糾正的 henrydingliu 親自合併。
教訓:被糾正的時候,最有價值的回覆是承認得具體。 不是「感謝指正」,是把哪一句錯、正確的是什麼,寫回 PR 裡讓下一個讀的人不會再被我誤導。
七、casact chainladder-python #1275:一個字元,775 列全錯
三個星期後回到同一個 repo。CapeCod.predict() 決定要不要把預測資料聚合回模型擬合時的粒度,判斷方式是數 sample_weight 比 apriori_ 多出幾層索引。條件寫的是「多於一層」,所以最常見的「剛好多一層」落到沒有聚合的分支,用預測資料重算了 apriori,而不是用擬合好的那個。
以 clrd 樣本的 comauto 業務線為例,擬合出的 apriori 是 0.5689995797,修正前 predict() 回傳 1.2516635774,775 列全部跟自己業務線擬合值不同。修法是把 > 1 改成 > 0,一個字元。
既有的 test_capecod_predict2 明明覆蓋這條路徑,為什麼沒抓到?因為它用 prism 資料集,那個三角形比擬合模型多五層索引,永遠走在聚合的分支上。測試覆蓋了分支,沒有覆蓋邊界。 新測試用 clrd,剛好多一層。這一筆的完整拆解在另一篇。
這筆 PR 依 casact 的 AI 使用政策做了揭露:Claude Code 重現了 bug、跑了前後比較、起草了回歸測試;那一個字元的修法是我和維護者在 issue 裡先談定的,在任何程式碼寫出來之前。我自己審過 diff 和測試,套件在我的機器上跑。這段揭露寫在 PR 裡,維護者合併時沒有任何意見。
三個共同病灶
把七筆疊起來看:
- 只跑 Linux 的 CI:NIST、FINOS 兩筆都是 Windows 上必炸、上游永遠看不到。解法不是要求對方加 Windows CI,是把平台差異變成參數(
PYTHONIOENCODING、mockpath成win32),讓它在 Linux 上也能重現。 - 為了錯的理由通過的測試:casact #1275 的測試走錯分支、inspect_k8s 若用子字串斷言會永遠綠、FINOS 用
path.join算期望值等於把 bug 釘進期望。每一筆我都先對未修改的程式碼跑新測試,確認它會紅,再對修好的跑,確認它會綠。 - 被語言語意騙過的哨兵與綁定:CERT/CC 的
== None、hotosm 的具名參數不進kwargs、Helm 的清單字串化。這三個都不是專案的錯,是語言或工具的規則跟直覺不一致,而直覺會贏。
給想開始貢獻的人
七筆裡面沒有一筆是「新功能」。全部是:讀 issue,重現,找到那一行,證明修正是對的,把不確定的地方寫在 PR 裡請人看。維護者合併的速度跟 PR 大小成反比,跟「我能不能一眼看出你證明了什麼」成正比。
如果你想從哪裡開始:挑一個你真的在用的函式庫,找一個有重現步驟的 issue,先讓它在你機器上壞掉。壞不掉就不要開 PR。
每一筆 PR 的完整紀錄都在 Ultra Lab 首頁的開源紀錄,點進去是 GitHub 原頁。