手書きの継ぎ目にはすべてガードが要る。Crossweft、AIエージェントのための言語横断の契約チェック
著者: Anastasiia Butova, ComfyUI・拡散モデルエンジニア(ベオグラード) · 公開 2026-10-08
英語版からの翻訳: English
私たちの最もコストの高いバグは、ファイルの中で起きたことは一度もありません。ファイルの間で起きました。1つの値が2つか3つの場所に、2つの言語で書かれていて、誰かが片方を変えます。すべてはビルドでき、テストは緑で、それでも顧客のマシンでインストールが壊れる。この記事では、そうした場所を明示的に、検査可能にした方法について書きます。そして、その検査を、私たちのコードの大きな部分を書いている AI エージェントの中に組み込んだ方法です。ツールはオープンソースです。github.com/AnastasiyaW/crossweft、プロジェクトページは happyin.work/crossweft です。
コンパイラには見えない10の境界
私たちは、ニューラルネットワークをユーザー自身のマシンで動かす Photoshop 用のレタッチプラグインを作っています。写真家とモデルの間には、10の信頼境界があります。プラグインとシステムサービスの間の保護されたチャンネル、デバイスの登録、信頼されたタイムスタンプを持つ署名済みのアーティファクト、ビルドの証明、アップデートのチャンネル、モデルの暗号化、そして透かしです。部品は C++ と Go で書かれ、別々のプロセスで動き、別々のインストーラーで届きます。
これらの境界のどれも、モジュールではありません。それぞれは、2つの側の間の合意です。同じサービス名、同じプロトコルのバージョン、同じルートの集合、同じ確認の文字列です。今日、これらの境界にまたがる54のリンクが、188の値の比較(コードの中の468か所)と105の集合の比較を担っています。コンパイラにはどれも見えません。2つの側が別の言語だからです。
私たちは、こうした場所を継ぎ目(seam)と呼び始めました。2つのコンポーネントが、正確に一致しなければならない地点です。
- クライアントが呼ぶルートと、サーバーが登録するルート。
- プロトコルのバージョンのヘッダー。
- Go と TypeScript の両方にある DTO のフィールド。
- サービスが読む環境変数と、
.env.exampleに挙がっているもの。 - 2つの言語に2度書かれた、価格の丸めの規則。
型、テスト、リンターは、1つの言語の中ではよく機能します。しかし、こうした合意を検査することはまれです。そのため、そうした合意は人の記憶の中にだけ残ります。
この名前は、織物の横糸(weft)から来ています。縦糸を横切って走り、生地を一つにまとめる糸です。
3つの話
1つの識別子、3つのコピー。 サービス名は、ほぼ同じ3つの関数の中で解決されていました。クリーンなマシンでは、サービスはまだインストールされていません。検索は失敗し、空のマシンは、古くて互換性のないインストールに見えてしまい、インストーラーは終了コード 76 で止まりました。私たちは1つのコピーを直しました。残りの2つは壊れたままでした。
1つの依存関係の2つの半分。 ビルドは、ライブラリを1つのインストールディレクトリから取り、そのツールを別のディレクトリから取っていました。設定は警告を表示するだけだったので、問題は後になってから表面化しました。本当の原因は、探すのが困難な場所でした。
何も守っていないガード。 あるセキュリティのルールの検証は、パスが間違っていました。何週間も、ゼロのファイルを検査して、毎回 OK と報告していました。それが通っていたのは、まさに実行されていなかったからです。
3つとも原因は同じです。合意には2つか3つの側があり、それが一致するかどうかは、誰かが注意を払うかどうかにかかっています。
AI エージェントはこれを悪化させ、そして助けもする
私たちのコードの大きな部分は、エージェントが書いています。主に Claude Code と Codex です。エージェントは、コンポーネント間の合意を、人よりも苦手にします。典型的なケースは、エージェントが Go の構造体を変更し、テストを実行し、緑を見て「完了」と言い、反対側の TypeScript のインターフェースを一度も開かないことです。
- ローカルな編集。 継ぎ目の反対側は、別のフォルダ、別の言語、別のプロセスにあります。
- 限られた文脈。 エージェントは製品全体を頭の中に持っておらず、コンパイラは、どこかのクライアントがその構造体に依存していると教えてくれません。
- 「完了」の狭い考え方。 エージェントにとっての完了とは、「自分に見えるすべてのチェックが通った」ことです。継ぎ目を見るチェックがなければ、ずれは何かが壊れるまで見えないままです。
エージェントは、正確なローカルの指示にはよく従います。編集の直後に、反対側のどのファイルを開き、そこで何が一致しなければならないかを伝えれば、エージェントはそれを実行します。両側が食い違ったまま終わらせないようにすれば、その間違いはコミットに届きません。
ルール:すべてのリンクは、どう一致を保つかを宣言する
コンポーネント間のすべてのリンクは、その2つの側がどう一致を保つかを宣言します。両側が手で書かれているなら、そのリンクにはガードが必要です。両側を読む検査です。
リンクはコードの隣にある JSON ファイルに置かれます。マップにはブロック(プログラム、サービス、ストア、設定ファイル)と、その間のリンク(HTTPS、名前付きパイプ、ファイル、コード生成、環境変数)があります。マップのすべての主張は、ソースファイルのリテラルにアンカーされます。そのため、コードが動いたとき、マップは過去を黙って描き続けることができず、検査は失敗します。
各リンクには contract.enforcement というフィールドがあります。
| Enforcement | 誰が両側を等しく保つか | 検査が求めるもの |
|---|---|---|
shared-code |
両側がインポートするシンボル | defined_in がそのシンボルを指す |
generated |
1つのソースからのジェネレーター | defined_in がそのソースを指す |
schema-tests |
共有のテストベクター | defined_in がそのベクターを指す |
duplicated |
誰も。手でコピーされる | 両側のファイルを読むガード |
convention |
誰も。合意されているだけ | 両側のファイルを読むガード |
none |
誰も | 両側のファイルを読むガード |
分類だけでも役に立ちます。「まだどこで誰かの記憶に頼っているのか」が、数えられるリストになります。片側しか読まないガードは、数えられません。ツールは、リンクの両端の、異なる2か所のファイルを読んでいることを確かめます。
3種類のガード:join、set、pair
ガードは意図的に単純にしてあります。正規表現で、値や値の集合を比較します。検査はミリ秒で終わり、ビルドを必要とせず、どの言語でも動きます。
join:値はどこでも同じ。 TypeScript のクライアントが送る API のバージョンと、Go のサーバーが期待するバージョン:
{"id": "api-version", "link": "web-orders", "points": [
{"path": "web/src/api.ts", "side": "web", "regex": "API_VERSION = \"([^\"]+)\""},
{"path": "server/main.go", "side": "api", "regex": "APIVersion = \"([^\"]+)\""}]}
すべての出現が比較されます。最初の一致だけではありません。最初の版は最初の一致だけを検査していたので、ファイルの下のほうにある2つ目のコピーがすり抜けてしまいました。
set:メンバーが一致する。 TypeScript のインターフェースのフィールドと Go の JSON タグ、Python のワーカーが書くステータスと Go の定数、コードが読む環境変数と .env.example。意図的な違いは、理由とともに allow に入れます。そして、もう必要なくなった allow のエントリーは、検査そのものを失敗させます。
pair:重複したロジックで、比較すべき単一の値がないもの。 例えば、Go と TypeScript の価格の丸め。両方の領域は、crossweft:begin price-rounding と crossweft:end price-rounding でマークされ、Crossweft がそれらの指紋を取ります。どちらかが変わると、誰かが対になるほうを読み直し、crossweft attest price-rounding --reason "..." を実行するまで、検査は失敗します。理由はロックファイルとレビューに入ります。
赤い pair から出る最短の道は、エージェントにとって、自分が変えた側を attest することで、もう一方には決して触らないことです。そこで、前回の attest から一方の領域だけが変わっていれば、attest は拒否します。先に対になるほうを直すか、すでに同等であるなら、--other-side-unchanged で明示的にそう言ってください。
さらに大きな検査もあります。サーバーが登録するすべてのルートは、マップに載っていなければなりません。Go の chi は直接読まれます(Express、Fastify、FastAPI、Flask、gin、echo も)。ほかのものは正規表現で読まれます。クライアントのコードにあるすべての /v1/... の呼び出しは、リンクによって説明されなければなりません。ブロックは、自分が作成したか受け取ったデータしか送れません。すべてのソースフォルダは、マップに載っていなければなりません。
組み込みの種類で足りないとき、リポジトリはプラグインとして独自の種類を追加できます。私たちには13個あります。1つは、Go のサーバーが SQL に話しかける916か所を、スキーマの161テーブルと276トリガーと比較します。別の1つは、185のサーバーのルートを検査します。さらに別のものは、タイムアウトのための27の「waiter ≥ work + margin」のペアを検査します。インポートに失敗したり、クラッシュしたり、何も比較しなかったりするプラグインは、緑の結果ではなく、エラーです。
エージェントが見るもの
検査は半分にすぎません。エージェントは、継ぎ目について、コードがコミットできる状態になってからではなく、編集の直後に知る必要があります。そのために、3つのフックがあります。
- セッションの開始: リポジトリに継ぎ目のマップがあること、その数、そしてそれとどう付き合うかを、短く知らせるメモ。
- 編集のたび: ファイルが継ぎ目の上にあるなら、エージェントに反対側と、ガードを伝えます。
- 終える前:
crossweft checkを実行します。継ぎ目が一致していなければ、エージェントは終えられず、何をすべきかを伝えられます。
デモの Go の API を編集した後、エージェントには次のように表示されます。
crossweft: server/main.go is part of 3 cross-component seam(s). Keep both sides in agreement:
- link web-orders (web -> api, https; Orders API [duplicated]): you changed its to side.
Re-read: web/src/api.ts. Guarded by: join:api-version, join:version-header,
set:order-fields, set:web-statuses, pair:price-rounding.
...
Run `crossweft check` before you finish.
もし Go で API のバージョンを上げて、そのまま終わろうとすると、フックは押し返します。
crossweft check fails:
- join:api-version disagrees: web@web/src/api.ts:3='2026-09-01'; api@server/main.go:14='2026-10-01'
[server/main.go:14] (key: join:api-version:b5dfb7e5)
A seam is out of agreement: bring the other side in line with the one you changed
(`crossweft impact <file>` names it).
助言は、問題の種類によって変わります。マップが指している値のリテラルが消えていれば、エージェントは「もう一方の側を直せ」ではなく、アンカーを更新するかコードを戻すように言われます。キーの末尾には、どのファイルがどの値を持っているかのダイジェストが付きます。そのため、記録された例外は、まさにその食い違いだけを許します。
停止のフックには、古典的な失敗のモードが2つあります。無限ループと、黙ったままの通過です。私たちのものは、エージェントが進捗している間(失敗しているキーの集合が変わる間)だけ、エージェントを送り返します。最大で連続4回までです。進捗がなければ、エージェントは止まってよく、人には、検査がまだ赤いという明示的なメッセージが届きます。
Claude Code のためには、フックとスキルを持つプラグインがあります。Codex のためには、スキルのプラグインがあります。MCP を話すどのエージェントも、crossweft mcp を接続すれば、検査、変更の影響、「どのファイルを読み直すべきか」を、読み取り専用のツールとして得られます。Gemini CLI、Copilot、Cursor のフックのアダプターは、まだ実験的です。出力の形式はテストされていますが、ループ全体はまだ検証していません。
静かに通ることのできない検査
3つ目の話は、何週間も何も走査していなかった検証でした。すべてのルールが「A が B に等しい」というものではありません。定数が1つのソースしか持たないことを証明したいこともあります。そうしたルールは小さなスクリプトに置かれ、まさにそれらが静かに壊れます。
crossweft validators のランナーは、次の場合に検証を失敗させます。ゼロでない終了コードで終わったとき。SCANNED: files=<n> items=<n> を出力しないか、ゼロを出力したとき。セルフテストがないか、失敗したとき(SELF-TEST: checks=<n>、埋め込んだエラーに対して実行)。時間切れのとき。本当に適用されない検証は、APPLICABILITY: <reason> を出力して、PASS ではなく SKIP になります。
Crossweft 自体も同じように動きます。結果は2つではなく3つです。0 は、すべてが一致していること、1 はずれ、2 は検査が行われなかったこと(モデルが読めない、何も走査されなかった)を意味します。終了コード 2 は、決して成功に見えません。
静かに腐らない、既知のずれの記録
すべての食い違いが、同じプルリクエストで直せるわけではありません。そのときは、finding(検出事項)を記録します。問題のキー、担当者、次の一手です。問題が残っている間は、検査は通ります。直されると、その finding は古くなり、誰かが閉じるまで検査を失敗させます。壊れたアンカーは、この方法では許されません。マップがコードについて誤ったことを記述することは、許されないからです。
すでにずれのあるリポジトリのためには、crossweft baseline がそれらすべてを、担当者つきの未解決の finding として記録します。検査は緑になり、新しいずれは依然として検査を失敗させ、直されたものは閉じるように求められます。
何が変わったか
正直な文脈から始めます。7月半ばから、私たちのインストーラーはクリーンな仮想マシンで、システムサービスを一度も立ち上げませんでした。9月26〜28日に、検査つきの継ぎ目を追加し、9月29日にサービスが初めて起動しました。これは時期の偶然であり、原因の証明ではありません。同じ時期に他の修正も入っていたからです。
変わったのは、失敗の種類です。9月28日の最初の失敗のうち3つは、継ぎ目でした。終了コード 76(サービス名が3つのコピーにある)。サーバーが、アーティファクトのバージョンで登録されたローダーを、プロトコルのバージョンで要求されたために拒否したこと。そして、2つの公開の経路が古いパッケージを削除したのに、3つ目は削除しなかったために、HTTP 500 になったこと。それぞれに、今は独自のガードがあります。次の2つの問題は、ふつうのバグで、「1つの値が複数の場所にある」ものではありませんでした。
リポジトリの履歴から数え直した、マップの状態は次のとおりです。
| 9月27日 | 9月29日 | 10月4日 | |
|---|---|---|---|
| ブロック / リンク | 130 / 160 | 134 / 166 | 138 / 192 |
| join のガード | 10 | 220(529 ポイント) | 261(644 ポイント) |
| set のガード | 4 | 125 | 138 |
| Finding | 58、すべて未解決 | 106:93 閉じた、10 未解決、3 受理 | 146:117 閉じた、25 未解決、4 受理 |
「すべてのリンクは、どう一致を保つかを宣言する」ことへの最初のパスは、エージェント自身が行いました。1日で、185の join と107の set のガードを追加し、コードの中で15の本物の食い違いを見つけました。閉じられた93の finding のうち91には、修正のコミットがあり、そのうち58のコミットメッセージには、マップ内の finding の ID が書かれています。
チェックのないマップは、1週間で古くなります。 最初の測定から1週間後には、96の問題がありました。そのうち94は、コードのバグではなく、マップが古くなったことでした。だからこそ、エージェントが停止するたびに、マップは検査されます。6月の4つの検証から、50に増えました。そして、そのどれもが、何かを走査したことを証明しなければなりません。
オープンにすること、そして最初の CI の実行が見つけたもの
このツールは商用製品の中で育ったので、慎重に公開しました。2つのブランチがあります。公開の oss は、ツールだけです。非公開の internal は、oss に、私たち自身のルールのフォルダを加えたものです。変更は oss から internal へだけ流れます。公開されるのは、ツリーのスナップショットで、リリースごとに中立の署名を持つ1つのコミットです。私たちの履歴ではありません。公開の前に、スキャナーがスナップショットと、そのコミットのメタデータを、非公開の単語のリストと照合します。さらに、新しい独立したレビュアーが、前回のリリース以降の差分を読みます。スキャナーは、与えられた単語しか知らないからです。最初のレビューでは、ドキュメントの中に、製品がどう作られているかを匂わせる3つの言い回しが見つかり、リリースの前に差し替えました。
リリースのタグは、Linux、Windows、macOS で CI が緑になってから付けます。タグは変更できないからです。最初の実行は赤でした。ローカルの157のテストが見逃していた、本物のバグを2つ捕まえました。
- コードが、Python 3.10 からしか存在しない引数を使っていました。一方で私たちは 3.9 をサポートすると約束しています。
- 同じ長さに、1秒以内に2回編集されたプラグインが、古いバイトコードから走っていました。Python のキャッシュは、ファイルを秒とサイズで区別するからです。ローカルでは見えませんでした。開発用マシンは、マシン全体でバイトコードの書き込みを止めているからです。
両方を直し、2回目の実行は緑になり、v0.1.0 が出ました。2日後、v0.2.0 は MCP サーバー、check --changed、OpenAPI と proto のスキーマから作るガード、さらに5つのフレームワークのルートのスキャナーを加えました。この記事の要点を示しています。緑のチェックが語るのは、誰かがすでに考えた種類の間違いについてだけです。
こんなものはもうないのか
それぞれの部品は、どこかに存在します。私たちが見つけられなかったのは、その組み合わせです。すべてのリンクが、どう一致を保つかを宣言し、すべての手書きの継ぎ目にガードがある、というルールです。
| ツール | 何をするか | どこが違うか |
|---|---|---|
Google の LINT.IfChange、ifttt-lint、if-changed |
「このブロックが変わったら、あのファイルも変えろ」 | 差分がそのファイルに触れたかを検査する。join は毎回、値を比較する |
| clevis | ファイルをまたいで1つの値を等しく保つ | 集合も、領域も、マップもない |
| Pact、buf、oasdiff | 契約テストとスキーマの互換性 | スキーマがあるときの正解。Crossweft は OpenAPI と proto から、手書きの側のためのガードを作る |
| ArchUnit、dependency-cruiser、import-linter | アーキテクチャのルール | 1つの言語の中だけ |
| archagent | エージェントのためのアーキテクチャの不変条件とスキル | 精神的には最も近い。ただし、言語をまたぐ値ではなく、構造が対象 |
| GitNexus、codegraph | エージェントのためのコードグラフと変更の影響。MCP 経由 | 失敗するチェックではなく、問い合わせるグラフ。互いに補完する |
| fiberplane/drift、Swimm | コードにアンカーされたドキュメント | ハッシュは、どんな編集でも失敗する。アンカーは、事実そのものが消えたときだけ失敗する |
このアプローチも、私たちのものではありません。Birgitta Böckeler は、ハーネスエンジニアリングについて書いています。エージェントがコードを書く前に作用するガイドと、後で検査するセンサーです。また、決定論的なチェックと、言語モデルによるチェックの違いも書いています。OpenAI も、Harness engineering: leveraging Codex in an agent-first world で同じことを書いています。Crossweft は、言語をまたぐ継ぎ目のための決定論的なセンサーで、その出力は、エージェントに何を直すべきかを正確に伝えます。
限界
- パースではなく正規表現。 宣言を整形し直すと、検査ははっきりと失敗します。そのときはパターンを更新します。しかし、広すぎるパターンは、本物の食い違いを依然として見逃すことがあります。抽出された値を見て、実行時の振る舞いは通常のテストで検査してください。
- enforcement の宣言が正直かどうかは、今も人が確認します。 エージェントがマップを作り、Crossweft はエージェントがリンクを捏造したり、フォルダを飛ばしたりするのを許しませんが、あるリンクが本当に
shared-codeなのか、2つのコピーなのかは、開発者だけが知っていることがあります。 - 導入には手間がかかります。 小さなプロジェクトでは、得られるものは小さいです。多くの言語とプロセスがある場所で、報われます。
- 今のところ、利用者は私たちだけです。 Claude Code のループは端から端まで検証済みです。他のエージェントは MCP とスキル経由で接続します。そのフックのアダプターは実験的です。
check --changedはコミット前を速くしますが、編集がルーターやクライアントのファイルに触れる場合は、まだ速くなりません。
試してみる
仕組みを見る最も速い方法は、デモ(Go の API、TypeScript のクライアント、Python のワーカー)を壊してみることです。
pip install "git+https://github.com/AnastasiyaW/[email protected]"
git clone --branch v0.2.8 --depth 1 https://github.com/AnastasiyaW/crossweft
cd crossweft/examples/polyglot-shop && crossweft check # RESULT: PASS
# now change API_VERSION in web/src/api.ts only:
crossweft check # join:api-version disagrees
下の各行は、デモのコピーで確認したものです。検査は終了コード 1 で失敗し、ガードの名前を示します。
| 変更 | 捕まえるガード |
|---|---|
web/src/api.ts だけで API_VERSION を上げる |
join:api-version |
Go の Order 構造体にだけ JSON フィールドを足す |
set:order-fields |
| Python のワーカーにだけステータスを足す | set:worker-statuses |
.env.example にない環境変数を、ワーカーで読む |
set:env-worker-vars |
クライアントから /v1/refunds を呼ぶ |
route:client-unexplained |
server/pricing.go の丸めを変える |
pair:price-rounding |
自分のリポジトリでは、crossweft init(init --example が、動く継ぎ目をまず見せます)を実行し、エージェントにリポジトリをマップ化させます。スキルがその作業を導き、crossweft check が正直さを保ちます。Claude Code では、/plugin marketplace add AnastasiyaW/crossweft の後に /plugin install crossweft@crossweft です。CI では、uses: AnastasiyaW/[email protected]、または GitHub のコードスキャンのために crossweft check --format sarif です。
Crossweft のリポジトリは、自分自身の継ぎ目を Crossweft で守っています。3つのファイルにあるバージョン、フックのイベント、ドキュメントにあるコマンド。README を書いている間、「ドキュメントのコマンドが存在する」というガードが、コマンドだと誤認した散文の行で3回発火しました。それらも直しました。
Crossweft は Apache-2.0 です。issue と pull request は github.com/AnastasiyaW/crossweft で歓迎します。