跳至內容

程式碼智能

Control Center 讓代理有四種在閱讀之外理解程式碼的方式。它們回答的是真正不同的問題,而之所以有四種而不是一種,是因為沒有任何單一機制能回答全部問題。

機制 回答的問題 代價
程式碼圖 這裡有什麼,以及什麼依賴它 一次索引;查詢成本低
語言伺服器 這段程式碼真的能編譯嗎,這個型別是什麼 每個項目一個伺服器行程
結構化搜尋 這種形狀出現在哪裡 每個檔案一次解析
除錯器 在這一刻、這個堆疊框裡,什麼是成立的 一個被暫停的行程

程式碼圖由 tree-sitter 建構:符號、呼叫邊、影響半徑。它快、成本低,但對型別一無所知。它無法告訴你剛寫下的呼叫能否編譯、那個欄位是否存在於該物件上,或者你的重新命名是否漏掉了一處再匯出。

沒有語言伺服器,代理要到執行測試時才發現問題——或者永遠發現不了。它寫下看似合理的東西,繼續往下做,而失敗會在幾輪之後冒出來,還掛在另一項改動上。

所以真正的回報其實不在 lsp 工具本身,而在於 write、edit 和 apply_patch 都被包了一層:每次寫入之後,檔案都會經過語言伺服器,新引入的診斷會併入工具結果。代理在弄壞組建的那一刻就知道自己弄壞了,無論它有沒有想到要問。

重複代理已經被告知過的內容,比什麼都不說更糟:它會讓結果塞滿雜訊,代理學會一眼略過,而那條唯一的新錯誤也隨之被略過。每次執行都有一份帳簿記錄已報告過什麼,以嚴重程度、代碼和訊息為鍵——而非位置,因此僅僅向下移動了四行的診斷,不會被當作新增而重新報告。

啟動時不會拉起任何伺服器。伺服器在首個需要它的請求出現時才啟動,而這幾乎總是對它名下某個檔案的第一次編輯;閒置的伺服器會被清掃掉。從不接觸 Dart 的主機永遠不會啟動分析器。

偵測是一個交集:檢出目錄裡必須有根標記,並且該二進位檔必須能被解析到。單憑標記,只能說明「這個項目是 Python」,說明不了伺服器是否已安裝;單憑可解析的二進位檔,只能說明主機上有工具鏈,說明不了這個檢出。只有兩者同時成立,查詢才可能成功;而一個會被提供、卻總是失敗的工具,比一個根本不存在的工具更糟。

既然已有 grep,為何還需要結構化搜尋

標題為「既然已有 grep,為何還需要結構化搜尋」的章節

grep 匹配的是字元。找名稱時它是對的工具,找形狀時則是錯的那個。

結構化模式由與該檔案相同的語法解析,因此 dispose($X) 無論如何加空格、換行或包裹,都能找到這個呼叫,也絕不會匹配字串常值或註解裡出現的相同字元。

讓它不只是「多繞幾步的正則表達式」的特性在於:同一個模式中重複出現的元變數,必須捕獲相同的文字:

if ($X != null) $X.dispose()

它會匹配處置自己所檢查對象的守衛,而不會匹配處置其他東西的守衛。這是純文字手段根本無法表達的一類 bug。

沒有第二個引擎。cc_natives 已為程式碼圖載入 tree-sitter 及其語法,並附有已編譯查詢快取和語言表。匹配器是純 Dart,作用於本來就會被解析的那些語法樹。再造第二個結構化引擎,就意味著第二個解析器、第二套需要在每個平台建置與部署的語法,以及對「什麼是呼叫表達式」的兩個不同答案。

語法只定義了整個檔案是什麼。把 dispose(x) 當作 Dart 頂層內容裸著解析時,它是一個完全合法的函式簽章——一個名為 dispose、接受參數 x 的宣告——而且不會有任何錯誤洩漏端倪。建立在這種解析之上的匹配器會去找宣告,一個呼叫也找不到,於是對一個從未按它表面樣子被解讀的模式報告「沒有匹配」。

因此,模式會在一連串支架中逐層嘗試——語句、表達式、類別成員、頂層——第一個接受它的就勝出。沒有任何上下文接受的片段,會被報告為無法解析,而不是被搜尋一番後悄悄查無結果。

結構化重寫是代理能做出的最「寬」的編輯:一個模式、四十個檔案,而沒有人會逐行讀結果——模型不會,提出要求的人也不會。把它寫下去然後報告「完成」,意味著對這項改動的第一次認真審視,要等到事後看 git diff。

所以 ast_edit 報告的是它將會做什麼——數量、檔案、匹配到的位置——只有 resolve 提交之後,改動才真正落地。這也正是為什麼在有任何內容到達磁碟之前,會先有一個 diff 和一個「接受/丟棄」擺在人面前。

提交是全有或全無,每個檔案都會與暫存時擷取的內容核對。在暫存與提交之間,代理可能手動編輯過其中一個檔案、一次診斷處理可能重寫過它,或者格式化工具可能碰過它——照樣提交會丟棄那些工作,而 diff 看起來卻像是有意為之。任何不一致都會拒絕整項改動,因為只套用了一半的結構化重寫,會留下一棵在兩種形狀下都編譯不過的樹。

除錯器能回答什麼打印語句回答不了的問題

標題為「除錯器能回答什麼打印語句回答不了的問題」的章節

打印語句的循環是:加一行打印,把整個東西再跑一遍,讀輸出,刪掉打印。每問一個問題都要付出跑一整輪測試的代價,而且它只能回答你在執行之前就想到要問的那個問題。

一個被暫停的堆疊框能一次回答關於那一刻的所有問題——每個區域變數、每個呼叫者,以及在該堆疊框自身作用域裡求值的表達式。對於一個卡死的行程,或者每十次執行才重現一次的失敗,這就是耗掉一個下午與花一分鐘的分別。

除錯配接器擁有一個被暫停的行程,它佔著那個行程所持有的一切——一個連接埠、一把鎖、一個資料庫連線。無人操作的工作階段就是一種洩漏,會比啟動它的那一輪活得更久。因此這裡有硬性 TTL、每個對話剛好一個工作階段,而第二次 launch 會被拒絕,而不是悄悄取代第一個:一個沒有結束第一個就啟動第二個的代理,幾乎肯定已經跟丟了第一個。

Debug Adapter Protocol 與 Language Server Protocol 共用 Content-Length 分幀,除此之外別無共同之處——不同的關聯鍵、不同的失敗形態,還有第三種在 LSP 裡沒有對應物的訊息類型(事件)。分幀只存在於一處;兩種協定各自架在它之上。

把分幀寫兩遍,就意味著對「一條訊息橫跨兩個資料塊」、「一個資料塊裡裝了三條訊息」和「伺服器在傳出第一幀之前往 stdout 寫了一行開場訊息」各有兩套答案,而其中只有一套會得到修正。