AI 代理撰寫的 PHP 程式碼,看起來正確的頻率遠高於其實際正確的頻率。若一次啟用所有品質工具,並不能解決問題,因為 AI 代理和開發者都會被大量的檢查結果淹沒。我會以固定的順序加入「門禁」檢查,並根據每個檢查工具每秒能捕捉到的問題數量來排序,在讓 AI 代理快速運行之前先設定好這些門禁。
這裡有兩個問題隱藏在「哪個門禁優先」之中:導入順序,即在專案中先引入哪個工具,並讓它通過檢查後再引入下一個?執行順序,即這些工具在提交(commit)或持續整合(CI)中以何種順序運行?這兩者有不同的答案。
導入順序遵循價值原則:能捕捉到 AI 撰寫程式碼中最多實際缺陷的門禁優先。執行順序則遵循成本原則:最快的檢查會先失敗,這樣較慢的檢查就不會白跑一趟。這篇文章主要討論導入順序,範例來自我的公開專案 template-symfony-ai。
門禁一:PHPStan(最高嚴格等級)。AI 撰寫的 PHP 程式碼中,典型的錯誤是類型錯誤。例如,一個可能回傳 ?Item 的方法,卻被當作總是回傳 Item 使用。
從解碼後的 JSON 酬載中取得的陣列,在未經檢查的情況下被視為 array<string, string>。此外,還可能呼叫一個在該類別中不存在的方法,因為該方法存在於模型曾見過的其他函式庫中。
PHPStan 無需執行任何一行程式碼就能找到所有這些問題,而且它能在 AI 代理剛寫好的檔案中發現它們。這就是為什麼它排在第一位。它運行成本低廉,發現的問題精確(檔案、行數、原因),且 AI 代理可以自行修復,無需猜測開發者的意圖。
該範本以我所知最嚴格的設定來運行 PHPStan:parameters: level: max,並啟用 checkUninitializedProperties: true、checkImplicitMixed: true、checkBenevolentUnionTypes: true 等檢查。
其中,checkImplicitMixed 對於 AI 代理程式碼最為重要。如果沒有它,一個沒有宣告類型的數值會悄悄地以 mixed 類型通過,而 mixed 幾乎允許任何東西通過。
另一個是每個函式的認知複雜度(cognitive complexity)限制為 8:AI 代理喜歡用一個充滿巢狀條件的長方法來解決問題。硬性限制會迫使它們將其拆分,使結果更容易審查。
我會優先複製的部分,甚至在 level max 之前,是一份附帶原因的禁用函式列表。例如,針對 var_dump 和 dd 建議「改用 logger」,針對 time 和 date 建議「改用 ClockInterface」。
訊息本身是關鍵。當 AI 代理遇到「改用 ClockInterface」時,它知道下一步該怎麼做。如果規則只說「禁用」,則會導致它尋找下一個變通方法。
我不嚴格的部分:該範本的 phpstan.neon 檔案有兩個 ignoreErrors 條目,每個都限制在特定路徑。它們分別是 config/ 下的閉包中拋出的已檢查例外,以及 tests/ 下對靜態方法的動態呼叫。
我寧願有兩個狹窄、可見的例外,也不願放鬆整個檢查等級。另一方面,全域忽略模式是 AI 代理會樂於添加以通過檢查的東西。因此,請自行審查該檔案的每一次變更。
門禁二:ECS 和 Rector。ECS 和 Rector 很少發現錯誤,它們排在第二位是因為幾乎沒有成本:兩者都能修復自己的發現,並將程式碼風格的討論從審查中移除。
範本中,ECS 設定啟用了 psr12、common、strict、cleanCode 等預設集。Rector 則啟用了 PHP_84、CODE_QUALITY、DEAD_CODE、EARLY_RETURN、TYPE_DECLARATION 等設定集。
在門禁本身,Rector 僅以試運行(rector process --dry-run)模式執行。它會報告將要變更的內容,如果存在變更則會失敗。實際修復是透過一個獨立的目標(make rector-fix)進行,這樣在變更提交之前,你就能看到這些變更。
對於 AI 代理程式碼,DEAD_CODE 和 EARLY_RETURN 設定集最有用:在幾輪「修復此錯誤」之後,未使用的變數和深度巢狀的 if 區塊是常見的殘留問題。
為什麼不先執行這些工具呢?因為即使程式碼格式整潔,如果類型錯誤,它仍然是錯誤的程式碼。如果你在 PHPStan 之前引入 ECS,最初幾週的發現都將是關於空白字元的。
門禁三:測試。測試排在第三位,並非因為它們不重要,而是因為 AI 代理也會撰寫測試。由撰寫程式碼的相同模型所寫的測試,往往會確認程式碼,包括其錯誤。
靜態分析則沒有這種偏見。因此,測試在類型正確之後才進行,並且稍後會再次檢查(門禁五)。該範本將快速測試套件與慢速測試套件分開。
test-unit 僅運行單元測試,而 test-integration 僅運行整合測試。在與 AI 代理的會話期間,每次變更後只運行單元測試套件。完整的測試套件則在提交之前運行一次,這使得回饋循環足夠短,以便 AI 代理能夠利用它。
門禁四:架構規則。AI 撰寫的程式碼會不經意地跨越層級邊界:例如,一個共用服務引入了領域類別,或者一個實體(entity)了解控制器。該範本透過 phpat 設定了相關規則。
例如,testSharedDoesNotDependOnDomains 規則確保 App\Shared 命名空間下的類別(排除 App\Shared\Controller)不依賴於 App\Example 和 App\User 等領域類別。
誠實地說,phpat 作為 PHPStan 的擴充功能運行,並且只檢查那些註冊為帶有 phpat.test 標籤的服務規則類別。在範本的 phpstan.neon 中,這些規則並未被啟用。
儘管規則存在、套件已安裝,且文件將 phpat 列為阻擋 CI 的門禁,但它從未被觸發。這就是這個門禁的教訓,也適用於所有其他門禁:一個你從未見過失敗的門禁,就不是真正的門禁。當你添加一條規則時,請故意添加一個違規,觀察檢查結果變紅,然後再移除它。
門禁五:變異測試(Mutation testing)。變異測試是對門禁三(測試)的檢查。Infection 會修改你的程式碼(例如,翻轉一個條件、移除一個方法呼叫、刪除一個回傳語句),然後再次運行測試。
如果測試仍然通過,則表示這些測試沒有涵蓋到該行程式碼。這正是 AI 代理為其自身程式碼撰寫測試的弱點。範本中設定了 minMsi 為 80、minCoveredMsi 為 90,並指定 --testsuite=unit。
它排在最後,因為它是迄今為止最慢的門禁,而且只有在其上方的測試穩定後才有用。在 CI 中,這個任務有其依賴性:如果單元測試失敗,變異測試就不會啟動。
根據範本的測量(在 i7-13700K 上的 Docker 環境中,三次運行的中位數):PHPStan 在整個專案上運行,有結果快取時需 0.4 秒,無快取時需 2.1 秒;Infection 在單元測試套件上運行需 4.7 秒。範本規模較小,在實際程式碼庫中,這個差距會更大,因為每個變異體都會重新運行涵蓋它的測試。
執行順序:成本最低的優先。在提交時,順序會有所不同。範本的 pre-commit hook 只檢查暫存的 PHP 檔案,並依序運行 ECS、PHPStan,然後是 Rector 的試運行。
make quality 運行相同的這三個工具。ECS 優先執行,這樣你就不必等待完整的分析,只為了發現一個括號放錯了位置。
對於五個暫存檔案,ECS 在範本上需要 0.23 秒,PHPStan 需要 1.2 秒(三次運行的中位數)。在 CI 中,ECS、PHPStan、Rector 和單元測試在共用映像檔建置後並行運行。
變異測試則等待單元測試完成。整合測試和端到端測試需要資料庫,並在各自的任務中運行。這種組合提供了兩種好處:導入順序告訴你應該先修復什麼,執行順序則告訴你最不需要等待什麼。
如果你從零開始:如果你的專案尚未具備這些,我會這樣做:首先設定 PHPStan 到目前能通過的等級,並加入禁用函式列表。然後逐步提高等級直到最高。
接著,在一個獨立的提交中,使用自動修復功能引入 ECS 和 Rector,這樣差異就不會與實際的程式碼變更混淆。
建立一個快速的單元測試套件,讓 AI 代理在每次變更後運行。引入架構規則,每條規則都透過故意植入違規來驗證。
最後,以較低的閾值引入 Infection 變異測試,然後再逐步提高。只有在完成這些之後,我才會讓 AI 代理進行更大的改動。
該範本已將所有五個門禁就位(截至撰寫本文時,其中一個尚未啟用),可在 github.com/tony-stark-eth/template-symfony-ai 找到。在你的專案中,哪個門禁捕捉到最多的 AI 錯誤呢?如果你覺得這篇文章有用,請在我的部落格上關注我以獲取更多內容。