AI 開發工作流

兩版 DLL 差了 76 個檔案,真正的功能變更只有 1 處

2026-07-13 #.NET#Claude Code#Agent Skills#ILSpy#反編譯

手上有兩個版本的 DLL——舊版 7/03、新版 7/08——沒有可信的原始碼 diff,要回答一個問題:新版到底改了什麼,有沒有夾帶不該進來的東西。

反編譯兩邊再 diff -r,跑出來 76 個 .cs 檔案有差異。看起來像大改版。實際逐檔判讀之後,真正會改變執行結果的只有 1 處——一個布林參數從寫死的 false 改成傳入實際值。其餘 75 檔的差異全部來自「舊版是 Debug 建置、新版是 Release 建置」,以及反編譯器把同一份 IL 還原成不同寫法。

這篇記錄怎麼把 76 收斂到 1,以及這套方法的極限在哪。文中的組件名、類別名、方法名都已代換過,數字與流程是真的。

一、為什麼不能直接看 diff

反編譯比對的難點從來不是「跑出 diff」,而是這個 diff 有大半是假的。

原因在於反編譯是有損還原。C# 編譯成 IL 時,區域變數名只留在 PDB、foreach 和 for 可能編成同一組指令、using 陳述式會展開成 try/finally。ILSpy 反過來把 IL 還原成 C# 時,只能靠啟發式猜一個寫法出來——同一份 IL,換個上下文就可能還原成不同但等價的程式碼。

所以直接 diff -r 得到的 76 檔,混了至少五種東西:

類別 是什麼 算不算問題
✅ 完全等價 變數改名、中間變數內聯、初始化器寫法互換 否
⚠️ 反編譯器產物 foreach↔for、LINQ 兩種語法互換、async 狀態機展開 否
🔶 IL 細部差 //IL_xxxx 位移漂移、cast 路徑微調 否
🟢 有意變更 真的改了行為的程式碼 是
🔴 可疑差異 條件反轉、SQL 字串變了、常數變了 是

前三類必須全部濾掉。濾不掉的話,使用者拿到一份 76 檔的報告,讀不完,也分不出哪一行才是重點——那份報告等於沒產。

二、分層比對:先問便宜的問題

不要一開始就跳進方法內部。三層由粗到細,前兩層便宜、結論強,往往在第一層就能回答掉最重要的問題。

