AI AgentTypeScriptCI/CDCode QualitySoftware Engineering

AI 寫的 Code 如何不變成技術債工廠:我的 5 條 Automated CI 鐵律

··閱讀約 6 分鐘

上個月某個深夜,我讓 AI Agent 幫我重構一個資料轉換模組。三分鐘後,它交出了 PR:改了 12 個檔案、補了 18 個單元測試,終端機上一片綠色的 PASS。

我以為可以收工了。

順手點開 diff 瞄了一眼,我倒吸了一口氣:在核心資料流的第三層,它遇到一個型別不相容的邊界,於是它熟練地寫下了 `as unknown as Record<string, any>`;在處理可選欄位時,它把原本共用的 `formatDate` 忽略,自己又手刻了一個沒有時區處理的日期函式;更絕的是,它為了讓某個測試通過,把一個本該拋出錯誤的狀態靜默回傳了空陣列。

這就是 AI 寫 code 的典型特徵:**它追求的是「在此刻滿足指令」,而不是「在未來維護系統」。** 如果沒有外部硬性拘束,它會像水一樣,永遠往阻力最小的方向流——而阻力最小的路,往往就是技術債。

鐵律一:把 tsconfig 鎖到最死,拔掉 AI 的逃生門

很多團隊的 TypeScript 只開了基礎檢查,這對人類工程師可能夠用,但對 AI 來說等於處處是漏洞。當型別系統有模糊空間時,AI 會毫不猶豫地利用這些灰色地帶。

我在專案裡的 `tsconfig.json` 必定開啟以下四個嚴格選項:

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
針對 AI 程式碼特性的 ESLint 攔截矩陣

鐵律三:領域架構閘門(Architecture Gates)

單元測試只能驗證「函式算得對不對」,驗證不了「系統架構有沒有被破壞」。

以 keeponfirst.com 這個網站為例,我們有 strict 的 SEO 與 AdSense 發布規則:廣告只能出現在文章頁、預渲染 HTML 不能有空殼、每篇新文章必須雙語對稱。如果只靠提示詞交代 AI「記得不要在首頁放廣告」,AI 在十次重構中有兩次會忘記。

我的解法是寫獨立的驗證腳本,掛在 `npm run build` 的最後一道防線:

javascript
// 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 機器人把守,讓人類把專注力留給最核心的架構與取捨,這才是一個人做出十人份軟體的真正支點。

參考資料