Writing
Apple Swift Engineering:讓 AI 在編譯前查清楚 Swift 程式
apple-swift-engineering 的起點,是一次對 SwiftFairy 的研究。我認可它的工程價值,也想把其中有用的能力帶進自己的 Agent 工作流程。
但我不想長期維護一套 Swift 規則庫。很多問題已有公開資料,與其自己保存和更新答案,我更想讓 Agent 根據程式碼上下文,知道應該搜尋什麼、去哪裡找證據。
當時,我的本地環境已經裝好 Xcode 27 提供的 Apple Skills,尤其在 macOS 開發上,已感覺到明顯改善。因此方案逐漸收斂:本地語義 CLI 提供專案與 SDK 事實,Apple Skills 提供框架指南,網絡搜尋補充資訊缺口,再用一份給 AI 看的工作說明,也就是 Skill,把流程接起來。最後做成了 Apple Swift Engineering Skill 與 swift-semantic CLI,沒有另外包成 MCP。
當時的目標是讓模型更快完成 Apple 工程任務。最近,我把這套工具整理成公開倉庫;回看實際使用時,我現在更關心的是:它能不能支援 AI 在 App 進入編譯之前,就指出具體的錯誤。
Apple Swift Engineering Skill 是什麼
Apple Swift Engineering 是給 coding Agent 使用的 Swift 工程工作流程與本地工具。Skill 本身是一份 SKILL.md,規定 Agent 如何取得專案事實、查證框架行為、追查程式碼並驗證修改;搭配的 swift-semantic CLI 負責產生可定位的查詢結果。
公開倉庫 包含 Skill、查詢與使用指南、原生 CLI、安裝腳本,以及讀取完整 JSON 結果的 Python 輔助程式。CLI 的 Swift 套件依賴固定版本的 swift-syntax 604.0.0,使用其中的 SwiftSyntax、SwiftParser 與 SwiftParserDiagnostics 解析原始碼及保留解析診斷;結果輔助程式需要 Python 3。
執行環境需要 macOS、Xcode 與相容的 Swift 工具鏈。本地 SDK 資訊取自選中的 Xcode,透過 xcrun、swift-frontend 與 swift-symbolgraph-extract 等工具取得。SDK 查詢會使用本機編譯器工具;本文的「編譯前」指完整 App build 之前。
Skill 將 Agent 使用的資訊分成三個來源:
| 來源 | 提供給 Agent 的資訊 |
|---|---|
本地 swift-semantic CLI | 專案宣告、引用與呼叫關係、檔案及行列位置;SDK 的簽名、所屬模組與可取得的 availability 資訊。 |
| 已安裝的 Apple Skills | 例如 SwiftUI 的框架使用指南,供 Agent 解讀 API 行為與工程約束。這些指南沒有打包進本倉庫;未安裝時可查官方文件。 |
| Agent 的網絡查詢 | 補充本地事實與指南未解答的問題,優先查 Apple 文件、WWDC、Swift.org 等一手資料,並核對適用版本。 |
CLI 返回 JSON,包含事實、來源位置、確定程度、診斷、環境資訊,以及 contextRequests。遇到無法確定的關係,會保留 ambiguous 或 needs_context 等狀態,讓 Agent 知道還需要哪個使用位置、模組或型別資訊。查不到關係時,不能直接推論它不存在。
例如,以下命令查詢 OpenSearchStatus 在專案裡的引用:
swift-semantic resolve \
--root /path/to/Syncnext \
--symbol OpenSearchStatus \
--query references下列 JSON 節錄自此案例保存的實際結果,只保留其中一筆引用與相關欄位,省略其他引用及統計,並將本機專案根路徑替換為 /path/to/Syncnext:
{
"schemaVersion": 1,
"state": "resolved",
"facts": [
{
"kind": "reference",
"subject": "OpenSearchStatus",
"values": [
"SearchCoreView.searchStatus"
],
"certainty": "resolved",
"evidence": [
{
"file": "/path/to/Syncnext/Syncnext tvOS/App/Search/Core/SearchCoreView.swift",
"line": 75,
"column": 39,
"role": "type-reference"
},
{
"file": "/path/to/Syncnext/Syncnext tvOS/App/Search/Status/OpenSearchStatus.swift",
"line": 15,
"column": 1,
"role": "declaration"
}
]
}
],
"contextRequests": [],
"diagnostics": [],
"context": {
"semanticScope": "source syntax and partial binding; no macro expansion or compiler type checking"
}
}這筆 reference 事實表示:SearchCoreView.searchStatus 引用了 OpenSearchStatus。evidence 給出使用端與宣告端的位置;Agent 可以直接讀取 SearchCoreView.swift 第 75 行附近的程式碼,再沿搜尋流程檢查結果的寫入與讀取。行列位置從 1 起算。
state: "resolved" 與這筆事實的 certainty: "resolved",表示查詢解析出了這個引用關係。contextRequests: [] 與 diagnostics: [] 表示本次結果沒有提出補充上下文要求或診斷;它們不證明整個搜尋流程正確。這筆 JSON 提供追查入口,尚未判斷回覆是否屬於正確的搜尋操作。
這套流程的改善在於判斷可以回到具體程式碼:Agent 從引用與呼叫位置追查資料的寫入端和消費端,再核對它們是否遵守同一個契約;框架相關判斷則有本機 SDK 與適用文件作依據。例如,接下來的搜尋案例需要檢查「新的搜尋工作,是否可能拿到上一輪的回覆」。這是資料歸屬問題,型別正確也可能發生。CLI 幫助定位相關程式碼,缺陷判斷由 Agent 檢查實作後提出,修改仍需編譯與測試驗證。
案例:非同步搜尋的請求身份與結果歸屬
Syncnext 的搜尋由 Swift 呼叫 JavaScript 插件,插件完成 HTTP 請求後,透過 bridge 回傳結果。以下分析的是重構前的普通插件搜尋路徑。
SearchCoreView
→ VODHelper.searchPlugin
→ SourcePlugin.search
→ JavaScript / HTTP
→ PluginCommunicationBridge
→ SourcePlugin 讀取結果
→ SearchCoreView 篩選、更新畫面這條資料流需要同時滿足兩個條件:
- 操作有效: 接收結果的工作仍屬於當前搜尋 session。
- 結果歸屬正確: 讀出的資料由該次插件搜尋產生。
1. 畫面檢查了工作身份,結果存取卻只識別來源
畫面接收來源批次時,已有以下檢查:
guard requestID == searchRequestID, !Task.isCancelled else {
continue
}但插件搜尋傳給 JavaScript 的結果 key 是 pluginAddress。回覆保存與讀取都使用同一個來源網址:
| 層級 | 使用的身份 |
|---|---|
| 畫面接收工作 | 每次搜尋建立的 UUID |
| 插件結果保存 | pluginAddress |
| 插件結果讀取 | pluginAddress |
同一插件先後搜尋 A、B,保存位置相同。開始 B 時,展示結果會清空,但 bridge 裡 A 的結果沒有清除。
等待邏輯檢查的是以下條件:
!PluginCommunicationBridge
.getSearchMedias(byKey: pluginAddress)
.isEmpty這裡判斷的是「來源位置已有非空資料」,沒有判斷「本次操作已回覆」。因此,畫面 UUID 檢查與結果 key 並未共同保證結果歸屬。
2. 可推導的失敗時序
以下是原始碼可推導的反例,不是執行期重現紀錄。令 P 為插件來源,A、B 為互不匹配的搜尋詞;P 下已有 A 的非空結果,B 的回覆尚未抵達:
| 步驟 | P 下的資料 | 執行結果 |
|---|---|---|
| 開始 B | A | B 建立新的 UUID,並啟動插件搜尋。 |
| 檢查等待條件 | A | 非空條件成立,B 的來源工作讀取 A 並返回。 |
| 畫面接收批次 | A | 工作 UUID 屬於 B,檢查通過。 |
| 按 B 的關鍵字篩選 | A | 不匹配 B 的舊片名被移除,該來源沒有顯示結果。 |
| B 的回覆抵達 | B | bridge 更新資料;該來源工作已返回,不會再發布第二個批次。 |
| 再次搜尋 B | B | 可能讀到上一輪留下的 B,於是出現結果。 |
這是由舊程式推導的失敗路徑,能解釋「首次缺結果、重搜才出現」的可能機制。原始日誌缺少 App 讀取快取的時點,尚不能確認當次回報是否走過這條路。
缺陷本身是身份契約不完整:requestID == searchRequestID 成立,不能推出 owner(result) == expectedOperation。
3. 回覆狀態與資料量也被合併了
getSearchMedias 在找不到 key 時返回 [];成功回覆零筆資料時也返回 []。
| 真實狀態 | 讀取值 | 非空等待條件 |
|---|---|---|
| 尚未收到回覆 | [] | 未完成 |
| 成功回覆零筆 | [] | 未完成 |
| 留有上一輪資料 | 非空陣列 | 完成 |
這個讀取介面無法區分未回覆與成功空結果,非空條件也可能把舊資料誤當本次完成。操作狀態需要與 payload 分開:
pending
→ success([items]) → terminal
→ success([]) → terminal
→ failure(error) → terminal
→ cancelled → terminal完成應由回覆事件決定,不能由陣列是否非空推定。
4. 修正:以原生 operation 綁定回覆
實際重構將普通插件搜尋改為獨立 operation。以下節錄身份建立與結果接收,省略逾時、取消及清理程式:
let requestID = UUID()
PluginAutomationRequestRegistry.begin(
requestID,
allowedOrigin: nil,
isSearch: true
)
let handler = JSCommunicationHandler(
debug: false,
name: plugin.name,
automationRequestID: requestID,
diagnosticRole: .search
)
// 位於 withCheckedThrowingContinuation 內
PluginAutomationRequestRegistry.awaitSearch(requestID) { result in
continuation.resume(with: result.mapError { $0 as Error })
}這裡的 requestID 是插件 operation 的身份,與畫面的搜尋 session UUID 分屬不同層。handler 建立 JS context 時,將其原生識別值綁到該 operation:
PluginAutomationRequestRegistry.bindJavaScriptContext(
automationContextKey,
to: automationRequestID
)callback 歸屬從原生 context 查得,JavaScript 傳入的 key 不再決定回覆應完成哪個 operation。
修正後的契約是:
- 每個 operation 接收自己的回覆,不讀取來源共用位置來推定本次結果。
success([])正常完成;同一 operation 最多完成一次。- 取消、完成後的晚到或重複回覆被拒收,清理只影響該 operation。
- session 在聚合時檢查當前搜尋身份,拒收已失效搜尋的結果。
來源錯誤仍只寫日誌,成功來源繼續呈現。內部保留失敗狀態,不要求畫面顯示錯誤。
5. apple-swift-engineering 提供了哪些證據
此案例保存的查詢之一是:
swift-semantic resolve \
--root /path/to/Syncnext \
--symbol OpenSearchStatus \
--query references返回的引用包括 OpenSearchView.openSearch 與 SearchCoreView.searchStatus,附有宣告及使用位置。Agent 依這些位置讀取執行流程,再檢查插件回覆的寫入與消費端。
| 證據 | 支持的結論 |
|---|---|
| CLI 引用與定位 | 找到狀態的入口及使用端。 |
| 舊版原始碼 | 確認共用結果 key、非空等待條件與畫面 UUID 檢查的契約缺口。 |
| 分析與編譯時間線 | 上述缺陷在重構及第一次 App 編譯前已提出。 |
| 重構後受控測試 | 覆蓋新舊搜尋切換、空回覆、晚到回覆、取消及單次完成;原生 JavaScriptCore 測試替換 HTTP/UI 等外部依賴。 |
| App/Top Shelf Simulator 編譯 | 相關改動通過編譯;不等同原始事故的實機重現。 |
CLI 提供引用與位置;資料流缺陷的判斷來自後續對 SourcePlugin 與 bridge 實作的檢查。它沒有自動產生這條缺陷 finding,這個案例也不建立與 SwiftFairy 檢測效果等價的結論。
公開版本
倉庫採用 MIT 授權,包含 Skill、本地 CLI、使用指南、安裝腳本與測試。
git clone https://github.com/qoli/apple-swift-engineering.git
cd apple-swift-engineering
bash swift_semantic/bin/install.sh安裝後可以請 Codex:
使用 $apple-swift-engineering,先追查搜尋的請求身份、回覆寫入與結果消費,檢查結果歸屬及完成條件,再提出修改。
公開版本已在 Xcode 27.0、Swift 6.4、macOS 27 arm64 上完成驗證:43 個原生測試、6 個結果處理測試通過,隔離安裝也通過。較舊工具鏈與 macOS 執行環境尚未驗證。