flowchart TD
    A[舊版 DLL] --> D["ilspycmd -lv CSharp5 -p"]
    B[新版 DLL] --> D
    D --> T["① 類型清單
class / interface / struct / enum"] T --> P["② 公開成員簽章"] P --> F["③ 逐檔 unified diff"] F --> C{逐 hunk 分類} C -->|"✅ ⚠️ 🔶"| N["雜訊:彙總成一張表"] C -->|"🟢 🔴"| R["逐項展開寫進報告"]

第一層:類型清單。

1
for t in c i s d e; do ilspycmd "$SRC/A_OrderSvc.dll" -l "$t"; done | sort -u > types_A.txt

結果 543 → 539,少了 4 個。逐個看:全部是編譯器產生的類型(<>c__DisplayClass* 這類 closure 類別數量不同、<PrivateImplementationDetails> 消失)。沒有任何一個是原始碼裡寫得出來的型別。

第二層:公開成員簽章。

1
2
3
grep -rh "^[[:space:]]*public " "$OUT_A" --include="*.cs" | sort -u > pub_A.txt
grep -rh "^[[:space:]]*public " "$OUT_B" --include="*.cs" | sort -u > pub_B.txt
diff -u pub_A.txt pub_B.txt

3,922 vs 3,922,diff 是空的。

這一步就把最貴的問題回答掉了:公開 API 一個字都沒動,所以呼叫端不需要跟著改、不需要重編其他專案。剩下的差異全在方法內部,影響範圍被鎖死在這顆 DLL 裡。

第三層才是逐檔 unified diff。這時候你已經知道自己在找什麼了——不是「有沒有變」,而是「內部行為變了什麼」。

三、三個線索合起來才指向 Debug → Release

逐檔看之前,先解釋一件事:為什麼會有 76 檔這麼多。

單獨看每個線索都不夠:

線索 單獨看的解讀
DLL 小了約 95 KB(1,542,656 → 1,442,816 bytes) 可能是刪了程式碼
Debug.Assert 呼叫消失 可能是有人手動拿掉了
兩個 async 狀態機從 class 變成 struct 看起來像結構改寫

三個合起來就只有一個解釋:舊版是 Debug 建置、新版是 Release 建置。

Debug.Assert 帶 [Conditional("DEBUG")],Release 下呼叫端整個被移除;async 狀態機在 Debug 下編成 class(方便偵錯器附加),Release 下編成 struct 以省一次配置;死碼消除、暫存變數合併則讓還原出來的程式碼到處都不一樣。體積差正好對得上。

這個判斷一旦成立,76 檔的雜訊就有了合理解釋,可以放心把注意力集中在「即使扣掉建置組態,也解釋不了」的那些差異上。

反過來說:如果你比對的兩顆 DLL 建置組態相同,卻出現這種規模的差異,那才要緊張。

四、唯一的真變更

76 檔逐 hunk 看完,只有一個 hunk 進了 🟢。

1
2
3
4
5
6
7
// 舊版
if (order.IsSpecialCode && !baseMethod.CheckCodeValid(
order.special_code, order.ship_date.Value.ToString("yyyy-MM-dd"), false))

// 新版
if (order.IsSpecialCode && !baseMethod.CheckCodeValid(
order.special_code, order.ship_date.Value.ToString("yyyy-MM-dd"), order.IsSpecialCode))

差一個參數。要判斷它的意義,得去看 CheckCodeValid 本身——它的第三參數 isSpecial 在方法內是這樣用的:只有 isSpecial && flag 成立才會真正去查資料庫,否則直接回 false。

也就是說 isSpecial=false 時這個方法恆回傳 false。搭配呼叫端的 !:

  • 舊版:第三參數寫死 false → 檢核恆失敗 → 只要資料列有指定這個代碼,一律被判成錯誤列
  • 新版:改傳 order.IsSpecialCode(在這個分支裡必為 true)→ 真的去查代碼在出貨日是否有效 → 有效的不再被誤判

這是 bug 修正,而且是「放寬原本必定失敗的檢核」,不涉及資料庫 stored procedure、不需要呼叫端配合。

值得一提的是這個 bug 的形狀:參數寫死成一個讓整條檢核失效的值。它不會拋例外、不會讓建置失敗、靜態分析也不會抱怨——只會讓使用者一直看到同一句錯誤訊息。這種 bug 從 diff 上看只有一個 token 的差別,卻是這次比對唯一真正要回報的東西。

五、順帶抓到的:同型檢核沒同步改

全 DLL 有 20 個 CheckCodeValid 的呼叫點,只有上面那一處改了。

其中有一處值得單獨拉出來:另一條匯入路徑(CheckRowForTypeB,同一個檔案裡)的同型檢核,第三參數仍然是 false。

這不是比對出來的差異——兩版都一樣,所以它根本不在 diff 裡。它是判讀 🟢 那處變更時,順手去看「其他呼叫點長什麼樣」才發現的。

報告裡把它寫成一句提醒而不是結論:若兩條路徑本來就該行為一致,這裡漏改了;若是刻意保留,忽略即可。判斷該不該一起改是開發者的事,比對報告只負責把事實擺到他面前。

六、76 個檔案怎麼判讀得完

逐 hunk 分類是這整件事裡最耗神的部分,而且無法只靠規則自動化——判斷「這兩段是不是等價」需要真的讀懂兩邊在做什麼。

做法是把 76 個檔案切成三份,交給三個平行的判讀代理,每個代理拿到同一份分類準則(就是第一節那張表的展開版),各自回報自己那份的分類結果。彙整之後再做一次交叉驗證:

1
2
3
4
# 字串字面值:SQL、訊息、設定 key、SP 名稱全部逐字比對
grep -rhoE '"[^"]*"' "$OUT_A" --include="*.cs" | sort | uniq -c > str_A.txt
grep -rhoE '"[^"]*"' "$OUT_B" --include="*.cs" | sort | uniq -c > str_B.txt
diff str_A.txt str_B.txt

這一步是刻意的冗餘。逐 hunk 判讀是 LLM 做的,會漏;但「所有字串字面值的多重集合完全相同」是機械可驗證的事實,不需要理解程式碼就能檢查。兩者都通過,結論才站得住。

數值常數、運算子出現次數也用同樣方式對過一次。

七、這套方法的極限

誠實說清楚它不能做什麼:

它證明不了「完全沒差異」。 只能把可疑面收斂到很小。逐 hunk 判讀有漏判的可能,交叉驗證只涵蓋字串與常數這類機械可比的東西——邏輯順序被調換而字串不變的情況,仍然要靠人讀出來。

它依賴反編譯器的一致性。 兩邊必須用同一版 ilspycmd、同一組參數。-lv 尤其重要:不指定的話 ILSpy 會用 Latest,把 C# 5 時代的 IL 還原成 pattern matching、switch 運算式之類的新語法,兩邊還原策略只要有一點不同,diff 就會爆炸。

1
2
3
ilspycmd -lv CSharp5 -p -o "$OUT_A" \
--referencepath "C:\Program Files (x86)\Reference Assemblies\Microsoft\Framework\.NETFramework\v4.5" \
"$SRC/A_OrderSvc.dll"

混合建置組態會嚴重放大雜訊。 這次 76 檔裡有 75 檔是這個原因造成的。如果拿得到同組態的兩顆 DLL,差異量會少一個數量級,判讀成本也跟著掉下來。能控制建置組態的話就控制它,別靠事後判讀來補。

它不告訴你「為什麼改」。 只告訴你「改了什麼」。變更的意圖、是不是刻意、要不要一起改別的地方,都得回去問人。

還有兩個操作上的坑:反編譯輸出不要寫進專案目錄(那是幾千個 .cs 檔,很容易誤 commit,一律丟 C:/tmp/ 底下);中文或含空白的路徑先把 DLL 複製成 ASCII 檔名再處理,省得在工具鏈某一環爆掉。

八、固化成 skill

這套流程跑第二次的時候就該固化了——步驟固定、判斷準則固定、報告結構固定,變的只有輸入的兩顆 DLL。

拆成兩層:

  • 腳本負責機械的部分:複製成 ASCII 路徑、反編譯兩邊、抽類型清單、抽公開成員、逐檔 diff、寫一份 _summary.txt 交棒
  • skill 說明負責判斷的部分:五類分類法的完整定義、什麼一定不算問題、報告要長什麼樣、什麼絕對不能做(例如把整份 unified diff 貼進報告)

分界線畫在「這一步需不需要理解程式碼」。不需要的全部進腳本,需要的才留給模型判斷——腳本部分每次跑出來都一樣,可重現;模型部分則靠明確的準則約束,避免每次分類標準漂移。

「禁止把整個 unified diff 貼進報告」這條看起來很瑣碎,但它是整份 skill 裡最重要的一行。少了它,產出的東西會退化回本文開頭那個沒用的 76 檔 diff。


同分類的其他文章:Skills for Real Engineers 介紹、mattpocock/skills 使用教學。
.NET 相關:Dictionary、MemoryCache 與 HttpRuntime.Cache 的取捨。

Creative Commons 姓名標示 非商業性

本文採用 CC BY-NC 4.0 授權

歡迎轉載與引用,請標明出處(兩版 DLL 差了 76 個檔案,真正的功能變更只有 1 處 — mur mur);禁止用於商業用途。

商業使用或合作提案,歡迎來信洽談:[email protected]

留言
分享

留言