AI 寫的 Code 如何不變成技術債工廠:我的 5 條 Automated CI 鐵律
上個月某個深夜,我讓 AI Agent 幫我重構一個資料轉換模組。三分鐘後,它交出了 PR:改了 12 個檔案、補了 18 個單元測試,終端機上一片綠色的 PASS。
我以為可以收工了。
順手點開 diff 瞄了一眼,我倒吸了一口氣:在核心資料流的第三層,它遇到一個型別不相容的邊界,於是它熟練地寫下了 `as unknown as Record<string, any>`;在處理可選欄位時,它把原本共用的 `formatDate` 忽略,自己又手刻了一個沒有時區處理的日期函式;更絕的是,它為了讓某個測試通過,把一個本該拋出錯誤的狀態靜默回傳了空陣列。
這就是 AI 寫 code 的典型特徵:**它追求的是「在此刻滿足指令」,而不是「在未來維護系統」。** 如果沒有外部硬性拘束,它會像水一樣,永遠往阻力最小的方向流——而阻力最小的路,往往就是技術債。
鐵律一:把 tsconfig 鎖到最死,拔掉 AI 的逃生門
很多團隊的 TypeScript 只開了基礎檢查,這對人類工程師可能夠用,但對 AI 來說等於處處是漏洞。當型別系統有模糊空間時,AI 會毫不猶豫地利用這些灰色地帶。
我在專案裡的 `tsconfig.json` 必定開啟以下四個嚴格選項:
{
"compilerOptions": {
"strict": true,
"noImplicitAny": true,
"strictNullChecks": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true
}
}特別是 `noUncheckedIndexedAccess`。預設情況下,`array[index]` 或 `record[key]` 的型別會被當成確定存在;開啟這個選項後,存取結果強制包含 `| undefined`。這一個改動,直接逼 AI 必須在每一處陣列與物件讀取時寫下完整的防禦邏輯,而不是假定資料永遠完美。
鐵律二:語義型 Linter 擋住「看似合理的壞味道」
語法正確不代表設計良好。AI 在被人類催促「修復這個錯誤」時,最喜歡用的三招是:加 `@ts-ignore`、把型別轉成 `any`、以及在 React Hook 裡偷偷拿掉依賴項。
這些動作在單元測試裡通常測不出來,但會在線上環境引發嚴重的競態(Race Conditions)或記憶體洩漏。我的 CI 規則直接在 ESLint 層級把這些行為列為 fatal error:
| 禁止行為 | AI 的慣性動機 | CI 處置 |
|---|---|---|
| @ts-ignore / @ts-nocheck | 遇到複雜泛型時圖快繞過 | 完全禁止,僅允許附帶 Issue 編號的 @ts-expect-error |
| explicit any | 處理第三方不可控資料懶得宣告型別 | CI build 失敗,強制使用 unknown 搭配 Type Guard |
| react-hooks/exhaustive-deps | 為了解決無窮 re-render 偷砍依賴 | 設為 error,不允許 warning 靜默通過 |
| no-duplicate-imports | 跨檔案剪貼時產生零碎 import | 自動 autofix 或擋下 PR |
鐵律三:領域架構閘門(Architecture Gates)
單元測試只能驗證「函式算得對不對」,驗證不了「系統架構有沒有被破壞」。
以 keeponfirst.com 這個網站為例,我們有 strict 的 SEO 與 AdSense 發布規則:廣告只能出現在文章頁、預渲染 HTML 不能有空殼、每篇新文章必須雙語對稱。如果只靠提示詞交代 AI「記得不要在首頁放廣告」,AI 在十次重構中有兩次會忘記。
我的解法是寫獨立的驗證腳本,掛在 `npm run build` 的最後一道防線:
// scripts/check-publisher-scope.mjs
const htmlFiles = await listHtml(distDir);
for (const file of htmlFiles) {
const html = await readFile(file, 'utf8');
if (!html.includes(ADSENSE_SRC)) continue;
const canonical = html.match(/<link rel="canonical" href="https:\/\/keeponfirst\.com([^"#?]*)"/i)?.[1] ?? '';
// 廣告一旦出現在非 /article/ 路徑,build 直接中斷噴錯
if (!/^\/(?:en\/)?article\//.test(canonical)) {
failures.push(`${relative(distDir, file)}: AdSense loaded outside an article canonical`);
}
}這類架構閘門不依賴任何外部測試框架,執行只需幾十毫秒。一旦 AI 在產出時違反了業務邊界,CI 會在第一時間直接中斷,並且給出精確的檔案與失敗原因。
鐵律四:合約測試(Contract Testing)優於自圓其說的單元測試
讓 AI 幫自己寫單元測試有一個致命缺陷:**它會為自己的錯誤實作量身定做一組剛好會過的測試。**
例如它把除以零的例外改成了回傳 0,它寫的測試就會是 `expect(divide(5, 0)).toBe(0)`。在 CI 看起來綠意盎然,實際上合約已經被悄悄竄改。
有效的解法是:**測試契約(Contracts)由人類或歷史固定規範定義,不讓 AI 自行發明驗收標準。** 使用 Invariant Testing(不變量測試)或 Property-based Testing,隨機生成 1000 組邊界輸入,驗證系統在極端狀態下是否始終符合狀態機不變量。
鐵律五:人類 Code Review 的範疇重組
在 AI 協同時代,工程師如果還把時間花在看縮排、命名風格、語法糖,那完全是放錯重點。那些事 Linter 早就該做完了。
我現在審查 AI 產出的 PR,眼睛只盯著三件事:
1. **失敗邊界**:網路斷線、API 回傳 500、JSON 格式損壞時,它怎麼死?是優雅降級,還是安靜吞掉錯誤?
2. **狀態契約**:這個改動會不會破壞既有資料庫或快取結構?舊版 client 打新版 API 會不會崩潰?
3. **架構重複**:專案裡是不是已經有現成的工具函式?AI 有沒有因為 context 沒讀到而自己又造了一個輪子?
結尾:AI 是引擎,CI 是防滾架
很多人問我:「AI 寫 code 這麼快,以後是不是不需要寫測試了?」
我的答案恰恰相反:**引擎的馬力越強,底盤和防滾架就必須越堅固。**
當產出代碼的成本降到接近零,驗證與過濾代碼的成本就決定了一套系統能走多遠。把嚴格的邊界交給 CI 機器人把守,讓人類把專注力留給最核心的架構與取捨,這才是一個人做出十人份軟體的真正支點。