コンテンツにスキップ

コードインテリジェンス

Control Center は、コードを読む以外にコードを理解する4つの手段をエージェントに与えます。それぞれが本当に異なる問いに答え、4つあって1つではないのは、単一のメカニズムではそのすべてに答えられないからです。

メカニズム 答えること コスト
コードグラフ ここに何があり、何がそれに依存しているか インデックスの1パス。クエリは安価
言語サーバー これは本当にコンパイルが通るのか、この型は何か プロジェクトごとにサーバープロセス1つ
構造検索 この形はどこに現れるか ファイルごとに1回のパース
デバッガー この瞬間、このフレームで何が真だったか 停止したプロセス

コードグラフは tree-sitter から構築されます。シンボル、呼び出しエッジ、影響範囲です。高速で、安価で、型については何も知りません。いま書いた呼び出しがコンパイルを通るか、そのフィールドがオブジェクトに存在するか、リネームが再エクスポートを見落としていないかは、コードグラフには分かりません。

言語サーバーがなければ、エージェントはテスト時に初めてそれに気づきます。あるいは、永遠に気づきません。もっともらしいものを書き、先へ進み、失敗は3ターン後に別の変更に紐づいた形で表面化します。

つまり、見返りは実際には lsp ツールではありません。write、edit、apply_patch がラップされていることです。書き込みのたびにファイルが言語サーバーを通り、新たに導入された診断がツールの結果に折り込まれます。エージェントは、ビルドを壊したまさにその瞬間に、尋ねようと思っていたかどうかに関係なく、壊したことを知ります。

すでに伝えたことを繰り返すのは、何も言わないよりも有害です。結果がノイズで埋まると、エージェントはそれを読み飛ばすようになり、唯一の新しいエラーも一緒に流されてしまいます。実行ごとの台帳が、報告済みの内容を重要度・コード・メッセージをキーに追跡し、位置はキーにしません。そのため、4行下にずれただけの診断が新しいものとして再報告されることはありません。

サーバーは起動時に立ち上がりません。必要とする最初のリクエスト、ほとんどはそのサーバーが担当するファイルへの最初の編集で立ち上がり、アイドル状態のものは回収されます。Dart に一切触れないホストがアナライザーを起動することはありません。

検出は積集合です。チェックアウトにルートマーカーが存在し、かつバイナリが解決できなければなりません。マーカーだけでは「このプロジェクトは Python だ」と言えるだけで、サーバーがインストールされているかは分かりません。解決できるバイナリだけでは、ホストにツールチェーンがあると言えるだけで、このチェックアウトについては何も分かりません。両方が揃って初めてクエリが成功しうるのであり、提示されるのに常に失敗するツールは、存在しないツールよりも悪いのです。

grep は文字にマッチします。名前には適したツールで、形には適しません。

構造パターンはファイルと同じ文法でパースされるため、dispose($X) はスペースの入れ方や折り返し方、改行の位置に関係なくその呼び出しを見つけ、文字列リテラルやコメントの中の同じ文字並びには決してマッチしません。

これを「ひと手間多い正規表現」以上のものにしている性質は、ひとつのパターンの中で繰り返されるメタ変数が同じテキストを捕捉しなければならないことです。

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

これは、検査したものを破棄するガードにはマッチし、別のものを破棄するガードにはマッチしません。これはテキストではそもそも表現できないバグの種類です。

第二のエンジンはありません。cc_natives はコードグラフのために tree-sitter とその文法をすでに読み込んでおり、コンパイル済みクエリのキャッシュと言語テーブルを備えています。マッチャーは、どのみちパースされていたツリーに対する純粋な Dart です。第二の構造エンジンは、第二のパーサー、あらゆるプラットフォームでビルドして展開すべき第二の文法セット、そして「呼び出し式とは何か」に対する2つの答えを意味します。

文法は、完全なファイルとは何かしか定義しません。Dart のトップレベルでそのままパースすると、dispose(x) は完全に妥当な関数シグネチャ、つまり x というパラメーターを取る dispose という宣言になり、それを知らせるエラーはどこにも出ません。そのパースを前提に作られたマッチャーは宣言を探し、呼び出しは見つからず、見た目どおりには一度も読まれていないパターンに対して「マッチなし」と報告します。

そこで、パターンはスキャフォールドのはしご、つまり文、式、クラスメンバー、トップレベルで順に試され、最初に受け入れたものが採用されます。どのコンテキストも受け入れない断片は、検索して静かに見つからなかったことにするのではなく、パース不能として報告されます。

書き換えが、書き込みではなくステージングする理由

「書き換えが、書き込みではなくステージングする理由」というセクション

構造的な書き換えは、エージェントができる最も広範囲な編集です。ひとつのパターン、40ファイル、そして結果を行単位で読む人は誰もいません。モデルも、それを依頼した人もです。それを書き込んで「完了」と報告するということは、変更を正直に初めて見るのが後からの git diff になるということです。

そのため ast_edit は、何をするつもりか、つまり件数、ファイル、マッチした箇所を報告し、変更は resolve がコミットしたときに初めて確定します。これによって、ディスクに届く前に差分と「承認/破棄」を人間の前に出すこともできます。

コミットはオール・オア・ナッシングで、すべてのファイルはステージング時に捕捉した内容と照合されます。ステージングとコミットの間に、エージェントがそれらのファイルの1つを手で編集したかもしれませんし、診断パスが書き換えたかもしれませんし、フォーマッターが触れたかもしれません。そのままコミットすればその作業は破棄され、差分は意図的なものに見えてしまいます。少しでも食い違えば変更全体が拒否されます。部分的に適用された構造的書き換えは、どちらの形でもコンパイルできないツリーを残すからです。

「print 文には答えられないことに、デバッガーが答える」というセクション

print 文のループはこうです。print を追加し、全体をもう一度実行し、出力を読み、print を削除する。問いごとにテストの完全な実行がかかり、実行する前に思いついた問いにしか答えられません。

停止したフレームは、その瞬間に関するあらゆる問いに一度に答えます。すべてのローカル変数、すべての呼び出し元、そしてフレーム自身のスコープで評価される式です。固まったプロセスや、10回に1回しか再現しない失敗にとって、これは午後が数分になるかどうかの違いです。

エンクロージャーと同じ理由で、同じように制限される

「エンクロージャーと同じ理由で、同じように制限される」というセクション

デバッグアダプターは、そのプロセスが保持するもの、つまりポート、ロック、データベース接続を保持する停止したプロセスを所有します。誰も操作していないセッションは、それを開始したターンより長生きするリークです。そこで厳密な TTL があり、会話ごとにセッションはちょうど1つで、2回目の launch は最初のものを黙って置き換えるのではなく拒否されます。最初のものを終わらせずに2つ目を起動するエージェントは、ほぼ確実に最初のものを見失っています。

ひとつのトランスポート、ふたつのプロトコル

「ひとつのトランスポート、ふたつのプロトコル」というセクション

Debug Adapter Protocol は Language Server Protocol の Content-Length フレーミングだけを共有し、それより上の層は共有しません。相関キーは異なり、失敗の形も異なり、LSP に対応するものがない3番目のメッセージ種別(イベント)があります。フレーミングは1か所にあり、各プロトコルはその上に別々に載っています。

フレーミングを2回書くということは、「メッセージが2つのチャンクにまたがる」「1つのチャンクに3つのメッセージが入る」「サーバーが最初のフレームの前に stdout へバナーを出力した」に対する答えが2つになり、そのうち片方しか修正されないということです。