DSH プラグインを「ローカルでは動く」から「他人が使っても大丈夫」へと押し進めると、本当の分水嶺が現れる。配布方式はビルド成果物がどこから来るかを決め、防御的プログラミングは境界ケースが Agent 全体を落とすかどうかを決め、インシデントの振り返りは同じ穴に二度目に落ちるかどうかを決める。この三つはデプロイ、コーディング、文化という三つの層に分かれているように見えるが、DSH のエンジニアリング体系の中では一本のチェーンになっている:リリース方式を間違えるとユーザー側で穴に落ち、境界バグはリリース後に爆発し、振り返りを書かないチームは同じ欠陥を同じやり方で何度も再発見することになる。本稿は「DSH プラグインの公開 + 防御的プログラミング + インシデント振り返り:プラグインを信頼できる資産にする」の前半である。まず三つの配布経路のビルド成果物と認可の違いを徹底的に説明し、次に prepare スクリプトのビルドチェーンと直交する結果報告の防御的な書き方をフィールドごとに分解し、最後に振り返りの四つの問いと「どんなバグが振り返りを書くに値するか」のしきい値判断に入る。この前半を読み終えれば、あなたは一つの問いに答えられるはずだ:なぜ 178 個のグリーンなユニットテストと 100% の行カバレッジを持つプラグインが、エディタが接続した最初の一秒でクラッシュし得るのか。

npm / tarball / Git:三つの配布経路のビルド成果物と認可の違い

ローカルインストールが通ったら、次のステップはプラグインを他人に配布することだ。ここには見落とされがちな前提がある:公開レジストリへの公開は必須ではない。DSH の公式ドキュメントは三つの配布経路——npm 公開、tarball 配布、Git インストール——を示している。それらのユーザー向けインストールコマンドはパラメータが違うだけに見えるが、基盤として届けられるものはまったく異なり、この違いがユーザーの手に届くのがロード可能なコードなのか、ロードに失敗するソースコードなのかを直接決める。

まず三つのインストールコマンドを見る。一つ目は dsh plugin add your-package で、ユーザーはインストール時に npm レジストリからすでに公開済みのパッケージを取得し、そのパッケージにはビルド済みの lib/ ディレクトリ、つまり事前ビルド成果物が含まれている。二つ目は dsh plugin add ./hello-plugin-0.1.0.tgz で、ユーザーが受け取るのはあなたが pnpm pack で作成した圧縮パッケージであり、その中身もまたあなたがパッケージした時点ですでにビルド済みの結果である。三つ目は dsh plugin --profile demo add github:you/hello-plugin で、ここでのソースは GitHub リポジトリであり、pnpm がクローンしてくるのはソースコードであって、ビルド成果物ではないことに注意してほしい。

これら三つの経路はビルド成果物に対する要求がそれぞれ異なり、それらを判断する核心的な問いはこれだ:ユーザー側で実際にあなたの build スクリプトを実行している人がいるのか。npm と tarball の二つの経路では、ユーザーはインストール時にビルド認可を一切必要としない。成果物はあなたが公開した瞬間にすでに固定されているからだ。Git インストールは最も柔軟で——ユーザーは特定のブランチ、特定の commit、特定の fork を直接指定できる——しかしその代償として、ユーザー側で実際にソースコードをコンパイルしなければならず、これが「ビルドスクリプト」というハードルにぶつかる。

示意图
三つの公開経路の比較図:npm 公開、tarball 配布、Git インストールはそれぞれ事前ビルド済みの lib/、pnpm pack パッケージ、ソースコードを届け、それぞれビルド認可に対する要求が異なる。

上記の違いを表にまとめるとより直感的になる。表の最後の列——「ビルド認可が必要かどうか」——に注目してほしい。これこそが Git と他の二つの経路を区別する鍵となる変数である:

方式ユーザーのインストールコマンドインストールされるものビルドの承認が必要か
npm 公開dsh plugin add your-packageビルド済みの lib/ コード不要
tarball 配布dsh plugin add ./hello-plugin-0.1.0.tgzpnpm pack で作成されたパッケージ不要
Git インストールdsh plugin --profile demo add github:you/hello-pluginソースコード(ビルド成果物ではない)必要(pnpm ≥ 10)

なぜ Git の経路では追加でビルドの承認が必要になるのか。根本的な原因は、pnpm がバージョン 10 から依存関係のビルドスクリプトの実行を厳格化したことにあります。Git からパッケージをインストールするとき、pnpm はインストールの過程でそのパッケージのビルドスクリプトを実行する必要があり、こうしたスクリプトはデフォルトで明示的な承認が必要な状態になっています。言い換えれば、npm と tarball が「承認不要」なのは、それらがより高度だからではなく、ユーザーのマシン上でビルドを走らせる必要がまったくないからです——成果物はすでに出来上がっています。Git インストールは「ビルド」という行為をユーザー側に移すため、承認の問題が付随して生じます。

これはプラグイン作者にとって何を意味するのか。ゼロフリクションの配布を望むなら、npm と tarball のほうが気楽な選択です。ユーザーはコマンド一発でインストールしてすぐ使え、ビルドの流れを理解する必要もなく、承認のダイアログに遭遇することもありません。あなたのプラグインが、ソースコードをいじることを厭わず、特定のブランチを追いかけたい開発者向けであるなら、Git インストールの柔軟性はそのコストに見合いますが、prepare スクリプトをしっかり書かなければなりません——それがなければ、TypeScript パッケージは lib/ の出力がない状態で届き、ロードはそのまま失敗します。

Git インストールに prepare が必須な理由:ソースから公開エントリまでのビルド経路

Git インストールが取得するのはソースコードです。この一文の帰結は、聞こえよりも深刻です。インストールの流れの中には、あなたの代わりに build スクリプトを実行してくれる工程が一切ありません。多くの人は「package.json に build コマンドをちゃんと書いたのに、インストール時にどうして効かなかったのか」と考えますが、問題はまさにそこにあります——build は手動で呼び出す必要のあるスクリプトであり、インストールのライフサイクルの一部ではありません。

しかし pnpm は Git インストール後に、特定のスクリプトを確かに実行します。それが prepare です。これこそがプラグイン作者が活用すべきフックです。作者側でまずやるべきことは、prepare スクリプトを用意し、pnpm が Git インストール完了後にソースから公開エントリをビルドできるようにすることです。ユーザーが dsh plugin --profile demo add github:you/hello-plugin を実行すると、pnpm はリポジトリをクローンし、依存関係をインストールし、そして prepare を起動します。あなたのコードが実際にロード可能な形へコンパイルされるのは、まさにこの瞬間です。

ここから、prepare スクリプトの最も重要な設計上の制約が導かれます。それは自己完結していなければなりません。自己完結とは、「開発環境にしかないコンテキスト」で実行されることを前提にしてはならない、という意味です。最も典型的な反例は monorepo のチェックアウトです——あなたの開発リポジトリでは、プラグインパッケージの隣に共有の TypeScript 設定、ルートの workspace 依存、他のパッケージから参照される型定義が置かれているかもしれません。これらはローカルでは何の問題もなく動きます。monorepo がすべてを整えてくれているからです。しかしユーザーが Git からあなたのプラグインをインストールするとき、取得するのはこのパッケージ単体か、完全な monorepo 構造を持たないリポジトリのスナップショットかもしれません。「隣に別のパッケージがある」「ルートに何らかの設定がある」という前提は、ユーザー側ではビルド失敗に変わってしまいます。

prepare にはもう一つの注意点があります。それはインストール時に実行され、公開時ではないということです。つまり、それが直面する環境は不確定であり、ユーザーの Node バージョン、パッケージマネージャーのバージョン、プラットフォームがあなたの開発マシンと異なる可能性があります。したがって、prepare で行うことは少なければ少ないほどよく、「src を公開エントリにトランスパイルする」という一つのことだけを行うべきです。型チェック、lint、テスト、ドキュメント生成といった開発期の動作を詰め込んではいけません。prepare を最小限に抑えることは、ユーザーがインストール段階で踏むかもしれない落とし穴を最小限に抑えることです。

この Git インストールの経路において、作者とユーザーがそれぞれ負う責任をまとめます。

  • 作者側:自己完結した prepare スクリプトを提供し、pnpm が Git インストールを完了した後に自動実行され、ソースコードを公開エントリにビルドする。
  • 作者側:prepare は開発環境にしか存在しないコンテキスト、例えば隣に monorepo のチェックアウトが存在することに依存してはならない。
  • ユーザー側:pnpm ≥ 10 ではビルドスクリプトの承認が必要であり、これは回避できないステップである。
  • 共通の前提:Git 経路が届けるのはビルド成果物ではなくソースコードであるため、「clone できる」ことは「ロードできる」ことと等しくない。

prepare スクリプト実践:専用の tsdown 設定で src/ を直接トランスパイルする

turtle-ui はそのまま参考にできる実用的な例です。その prepare は専用の tsdown 設定を実行し、src/ を直接トランスパイルし、プロジェクト参照も型チェックも行いません。これら三つの設計上の選択はどれも恣意的ではなく、それぞれが先に述べた「自己完結」のリスクを解消しています。

まず「専用設定」について。turtle-ui は公開経路のために別途 tsdown 設定を用意しており、開発時に使うものを再利用していません。開発用設定には、プロジェクト参照、インクリメンタルキャッシュ、型宣言生成など、ローカル開発を高速化するためのものがしばしば含まれており、これらこそが monorepo のコンテキストに最も依存するものです。専用設定はすべての外部依存を削ぎ落とし、「src/ 内の TypeScript をトランスパイルして出力する」という一本の経路だけを残します。

次に「src/ を直接トランスパイルする」ことと「プロジェクト参照を使わない」ことについて。TypeScript のプロジェクト参照(project references)は、参照されるプロジェクトが実際にディスク上に存在し、かつすでにビルドされていることを要求します。monorepo では問題ありませんが、ユーザーのインストールディレクトリでは必ずしも成り立ちません。src/ を直接トランスパイルすれば参照グラフ全体を迂回でき、依存パッケージを事前にビルドする必要もなく、tsconfig の references フィールドにも依存しません。

最後に「型チェックを行わない」ことについて。型チェックは開発期の品質ゲートであり、CI やローカルでのコミット前に完了すべきものであって、ユーザーがプラグインをインストールする経路に置くべきではありません。型チェックは開発フローに任せ、prepare は実行可能な JavaScript を生成することだけを担う。そうすれば、ユーザーの TypeScript バージョンが作者と異なっていても、一つの型エラーでインストール全体が失敗することはありません。

以下はそのまま貼り付けて使える package.json の scripts 断片で、dsh-hello-plugin からのものであり、prepare を専用設定に結び付けています。

{
  "name": "dsh-hello-plugin",
  "scripts": {
    "prepare": "tsdown -c tsdown.publish.ts"
  }
}

この設定はたった1行のスクリプトだが、情報量は非常に多い。tsdown -c tsdown.publish.ts は設定ファイルを明示的に指定しており、tsdown がデフォルトの開発設定を自動的に拾うのを避けている。設定ファイル名にある publish も明確なシグナルであり、この設定がローカル開発ではなく公開パスのために使われることを将来のメンテナに知らせている。開発設定を変更しても、ユーザーがインストールする際のビルド動作を誤って変えてしまうことはない。

このやり方をチェックリストとして定着させると、次のように自己点検できる:

  1. package.json に prepare スクリプトが存在し、専用のビルド設定を指しているか?
  2. この設定は、すでにビルドされた参照プロジェクトに依存するのではなく、src/ を直接トランスパイルしているか?
  3. ディスク上に他のパッケージが存在することを要求しないよう、プロジェクト参照を無効にしているか?
  4. 型チェックを除外し、品質ゲートを開発フローに残しているか?
  5. リポジトリを単独で空のディレクトリに clone し、もう一度インストールを実行したとき、prepare は独立して成功できるか?

最後の自己テストが最も効果的だ:プラグインリポジトリを monorepo 構造のない空のディレクトリに単独で clone し、Git インストールを一度実行する。もし prepare に「隣のあのパッケージ」への依存が隠れていれば、この手順で即座に露呈する。ユーザーに先に見つけてもらうのを待つ必要はない。

直交する結果を独立して報告する:timedOut、signal、exitCode をなぜネストしてはいけないのか

プラグインを配布できるようになったら、次に解決すべきは「普段はテストで出ず、本番に出た途端に問題になる」類の境界バグだ。公式ドキュメントはこれらを「苦労して得られた欠陥カテゴリのルール」としてまとめている——その一つひとつが、実際のリリース、あるいはリリース寸前だった欠陥に由来する。最初で最も重要な防御的パターンは、直交する結果を独立して報告することだ。

直交とは何か?一つの結果は同時に複数の性質を持ちうるが、それらの性質の間には従属関係がない。ドキュメントは非常に良い例を挙げている:プロセスはすでにタイムアウトしているのに、終了シグナルを捕捉したため、終了コード 0 で終わることがある。もしコード内で「終了コードが非ゼロ」だけを失敗の唯一の印として扱ったり、timedOut の報告を exitCode の分岐の中にネストしたりすると、このプロセスは正常な成功と誤判定される。しかし実際には、それはすでにタイムアウト機構によって終了させられているのだ。

示意图
防御的プログラミングの悪い例と良い例の対照図:結果報告、dispose の安定化、資格情報の消去、リンク削除、コールバック分離という5つのパターンについて、誤った書き方と正しい書き方を並べて示している。

これが「直交する結果を独立して報告する」で解決しようとしている問題だ。ドキュメントは明確に述べている:各独立した事実(timedOut、signal、exitCode)は個別に報告されるべきであり、あるフラグの報告を別のフラグの分岐の中にネストしては決してならない。なぜなら、ネストしてしまうと、呼び出し側は事実の完全な視野を失うからだ——外側の分岐が見せることを許したものしか見えず、早期に終了した実行はまさにネスト構造の隙間に落ちてしまう。

なぜこれら3つのフィールドは直交しているのか?一つずつ分析する:

  • timedOut:自分自身でタイムアウトタイマー内に保持し、「あなたが能動的に終了を開始した」ことを表す。これはプロセスが最終的にどう終わるかとは無関係だ。
  • signal:プロセスがどのシグナルで終了したか。タイムアウト後に SIGTERM を送り、プロセスがこのシグナルを処理しなければ、この signal を伴って終了する。
  • exitCode:プロセスの終了コード。重要なのは、SIGTERM を捕捉して優雅な終了を選んだプロセスは 0 を返せることだ——これは意味的には完全に正当だが、今回の実行が「成功」したことを決して意味しない。

この三者が組み合わせて作れる状態空間こそ、ネストした書き方では失われてしまう部分である。次のようないくつかの組み合わせを考えてみよう:

timedOutsignalexitCode実際の意味
falsenull0正常な成功
falsenull非 0正常な失敗
trueSIGTERM0すでにタイムアウトしたが、プロセスがシグナルを捕捉して優雅に終了した(最も誤判定しやすい)
trueSIGTERMnullすでにタイムアウトし、プロセスがシグナルで kill された

3 行目がまさにその古典的な罠である。呼び出し側の判定ロジックが「exitCode === 0 なら成功とみなす」と書かれている場合、この行は成功に分類されてしまう。正しいやり方は timedOut と signal を同時に確認することである。3 つの独立した事実をフラットに返して初めて、呼び出し側は正しい判断をする機会を得られる。それらをネストしてしまえば、あなたが呼び出し側に代わって誤った単純化をしたことになる。これが、ドキュメントがこれらのルールを「ライフサイクル、並行性、サブプロセス、またはクリーンアップのコードを書く前に」一読するよう強調している理由でもある——コーディングスタイルの好みではなく、単純な境界ケースが Agent 全体を落とすのを防ぐためである。

run.ts の防御的な書き方:spawn から close までのフィールド保守チェックリスト

上記の原則を具体的なコードに落とし込むには、packages/my-shell/src/run.ts の実装を参照するとよい。それが行っているのは、サブプロセスを実行し、3 つの独立した事実——timedOut、signal、exitCode——を直交的に報告することである。コード全体は長くないが、各フィールドを保守するタイミングにはそれぞれ意味があり、項目ごとに分解する価値がある。

まずインターフェース定義を見てみよう。RunResult では 3 つの事実がそれぞれ独立したフィールドとして宣言されており、コメントでもそれらが「独立した事実 1/2/3」である地位が明確に示されている:

// ファイルパス:packages/my-shell/src/run.ts
// サブプロセスを実行し、3 つの独立した事実を直交的に報告する:timedOut、signal、exitCode。
import { spawn, type ChildProcess } from 'node:child_process'

export interface RunResult {
  timedOut: boolean            // 独立した事実 1:タイムアウトしたかどうか
  signal: NodeJS.Signals | null // 独立した事実 2:シグナルで終了させられたかどうか
  exitCode: number | null       // 独立した事実 3:終了コード
  stdout: string
  stderr: string
}

export function run(argv: string[], timeoutMs: number): Promise<RunResult> {
  return new Promise((resolve, reject) => {
    const child: ChildProcess = spawn(argv[0], argv.slice(1), {
      stdio: ['ignore', 'pipe', 'pipe'],
    })

    let stdout = ''
    let stderr = ''
    child.stdout.on('data', (d: Buffer) => (stdout += d))
    child.stderr.on('data', (d: Buffer) => (stderr += d))

    // 独立した事実 1 は個別に保守する:タイムアウトはフラグであり、終了コードとは無関係である。
    let timedOut = false
    const timer = setTimeout(() => {
      timedOut = true
      child.kill('SIGTERM') // タイムアウトが終了をトリガーする
    }, timeoutMs)

    child.on('close', (code, signal) => {
      clearTimeout(timer)
      // 3 つのフィールドはそれぞれ独立に返される:プロセスは timedOut=true かつ exitCode=0 になり得る。
      // タイムアウト後に SIGTERM を捕捉し、0 で終了したためである。
      resolve({ timedOut, signal, exitCode: code, stdout, stderr })
    })

    child.on('error', reject)
  })
}

このコードにおける保守ポイントを項目ごとに説明します:

  1. stdio 設定stdio: ['ignore', 'pipe', 'pipe']。stdin は ignore に設定し、子プロセスが親プロセスから入力ストリームを継承して予期しない対話やハングを引き起こすのを避けます;stdout と stderr はどちらも pipe に設定し、これによりそれらが data イベントとして親プロセスに流れ戻り、結果に蓄積されることが可能になります。
  2. stdout / stderr の蓄積:2 つの data イベントで文字列連結を行います。data コールバックが受け取るのは Buffer であり、ここではテンプレート文字列の暗黙的な変換によってテキストを得ている点に注意してください;蓄積はリスナーを登録したのと同じ瞬間に開始しなければならず、そうでなければリスナーより早く到着したデータを取りこぼします。
  3. 独立した timedOut フラグlet timedOut = false は Promise スコープ内で宣言され、exitCode から導出されるものではありません。これがこのパターン全体の核心です——タイムアウトはあなたが能動的に設定する事実であり、プロセスの結末から逆算する推測ではない。コメントにも明確に書かれています:このフラグは終了コードとは無関係です。
  4. タイマーと SIGTERMsetTimeout の満了後、まず timedOut = true を設定し、次に child.kill('SIGTERM') を呼び出します。フラグを先に設定してからシグナルを送ることで、プロセスがシグナルにどう反応しようと、タイムアウトという事実がすでに記録されていることが保証されます。
  5. clearTimeout のタイミングclose コールバックの最初の行で clearTimeout(timer) を実行します。そうしなければ、プロセスが正常終了した後もそのタイマーは計時を続け、最終的に結果がすでに resolve された後に余分な kill を発火させ、さらにはすでに回収されたリソースへの操作を引き起こす可能性があります。
  6. close コールバックにおける resolve フィールドの順序resolve({ timedOut, signal, exitCode: code, stdout, stderr })。3 つの独立した事実が同じオブジェクトにフラットに並べられ、どれも他の分岐の下にネストされていません;close コールバックは code と signal の両方のパラメータを提供し、ユーザーが関心を持つ 2 つの結末の次元にちょうど対応しています。

もう 1 つ見落としやすい箇所があります:child.on('error', reject)spawn 自体の失敗(例えば実行ファイルが存在しない場合)は close ではなく error イベントを通ります。error を監視しなければ、この Promise は永遠にハングし、呼び出し側は何の結果も得られません——これ自体が別の「単純な境界ケースが Agent 全体をダメにする」シナリオです。run.ts はそれを reject として外に出し、失敗を無限待機ではなく明示的なエラーにします。

dispose の停止、資格情報の消去、リンクの削除、コールバックの分離:見落としやすい 4 つのクリーンアップ動作

結果の報告が解決するのは「どう事実をはっきり述べるか」であり、次に解決すべきは「どうリソースをきれいに片付けるか」です。ドキュメントがまとめた 5 つの防御的プログラミングパターンのうち、結果報告以外の 4 つはライフサイクル、並行性、子プロセス、クリーンアップコードを中心に展開します:dispose の停止、資格情報の消去、リンクの削除、コールバックの分離。それらは共通して 1 つの目標に奉仕します——単純な境界ケースが Agent 全体を落とすのを防ぐことです。

まず dispose の停止完了を見る。Plugin がアンロードされたりシステムがシャットダウンしたりするとき、dispose は「通知を出す」だけで済むものではなく、それによって起動されたすべてのものが本当に停止したことを保証しなければならない。確認すべき境界条件には、実行中の子プロセスが終了され、回収を待っているか、タイマーがすべてクリアされているか、リスナーがすべて解除されているか、非同期タスクが宙ぶらりんに放置されず正しくキャンセルされているか、が含まれる。よくある悪い匂いは、dispose がフラグを立てるだけで、進行中の操作の終了を待たないことだ——「停止完了」という言葉が強調しているのは「停止命令を出す」ことではなく、「本当に停止するまで待つ」ことである。

次に 資格情報の消去。Plugin は実行期間中に API key、token、一時的な資格情報に触れる可能性があり、これらの値はライフサイクル終了後もメモリオブジェクト、ログバッファ、デバッグ出力に残っていることがある。確認すべき境界条件には、dispose またはライフサイクルの終点で資格情報を保持するフィールドが明示的にクリアされているか、エラーパスで資格情報も一緒にログへ出力してしまわないか、シリアライズ結果に含めるべきでない内容が含まれないか、が含まれる。資格情報の消去の鍵は「エラーパスでも消去する」ことにある。なぜなら、例外が発生したときこそ、ついでにコンテキスト全体を dump してしまいやすいからだ。

第三に リンクの削除。Plugin は一時ファイル、シンボリックリンク、待ち受けポート、その他の外部から見える参照を作成することがあり、これらはプロセス内オブジェクトではないため、GC が代わりに片付けてくれることはない。確認すべき境界条件には、作成と削除が対になっているか、例外で中断されたときにもまだクリーンアップの機会があるか、削除失敗が黙って飲み込まれず明示的に処理されているか、が含まれる。ここは事故レビュー 0004 の教訓と通じる——「部分的な成功」を「完全な成功」と誤認することは、クリーンアップ処理で最も典型的な誤判断である

第四に コールバックの分離。あなたがフレームワークに登録したコールバックはフレームワークの呼び出しスタック上で実行されるため、あらゆる例外が呼び出し側のフローに影響を与える可能性がある。確認すべき境界条件には、コールバック内で投げられた例外が自身の範囲に限定されているか、コールバックが特定の外部状態の存在を前提としていないか、コールバックが並行してトリガーされたときに可変状態を共有していないか、が含まれる。ドキュメントがこの項目を「並行性」のグループに置いているのは、まさにコールバックがしばしば並行して呼び出されるからであり、共有された可変状態はコールバック分離において最も見落とされやすい地雷である

これら四つの動作をチェックリストに整理して、コーディング前に照合できるようにしよう:

クリーンアップ動作対処するカテゴリ重要な境界条件
dispose の停止完了ライフサイクル子プロセスの終了と回収、タイマーのクリア、リスナーの解除、非同期タスクのキャンセル後に終了を待つ
資格情報の消去クリーンアップコード保持フィールドの明示的なクリア、エラーパスでログを出さない、シリアライズに機密値を含めない
リンクの削除クリーンアップコード作成と削除が対、例外中断でもクリーンアップ機会が残る、削除失敗を黙らせない
コールバックの分離並行性例外が外へ漏れない、外部状態を前提としない、並行トリガーで可変状態を共有しない

これら四つのカテゴリの動作には共通の判断基準がある:問題の出方が「通常のテストでは検出できず、本番に出て初めて爆発する」ものであるなら、それはこのグループに属する。それらは機能実装ではなく、機能がエラーを起こしていないときにもずっと黙って維持している秩序である。ひとたびある境界が破られると、露呈するのはたいてい小さなエラーではなく、Agent 全体が動作を続けられなくなることである。

振り返りの四問:何が壊れたか、仕組みは何か、なぜセーフティネットで止められなかったか、何の防御を追加したか

防御的プログラミングは欠陥を減らせるが、欠陥をなくすことはできない。DSH リポジトリのやり方は、『存在してはいけないのに存在してしまった』bug をすべて事故のポストモーテム(postmortem)として書き残し、テスト・ドキュメント・ルールの層に防護を残すというものである。まずポストモーテムの適用対象を明確にする必要がある。それが記録するのは、実ユーザー、マージ済みの PR、リリース済みのバージョンに現れた bug、つまり存在してはいけない場所に現れた bug である

示意图
事故ポストモーテムの流れとテストピラミッド図:事故の発見から四つの問いによる整理、さらにユニットテスト、カバレッジゲート、実 API e2e、スナップショットという四層の防護へと定着していく経路。

ポストモーテムの価値はその一行の修正コードではなく、四つの問いに答えることにある。ドキュメントに示された四つの問いの構造は以下のとおりで、それぞれの問いには明確な回答対象がある。

  1. 何が壊れたか:短い段落で、忙しい読者が三十秒で要点を把握できるようにする。この問いがポストモーテムの可読性を決め、うまく書けなければ誰も読まない長文になってしまう。
  2. メカニズムは何か:根本原因を平易な言葉で説明し、個人を責めない。この問いが求めているのはメカニズムの層での説明であり、「誰が間違って書いたか」ではないことに注意する。
  3. なぜどの安全網も止められなかったのか:一度きりの書き間違いではなく、テスト・ツール・取り決めの欠落を見つける。この問いは「一つの bug」を「一类の脆弱性」へと引き上げる。
  4. どんな防護を追加したか:テスト、AGENTS.md のルール、ADR によって、同種の bug が次回は明確にエラーを出すようにする。

第三の問いが、このポストモーテム手法全体の重心である。注目に値するのは、なぜ私たちのプロセスがそれを通してしまったのかであり、単にその一行の修正ではない。ポストモーテム 0001 を例にすると、プラグインが export default apply を余分に書き、Loader の unwrapExports が裸の関数を取得して、名前空間上の inject をまるごと落としてしまい、その結果エディタ(Zed)が接続して最初の session/new で cannot get property "agents" without inject を報告した。この問いの答えは「作者の手滑り」ではなく、「178 個のグリーンなユニットテスト + 100% の行カバレッジが揃っていたが、すべてのテストが手動の ctx.plugin(...) でマウントされ、実際の Loader のロード経路を迂回していた」である。そこで追加された防護も、単にその default export を削除することではなく、default export を削除し、key 不要の実際の Loader スモークテストを追加し、ルール「実際の入口経路をテストせよ、行カバレッジは振る舞いカバレッジではない」を定めることだった。

ポストモーテム 0002 も同様に典型的である。作者は disabled: !!js ... を使ってファイルシステムプラグインを条件付きで有効化しようとしたが、Cordis はプラグイン config の内部でのみ JS 式を評価する。disabled 設定項目を直接読むと、それは truthy なオブジェクトとして見えるため、七つのファイルシステムシナリオがレジストリに存在しないツールを呼び出し、UNKNOWN_TOOL を返した。第三の問いの答えは「スナップショットの更新が、決定論的な再生を振る舞いの正しさと取り違えた——それは回帰が安定して再現されることを証明したが、ファイルシステムツールが実際に登録されたことは証明しなかった」である。追加された防護には、明示的なファイルシステム overlay への切り替え、Loader 設定項目メタデータ内の式ノードを拒否する静的設定ガード、構造化された UNKNOWN_TOOL 結果を拒否するスナップショットフレームワークが含まれる。

振り返り 0003 と 0004 はそれぞれ、「Web コンポジションがモデルに現在の GUI、正規 URL、実行モードの識別情報を提供していない」という欠落と、「サンドボックス結果型が表現できるのは部分文字列の集合だけで、Landlock の失敗が終了コード 125 + 1 行の致命的診断を伴わなければならないことを表現できない」という型と情報の層の欠落を指している。4 つのケースを並べると、共通の教訓が 1 つ導き出せる:テストは必ず実際の入口経路を通らなければならない。手動マウント、何でも mock、スナップショット更新を検収とみなす——これらはすべて「ユニットは全部グリーンなのに、製品は壊れている」を可能にしてしまう。

これはまた、DSH の階層的テスト戦略がなぜ 4 層なのかも説明している。各層は、前の層では捉えられない盲点を補っている:

コマンド何を捉えるか
ユニットテストpnpm run testvitest がパッケージ内テストを実行し、境界、エラーパス、イベント順序、並行競合を優先する
カバレッジゲートpnpm run test:coverageファイルごとに 100% カバレッジ。未カバー行は多くの場合、削除すべきデッドコードである
実 API e2epnpm run test:e2eキー付きで実際のプロバイダー API を呼び出す。キーがなければ自動スキップし、keyless CI はグリーンを保つ
スナップショットpnpm run test:snapshot / test:webキーなしの期待出力で対外挙動をカバー。ブラウザスナップショットは Chromium 再生で比較する

リポジトリは DeepSeek 自身のものなので、特別な原則がもう 1 つある:ここでは推論は安い。実際の API テストを惜しんではならない。キーなしテストは基盤となる経路を証明できるだけであり、キー付きで実行して初めて、agent が実際のモデルに接続して正常に動作できることを証明できる。最も価値が高いのはスモークテストである——実際のサンプルを起動し、プロンプトを 1 つ送り、外部世界を確認する——これらは「ユニットテストは全部グリーンなのに、製品は壊れている」という mock では発見できない類の問題を捉えられる。行カバレッジは必要条件であり、決して十分条件ではない。それは行が実行されたことを証明するが、機能が納品時の期待どおりに動作することを証明しない。0001 のケースで 100% カバレッジでも 2 つの統合バグを見逃したことが、何よりの注釈である。これと対になるもう 1 つの原則は、自己申告ではなく外部世界を検証せよである:e2e のアサーションはコマンドを再実行するか、外部からファイルを読み直すべきであり、agent 自身の出力に対するキーワード探索はズルをする agent を通してしまう。未変更のはずのファイルへのアサーションはバイト単位で一致すべきである。

どんな bug なら振り返りを書く価値があるか:隠蔽的、体系的、再発見のコストが高い

振り返りにこれほどの価値があるなら、すべての bug に 1 本書くべきだろうか? 文書の答えは否である。bug が 3 つの条件を同時に満たすときだけ振り返りを書く:隠蔽的、体系的、再発見のコストが高い。この 3 つのしきい値が、振り返りを「流水帳」から「資産」へと引き戻す。

第 1 は隠蔽的であること:仕組みが自明ではなく、注意深いエンジニアでも再導出に苦労する。0002 の disabled: !!js が truthy オブジェクトとして読まれたことや、0004 で無害な landlock-run: partial enforcement 通知と非ゼロ終了コードの組み合わせをサンドボックス障害と誤判定したこと——これらの根本原因はエラーメッセージに直接書かれておらず、経路をたどって逆算しなければはっきり見えない。反対に、変数の綴り間違いや null ポインタはスタックを見れば一目で分かり、仕組みを記録するための振り返りは必要ない。

二つ目は系統性である。エスケープの原因はテスト、ツール、取り決めの隙間にある。このしきい値は最も見落とされやすいが、それこそがポストモーテムが本当に定着させるべきものである。0001 のエスケープ原因は「すべてのテストが実際の Loader を迂回している」、0003 のエスケープ原因は「Web コンポジションがモデルに現在の GUI の識別情報を提供していない」、0004 のエスケープ原因は「テストマトリクスが通知の後に非ゼロの子プロセス終了が続く組み合わせを決して構築しない」——これらはいずれも一度きりの誤記ではなく、品質体系全体のある空白である。一度きりの誤記には系統性がなく、書き留めても防御に変換できない。なぜなら次回犯すのは別の誤記であり、同じ隙間ではないからだ。

三つ目は再発見のコストが高いことである。実際のデバッグ時間を消費し、次回も同じようになる。ある問題が現れるたびに位置を特定し直すのに大量の時間がかかるなら、それを固定化することには明確な見返りがある。これもドキュメントが「ポストモーテムは回顧的な失敗記録である」と強調する理由である——それは未来のデバッガーに奉仕するものであり、過去の一度の過ちを断罪するものではない。

三つのしきい値のほかに、ポストモーテムが何を書かないかも明確にしなければならない。「本来現れるべきでない場所」に属さないバグは書かない。あるバグが開発ブランチに現れ、まだマージされていないなら、それはせいぜい普通のバグ修正であり、個別に記録する必要はない。しきい値の仕組みの本質は、ポストモーテムを希少に保つことである——すべてのポストモーテムが真剣に読み通される価値があるときにのみ、ポストモーテム文化は成り立つ

では、新たに追加された防御は一体どこに落ちるのか。ドキュメントが示す落点はテスト、AGENTS.md のルール、そして ADR である。テストは同種のバグを次回 CI で明確にエラーにさせる。AGENTS.md のルールは制約を協業の取り決めに書き込み、後続の変更者が着手前に見えるようにする。ADR はアーキテクチャ層の決定とその理由を記録する。三者は四つの問いのうち第四の問いと一対一に対応する——もし一篇のポストモーテムを書き終えても新たな防御が何も追加されていないなら、それは単なる感情の発散であり、エンジニアリング資産ではない

この記事の前半に戻ろう。私たちは npm、tarball、Git という三つの配布経路がビルド成果物と認可において根本的に異なる点を分解し、Git インストールがなぜ prepare スクリプトに頼らなければならないのか、そしてこのスクリプトがなぜ自己完結していなければならないのかを説明した。turtle-ui が専用の tsdown 設定で src/ を直接トランスパイルする実践的なやり方を見て、run.ts の spawn から close までのメンテナンスチェックリストをフィールドごとにたどった。最後に事故ポストモーテムの四つの問いの構造と三つのしきい値に入った。後半でさらに深く掘り下げる部分は、ドキュメント規律がどのように「ドキュメントとソースコードのドリフト」を防ぐのか——つまり verify-type-equiv ゲートと「一つの事実に一つの家」という二つの仕組みが具体的にどう機能するのか、そして本当に役立つ三十秒のエグゼクティブサマリーをどう書くかである。

前半で私たちはプラグインをローカルで動かすことから配布可能な資産へと前進させた。npm、tarball、Git という三つのリリース経路にはそれぞれコストがあり、prepare スクリプトは自己完結していなければならない。そうでなければ Git インストールで取得したソースコードは永遠に lib/ を生やせない。しかし配布可能性は第一層にすぎない——プラグインが長期的に信頼できるかどうかを本当に決めるのは、境界条件下での振る舞いと、メンテナーが一つの事故から系統的な防御を抽出する能力があるかどうかである。この節の四つの実際のポストモーテム、四層のテスト戦略、二つのドキュメント規律こそが、dsh リポジトリが「プラグイン即資産」をエンジニアリングの事実として定着させる全過程である。

ポストモーテム 0001:export default が inject を失い、178 個のグリーンテストがなぜ止められなかったか

これは四篇のポストモーテムの中で最も痛烈な一篇である。なぜなら、間違えようがないように見える場所で起きたからだ。何が壊れたか:エディタ(Zed)が dsh の ACP サーバーに接続すると、最初の session/new リクエストが直接 cannot get property "agents" without inject を報告した。このエラーの文言に注目してほしい——あるフィールドが undefined だと言っているのではなく、missing inject の状況で agents にアクセスしたと言っている。つまり、プラグイン名前空間に本来ぶら下がっているべき inject 宣言が、ロード後に丸ごと消えてしまったのである。

仕組みは非常に具体的で、一コマずつ再現する価値がある。プラグインが余分に export default apply を書いていた。通常のモジュールエクスポートでは、これは単にエクスポートが一つ増えただけに見え、害はない。しかし dsh の Loader はプラグインモジュールをロードする際に unwrapExports というステップを通る——その役割は、モジュールのエクスポートオブジェクトから「プラグイン本体」を取り出すことだ。モジュールに default エクスポートが存在する場合、unwrapExports は default を優先して取るため、取得するのはあの裸の関数 apply であり、名前空間メタデータ(inject 宣言を含む)を伴った完全なエクスポートオブジェクトではない。名前空間上の inject はこうして丸ごと捨てられてしまう。

こうして連鎖は次のようになる:Zed が接続 → ACP サーバーが session/new を受信 → agents サービスを解決する必要がある → プラグインが宣言した inject はもはや存在しない → cannot get property "agents" without inject を報告する。プロセス全体を通じて、どの一行のコードも「ロジックを間違って書いた」わけではなく、エラーは完全にエクスポートの形態とローダーの契約との間の不一致に由来する。

なぜセーフティネットは止められなかったのか?これは記事全体の中で上級読者が最も三十秒立ち止まって考えるべき箇所だ:当時リポジトリには 178 個のグリーンなユニットテスト + 100% 行カバレッジがあった。数字は非常に美しいが、それらはすべて手動で ctx.plugin(...) を呼んでプラグインをマウントしてコンテキストを構築していた。手動マウントとは:テスト自身がプラグインオブジェクトを直接 ctx.plugin に渡し、Loader の unwrapExports のステップを飛ばしているということだ。言い換えれば、テストが検証しているのは「プラグインオブジェクトがマウントされた後に正しく動作するか」であり、本番で起きているのは「モジュールが Loader にロードされた後にプラグインオブジェクトがどんな形になるか」である。この二つの間には重要な関数が一つ挟まっており、すべてのテストはその先から走り始めている。

これこそが行カバレッジは振る舞いカバレッジではないという最も鋭い注釈だ:あの 178 個のテストは確かに每一行を実行しており、plugin 本体の每一行も含めて実行していたが、一行のテストも真のエントリーパスを通っていなかった。カバレッジはコードが実行されたことを証明し、振る舞いカバレッジはデリバリーパスが検証されたことを証明する。両者は互いに代替できない。

新たな防御策は三つあり、層を追って厳しくなる:

  • default export を削除——Loader が誤って裸の関数を取る可能性を源から排除し、「エクスポートの形態」という暗黙の契約をハードな制約に変える。
  • key 不要の本物の Loader スモークテストを追加——もはや手動で ctx.plugin を呼ぶのではなく、テストに完全な Loader のロードプロセスを通らせる。このテストは一切の鍵を必要としないため、keyless CI に常駐でき、コストは極めて低く、遮断力は極めて高い。
  • ルール「真のエントリーパスをテストせよ、行カバレッジは振る舞いカバレッジではない」を定着させる——AGENTS.md に書き込み、後から来る者がテストを追加する際にデフォルトでまず「自分は真のエントリーを通ったか」と問うようにする。

プラグインを書く人にとって、この振り返りの直接的な結論はこうだ:あなたのプラグインモジュールに気軽に export default を加えてはいけない;どうしてもデフォルトエクスポートが必要なら、まず Loader の unwrapExports の意味論を確認せよ。より一般的な教訓は:あらゆる「ローダー + モジュール形態」の組み合わせがこの種の落とし穴を隠しうるので、それに対して真のロードパスを通るスモークテストを一本書くことは、ユニットテストを百本補うよりも価値がある。

振り返り 0002:一つのリテラル !!js オブジェクトがいかにしてファイルシステムスナップショットツールを永久に無効化したか

何が壊れたか:ファイルシステムスナップショットツールは7つのファイルシステムシナリオすべてで失敗し、それらはすべてレジストリに存在しないツールを呼び出しており、一律で UNKNOWN_TOOL を返しました。つまり、ツールは「実行に失敗した」のではなく、「そもそも登録されていなかった」のです。

仕組みは何か:著者は設定項目 disabled: !!js ... を使ってファイルシステムプラグインを条件付きで有効化しようとしました。これは YAML でよくあるカスタムタグの書き方で、意図は「この disabled の値は JS 式を評価して決まる」というものです。問題は、Cordis が JS 式を評価するのはプラグイン config の内部だけにある点です——config オブジェクトの解析コンテキスト内でのみ、こうした式ノードは実際にブール値へと実行されます。しかし、誰かがトップレベルの設定項目 disabled を直接読むと、見えるのは truthy なオブジェクト(まだ評価されていない式ノードそのもの)であり、false ではありません。

オブジェクトは truthy なので、「条件付き有効化」は静的に「無効」と判定され、ファイルシステムプラグインは恒久的に無効化されました。その結果、7つのシナリオすべてが未登録ツールにぶつかり、UNKNOWN_TOOL を返しました。

ここには見落とされがちな階層的な事実があり、独立した表で説明する価値があります。同じ disabled フィールドでも、読む位置によって意味がまったく異なります。

読み取り位置見える値真偽判定の結果結果
プラグイン config 内部(Cordis の評価コンテキスト)JS 式評価後のブール値式の実際の結果による条件付き有効化が機能する
トップレベルの disabled 設定項目を直接読む未評価のリテラル !!js オブジェクトtruthyプラグインが静的に無効化され、ツールが登録されない

なぜ安全網が止められなかったのか?スナップショットフレームワークは「決定論的な再生」を「振る舞いの正しさ」と見なしていました。スナップショットテストの論理は、期待される出力を保存し、以降の実行ごとに一致するか比較するというものです。それは確かに「リグレッションが安定して再現される」ことを証明しました——毎回同じ結果が出て、非常に確定的です。しかし「ファイルシステムツールが実際に登録された」ことはまったく証明していません。バグ自体が決定論的(常に UNKNOWN_TOOL)である場合、スナップショットはむしろ誤りをベースラインとして固定化し、一貫性が正しさを覆い隠してしまいます。

新たな防御も同様に3つ追加されました:

  • 明示的なファイルシステム overlay に切り替える——脆い式条件に依存せず、「有効/無効」を明示的な構造として表現する。
  • 静的設定ガードが Loader 設定項目メタデータ内の式ノードを拒否する——設定解析段階でこうした危険な評価ノードを止め、実行時に静かに劣化するのではなく起動時にエラーにする。
  • スナップショットフレームワークが構造化された UNKNOWN_TOOL 結果を拒否する——スナップショットに意味論的ルールを追加する:出力に「ツールが登録されていない」ことを示す UNKNOWN_TOOL のような構造化結果が現れたら、直ちに失敗と判定し、正当なベースラインとして扱うことを許さない。

プラグイン作者への示唆:設定式のスコープは明示的でなければならない。「A で評価される」を「どこでも評価される」と見なしてはいけない。条件付き有効化スイッチについては、埋め込み式よりも明示的な overlay を優先すべきです。また、スナップショットテストに意味論的アサーション(特定の構造化エラーを拒否する)を加えることは、「一貫性」を「正しさ」へ引き上げる低コストな手段です。

振り返り 0003:Web agent はセッションをホストする GUI ではなく、代替サーバーを検収してしまった

何が壊れたか:agent は GUI のソースコードを修正したが、現在のセッションがどの URL に対応し、どのプロセスによってホストされているかを知らなかった——そのため、まったく無関係なサーバーを「検収」し、自信満々に合格を宣言した。

メカニズムは何だったか:プロセス全体にいくつかの連続した誤りがあった。第一段階で、agent は裸の Vite サービスにアクセスし、HTTP 200 を受け取ってそれを成功シグナルとみなした。しかしその 200 が実際に返していたのは白画面だった——HTTP ステータスコードが正しいことはページが正しいことを意味しない。第二段階で、agent は続いて別のポート上の代替 dsh web サーバーを検収しに行き、その代替インスタンスの挙動を、自分が修正すべき GUI の挙動だとみなした。第三段階、これが最も致命的だが、agent は3081 ポートを一度も探査しなかった。そこにこそ、現在のセッションをホストする本当の GUI があった。

三つの動作を並べて見ると、agent は「間違って修正した」のではなく、「対象を間違って認識した」のだ。自分の変更がどのアドレスで検証されるべきかを知らず、そのため手当たり次第に 200 を返すものをつかまえて採点を始めた。

なぜセーフティネットは止められなかったのか?根本原因はアイデンティティ情報の欠如にある。Web コンポジションは、現在の GUI、正規 URL、実行モード(開発/本番)に関するいかなる情報もモデルに提供していなかった。モデルは「自分は誰で、どこで検証されるべきか」という前提を欠いた状態で、ポートスキャン式の推測に頼るしかなかった。第二の問題は、回帰テストが「プロセスタイムアウト」を「高速失敗」として扱っていたことだ——失敗をより早く露見させたいという意図だったが、結果として誤報を生み、本当の問題を覆い隠してしまった。

追加された防御策:

  • ランチャーが正規ループバック URL と実際の実行モードを公開する——環境変数 + プロンプト区間を通じて、「正規 URL」「本番/開発モード」をモデルに明示的に伝え、推測させないようにする。
  • 独立 Vite サービスモードは設定段階で起動を拒否する——agent が裸の Vite を誤って検収対象とみなすことを仕組みとして防ぐ。
  • 階層化された実パステストが CLI、プロンプト、ランタイム事実、ブラウザ HMR をカバーする——「agent が知るべきこと」をテスト可能なアサーションに変える。

この振り返りは Agent エンジニアリングに携わる人にとって極めて価値が高い:ツールの正しさはアイデンティティコンテキストに依存する。モデルが Web アプリケーションを操作する必要がある場合、「正規アドレス + 実行モード」をランタイム事実として注入しなければならない。そうでなければ、ステータスコードのような弱いシグナルで意思決定してしまう。同時に、タイムアウトを失敗の代用にするのはよくあるテストのアンチパターンであり、「遅い」と「誤り」を混同してしまう。

振り返り 0004:Landlock の部分強制通知により子プロセスの失敗が誤分類された

何が壊れたか:古い Landlock ABI のカーネル上で、ripgrep はマッチがない場合に終了コード 1 で正常終了する——これは ripgrep の既定のセマンティクスである(1 はマッチなし、2 がエラー)。しかし dsh はこの正常な「マッチなし」を SANDBOX_UNAVAILABLE サンドボックス障害として提示し、成功した検索を環境崩壊として報告してしまった。

メカニズムは何だったか:ランチャーは古いカーネル上で無害な通知を一行出力する:landlock-run: partial enforcement (older Landlock ABI)。これはカーネルが部分的な強制をサポートしていることを示す。一方、harness の判定ロジックは大文字小文字を区別しない landlock-run: の部分文字列マッチを使用し、この部分文字列と「任意の非ゼロ終了コード」を組み合わせて判断していた——出力にこのプレフィックスが現れ、同時にプロセスが非ゼロコードで終了すれば、runner が失敗したと判定する。ripgrep のマッチなし終了コード 1 はちょうどこの組み合わせを満たしてしまい、そのためサンドボックス利用不可として誤分類された。

なぜセーフティネットは止められなかったのか?サンドボックスの結果型は部分文字列の集合しか表現できず、より精密な契約——「Landlock の失敗は終了コード 125 + 1 行の致命的診断を同時に満たさなければならない」——を表現できない。テストマトリクスも「通知の後に非ゼロの子プロセス終了が続く」という組み合わせを決して構築しない。すべてのテストは通知をテストするか、失敗終了をテストするかのどちらかで、両者を一緒に置くことは一度もなかった。ギャップは、組み合わせシナリオを誰もカバーしていない点にある。

修正の核心は RunnerFailureRule であり、「どのような状況が runner の失敗と見なされるか」を曖昧な部分文字列ではなく、構造化されたフィールドの集合として表現する:

フィールド意味解決する問題
許可された終了コードホワイトリスト化された終了コードの集合(例:Landlock の失敗は 125 でなければならない)「任意の非ゼロコードが失敗と見なされる」という広範なマッチングを排除する
行ごとの致命的シグネチャ行ごとにマッチしなければならない致命的診断テキスト無害な通知行と真に致命的な行を区別する
正確に除外された情報行情報的であり判定に関与しない行(partial enforcement 通知など)を明示的に列挙する「通知の後に非ゼロ終了が続く」ケースが誤判定されなくなる

同時に、ファイルシステム検索は ctx.subprocess でパッケージ化された ripgrep を直接実行する方式に切り替え、サンドボックス化された bash を経由しなくなった——「サンドボックス判定」と「検索実行」を分離し、経路上でこの種の誤分類を回避する。

4 つの振り返りをまとめると、共通の教訓が 1 つ抽出できる:テストは実際のエントリパスを通らなければならない。手動マウント、すべてのモック化、スナップショット更新を検収と見なすことは、いずれも「ユニットはすべてグリーンなのに製品は壊れている」を可能にする。0001 はロードパスが迂回されたこと、0002 はスナップショットが決定性を正しさと見なしたこと、0003 はアイデンティティコンテキストの欠如 + タイムアウトが失敗を装ったこと、0004 は組み合わせシナリオを誰もカバーしていなかったこと——4 つの顔、1 つの病根。

テストの 4 層:pnpm run test / test:coverage / test:e2e / test:snapshot がそれぞれ何を捉えるか

病根がわかれば、階層化戦略で塞ぐしかない。dsh リポジトリのテストは階層化されており、各層が前の層では捉えられない盲点を専門に補う——単なる積み重ねではなく、分業である。

コマンド捉えるもの捉えないもの
ユニットテストpnpm run testvitest がパッケージ内テストを実行し、境界、エラーパス、イベント順序、並行競合を優先する実際のロードパス、実際の API 挙動
カバレッジゲートpnpm run test:coverageファイルごとに 100% カバレッジ。未カバー行は多くの場合、削除すべきデッドコードである振る舞いの正しさ——行が実行される ≠ 機能が期待通りに動作する
実 API e2epnpm run test:e2eキーを使って実際のプロバイダー API を呼び出し、agent が実際のモデルに接続できることを検証するキーのない環境では自動的にスキップされる
スナップショットpnpm run test:snapshot / test:webキーなしの期待出力が対外挙動をカバーする。ブラウザスナップショットは Chromium リプレイで比較する意味的な正しさ——UNKNOWN_TOOL のような構造化エラーを拒否する追加ルールが必要

ユニットテストは最初の網であり、最も書き間違えやすいものでもある。その重点は「すべての行を実行する」ことではなく、境界、エラーパス、イベント順序、並行競合を優先的にカバーすることにある——これらこそが、普段はテストで炙り出せず、リリースした途端に問題になるカテゴリだ。ユニットテストを書くときは、「自分は最も意地悪な入力順序を構築しているか」と自問するほうが、「何行カバーしたか」と問うよりもはるかに価値がある。

カバレッジゲートはファイルごとに100%を要求する。ここには見落とされがちな副産物がある:カバーされていない行は、多くの場合、削除すべきデッドコードである。もし100%ゲートによってある行のためにテストを書かざるを得なくなり、その振る舞いがいつ発生するのか想像できないなら、その行自体が存在すべきでない可能性が高い。カバレッジは「冗長性を発見する」ためのツールであり、単なるKPIではない。

実API e2eにはキーが必要で、キーがない場合は自動的にスキップされ、それによってキーレスCIをグリーンに保つ——これがオープンソース/マルチクラウド環境で常駐できる前提である。しかしこのリポジトリには、自分のプロジェクトに取り入れる価値のある特別な原則がある:ここでは推論は安いので、実APIテストを惜しんではならない。なぜなら、キーなしのテストは基盤となる経路が通っていることしか証明できず、キーありで実行して初めて、agentが実際のモデルに接続して正常に動作することを証明できるからだ。

スナップショット層は、キーなしの期待出力で対外挙動をカバーし、ブラウザスナップショットはChromiumのリプレイで比較する。その価値は「対外契約の予期せぬドリフト」を捉えることにあるが、振り返り0002で示されているように、意味レベルの拒否ルールと組み合わせなければ、誤りをベースラインとして固定化してしまう。

すべての層の上で、最も価値が高いのはスモークテストである:実際のサンプルを起動し、プロンプトを1つ送り、外部世界を確認する。これらは「ユニットテストはすべてグリーンなのに製品は壊れている」という、mockでは決して発見できない類の問題を捉えることができる。もう一度その結論を強調しよう:行カバレッジは必要条件であり、決して十分条件ではない。それは行が実行されたことを証明するが、機能が納品期待どおりに動作することを証明しない。0001の178個のグリーン+100%カバレッジがなお2つの統合バグを見逃したことが、その最良の脚注である。

自己申告ではなく外部世界を検証する:e2eアサーションとバイト単位の比較

階層化だけでは十分ではない。アサーションの書き方もまた成否を決める。ここには鉄則がある:自己申告ではなく、外部世界を検証せよ。

自己申告とは何か?それはe2eが終わった後、agent自身の出力テキストを読み、「成功」「完了」といったキーワードが含まれているかどうかを確認することだ。この手法には致命的な穴がある:カンニングする(あるいは単に過信している)agentは、出力に正しいキーワードを書くだけでテストに合格できてしまう。テストは実際には「うまく言うこと」を報酬として与えており、「正しく行うこと」ではない。さらに隠蔽性が高いのは、agentがカンニングしなくても、その自己記述が実際の状態と一致しない可能性があることだ——振り返り0003でagentが誤ったサーバーを検収しながら合格を宣言したのは、典型的な「自己申告と外部の真実の乖離」である。

正しいやり方は次のとおり:

  • e2eアサーションはコマンドを再実行するか、外部からファイルを再読み取りすべき——agentが言ったことを信頼せず、独立に観測可能な状態のみを信頼する。
  • 未変更のファイルはバイト単位で一致することをアサートする——「おおよそ変わっていない」でも「内容を含む」でもなく、byte-for-byteで同一であること。これは「ついでにフォーマットした」「うっかり行末を変えた」といった隠れた副作用を捉えることができる。
  • 成否は自然言語ではなく構造化された結果で判定する。UNKNOWN_TOOLのような構造化シグナルは、モデルに記述させるのではなく、テストフレームワークが直接失敗として認識すべきである。

この原則を振り返り 0001 と 0004 とつなげて見てみよう。0001 の病はテストが偽の入口を通っていたこと(自己申告式のマウント)であり、0004 の病は判定ロジックが広すぎたこと(部分文字列マッチング)だった。それらの解毒剤は同じ方向を指している——プロセス内部の自己申告や曖昧なマッチングではなく、独立に検証可能な外部事実で受け入れること。外部副作用(ファイル内容、プロセス終了コードの正確な契約、URL の実際のレスポンスボディ)をアサートするほうが、内部状態をアサートするよりもごまかしにくく、ユーザーの知覚にもより近い。

ドキュメント規律:verify-type-equiv ゲートと一つの事実に一つの家

コードの外では、ドキュメントは二番目に腐敗しやすい資産である。dsh リポジトリのドキュメントは「書いたら終わり」ではなく、ドキュメントとソースコードのドリフトを防ぐ仕組みを持っている。

最初のゲートは verify-type-equiv と呼ばれる。その動作はかなり硬派だ。TypeScript パーサーでソースコードから型宣言のシンボルと、それらの宣言に付随する JSDoc を抽出し、ドキュメント内のコードブロックがその両方に同時に一致することをアサートする。つまり、ドキュメントに貼り付けられた型定義は手書きのテキストではなく、ゲートによって検証されたミラーである。記録済みの型宣言やその JSDoc を変更すると、ドキュメント内の貼り付け内容を同期して更新するまでゲートは失敗する。これにより、「ドキュメントが古い版を写している」という最もよくあるドリフトを根絶できる。

第二の規律は一つの事実に一つの家と呼ばれる。各事実は一つのファイルでのみ維持し、他のファイルはそれを参照する。具体的にはツール schema について、その「真源」は adding-a-tool.md にあり、他のページはコピーせず参照する。この利点は、一箇所を変えればどこでも一貫し、三箇所にコピーして二箇所を変えると嘘をつき始めることだ。

中国語と英語のドキュメントはバイリンガルペアリングで維持され、更新時には明確な順序がある。まず pnpm run gen-doc-graphs を実行して英語を更新し、次に中国語を更新してペアリングを検証する。順序は逆にできない。グラフ生成は英語を基準にしているからだ。

この規律を自分のプロジェクトに移してみよう。唯一の事実を一箇所に置き、同じ設定を三つのドキュメントに散らばせないこと。ある設定項目が複数箇所で記述されていることに気づいたら、すぐに一つの真源を指定し、他の位置は参照に変える。さらに、型関連のドキュメントも書いているなら、「ドキュメント内の型スニペットはパーサーによってソースコードと等価であると検証できなければならない」をゲートにすると、効果は期待をはるかに超えるだろう。

2026 年 9 月の最新実践:振り返りの四問、直交レポート、prepare の自己完結を CI ゲートに書き込む

振り返り文化は「ドキュメントを書くこと」と最も誤解されやすい。実際には、振り返りは新しい防御機構を生み出したときに初めて完全となる。dsh リポジトリの四問は非常に明確に問いかけている。

振り返りの四問何に答えるか
何が壊れたか短い段落で、忙しい読者が三十秒で要点を吸収できるようにする
仕組みは何か根本原因を平易な言葉で説明し、個人を責めない
なぜどの安全網も止められなかったのかテスト、ツール、取り決めの欠陥を見つける。一度きりの誤記ではない
何の防御を追加したかテスト、AGENTS.md のルール、ADR。同種の bug が次回明確にエラーを出すようにする

最初の問いにある三十秒に注目してほしい。これは修辞ではない。忙しい読者は30秒以内に概要を理解する必要がある。だから要約の公式は何が壊れたか → 平易な言葉で根本原因 → なぜ逃げ切れたか → 長期的に活かせる教訓である。同時に、すべての bug が振り返りを書く価値があるわけではなく、三つの条件を同時に満たすものだけを書く:隠蔽性(仕組みが自明でなく、注意深いエンジニアでも再導出に苦労する)、系統性(逃げ切れた原因がテスト・ツール・取り決めの隙間にある)、再発見のコストが高い(実際のデバッグ時間を消費し、次回も同じことが起きる)。このしきい値が、振り返りを「作業ログ」から「アーカイブに値する資産」へと変える。

2026年9月になると、振り返りを本当に複利化させる実践は、その結論をCI チェック項目と AGENTS.md のルールに固定化することであり、ドキュメントに残して人の自覚に頼ることではない。以下の AGENTS.md の抜粋は、そのままあなたのリポジトリにコピーして CI と組み合わせて使える:

# AGENTS.md(抜粋):再利用可能なハードルール

## テスト
- 新規プラグインには必ず、**実際の Loader ロード経路を通る**スモークテストを添付すること。
  手動の ctx.plugin(...) だけでロード挙動をカバーすることは禁止。
- 単体テストは優先的にカバーする:境界値、エラーパス、イベント順序、並行競合。
- カバレッジはファイルごとに 100%。トリガーシナリオを書けない行は、削除対象のデッドコードとみなす。
- e2e アサーションは**外部世界**を検証すること:コマンドを再実行するか、外部からファイルを読み直す。
  agent 自身の出力に対するキーワード探索は禁止。
- 未変更のファイルをアサートする場合、バイト単位で一致(byte-for-byte)していること。
  「含む」や「ほぼ同じ」は許されない。

## 結果報告
- 子プロセスの結果は**直交して独立に報告**すること:timedOut / signal / exitCode
  それぞれに一つのフィールドを持たせ、いずれかのフラグの報告を別のフラグの分岐にネストすることは禁止。

## リリース
- prepare スクリプトは**自己完結**であること:monorepo checkout に依存してはならず、
  プロジェクト参照を使ってはならず、開発環境にしかないコンテキストに依存してはならない。
- Git インストール経路は、クリーンな環境でソースからリリースエントリをビルドできること。

## 設定
- Loader 設定項目のメタデータで式ノード(!!js など)を使うことは禁止。
  条件付き有効化には明示的な overlay を使う。
- サンドボックス失敗判定には構造化された RunnerFailureRule を使うこと:
  許可された終了コード + 行ごとの致命的シグネチャ + 正確に除外された情報行。

これに対応する CI チェック項目(例示。あなたのパイプライン構文に書き換えてよい):

# CI パイプラインの主要ステップ(疑似 YAML。実際のプラットフォームに合わせて書き換える)
steps:
  - name: unit
    run: pnpm run test

  - name: coverage-gate
    run: pnpm run test:coverage   # ファイルごとに 100%。未カバーはデッドコードとみなす

  - name: real-api-e2e
    run: pnpm run test:e2e        # キーがなければ自動スキップ。keyless CI はグリーンを維持
    env:
      DSH_API_KEY: ${{ secrets.DSH_API_KEY }}

  - name: snapshot
    run: pnpm run test:snapshot   # UNKNOWN_TOOL などの構造化エラーのベースライン入りを拒否

  - name: web-snapshot
    run: pnpm run test:web        # Chromium リプレイ比較

  - name: doc-graph
    run: pnpm run gen-doc-graphs  # まず英語ドキュメントの図を更新

  - name: verify-type-equiv
    run: pnpm run verify-type-equiv  # ドキュメントの型ブロックはソースシンボル + JSDoc と等価でなければならない

この3つをCIに書き込めば、4種類の欠陥は「人が覚えておくもの」から「通らなければエラーになるもの」へと変わる:

  1. 実際のエントリパスをテストする——0001のdefault exportがinjectを落としたとき、もし当時Loaderを通るスモークテストがあれば、Zedが接続する前に赤くなっていたはずだ。
  2. 直交する結果を独立して報告する——timedOut、signal、exitCodeはそれぞれ独立して返されるため、呼び出し側が「タイムアウト後にSIGTERMを捕捉し、終了コード0で終了した」を正常な成功と誤判定することはない。子プロセスはtimedOut=trueかつexitCode=0になり得るので、この2つの事実はどちらも見える形でなければならない。
  3. prepareは自己完結でなければならない——Gitインストールが取得するのはビルド成果物ではなくソースコードであり、あなたのbuildスクリプトを自動で実行する工程はどこにもない。もしprepareが隣のmonorepoに依存していれば、ユーザー側が受け取るのはlib/のないTypeScriptパッケージであり、読み込みはそのまま失敗する。

こうなれば、振り返りはもはや「事後の追悼」ではなく、「一度の事故を一連の防御に変える」という正の循環になる。本当に価値のある問いは常にこれだ:このbugの価値はあの一行の修正ではなく、なぜプロセスがそれを見逃したのか、そして同種の問題が次回明確にエラーになるためにどんな防御が追加されたのかにある。

図版については、この段落はちょうどリポジトリが示す事故振り返りフローとテストピラミッド図を中心に展開している:「事故の発見 → 四つの問いによる帰因 → 防御の追加」と「ユニット / カバレッジ / e2e / スナップショット」のピラミッド階層を同じ図に置くことで、防御をどの層に加えるべきかを直感的に示している。

まとめとベストプラクティス

記事全体(公開 + 防御的プログラミング + 事故振り返り)を実行可能なチェックリストに圧縮する。これはプラグイン作者向けの受け入れ表であると同時に、メンテナ向けの日々の規律でもある。

公開と配布:

  • 3つの配布経路を必要に応じて選ぶ:npmとtarballは事前ビルド成果物を配布するため、ユーザー側にビルド権限は不要;Gitインストールはソースコードを取得するため、ビルド権限が必要(pnpm ≥ 10)。
  • Gitインストールを使う場合、プラグインはprepareスクリプトを提供し、pnpmがインストール後にソースから公開エントリをビルドできるようにしなければならない。
  • prepareは自己完結でなければならない:周囲にmonorepo checkoutがあると仮定してはならず、プロジェクト参照を使わず、型チェックも行わず、src/を直接トランスパイルする。参考例:"prepare": "tsdown -c tsdown.publish.ts"
  • 配布方式を間違えれば必ずユーザー側で問題が起きる:ユーザーに追加の権限設定をさせたり、ソースコードを成果物として使ったりするのは、どちらも予測可能な失敗である。

防御的プログラミングの5パターン:

  • 結果の直交報告:timedOut、signal、exitCodeをそれぞれ独立したフィールドにし、1つの事実に1つのフィールド、決してネストして報告しない。
  • disposeは安定して停止する:クリーンアップは安定して停止するまで待つ。片側のリソースだけ先に解放してはならない。
  • 資格情報の消去:使い終わったらすぐ消去し、残留を残さない。
  • リンク削除:削除時にリンクの意味論を処理し、誤削除やダングリングを避ける。
  • コールバックの分離:1つのコールバックの例外がAgent全体を道連れにしてはならない。全体目標はただ一言:単純な境界ケースでAgent全体を落とさないこと。

振り返り文化:

  • 四つの問いで定式化する:何が壊れたか / 仕組みは何か / なぜすべての安全網が止められなかったのか / どのような防御を追加したか。
  • 隠蔽的、系統的、再発見のコストが高いという三つの条件が同時に満たされる場合にのみ、振り返りを書く価値がある。
  • 振り返りは検証可能な防御を生み出さなければならない:一つのテスト、一つの AGENTS.md ルール、一つの ADR。
  • 検証済みの四つの教訓:余分な export default を付けない(0001)。設定式のスコープに依存しない(0002)。Agent に規範 URL と実行モードを注入する(0003)。サンドボックス失敗判定には部分文字列ではなく構造化ルールを使う(0004)。

テストと検証:

  • 四層の分業:ユニットは境界/エラー/順序/競合を捉える。カバレッジはファイルごとに 100% とし、ついでにデッドコードを発見する。キー付き e2e は実際のモデル接続を証明する。キーなしのスナップショットと Chromium リプレイは外部へのドリフトを捉える。
  • keyless CI をグリーンに保つ前提は、e2e がキー欠如時に自動的にスキップすることである。
  • 外部世界を検証し、自己報告に頼らない:e2e はコマンドを再実行するか、外部からファイルを再読み込みする。agent 自身の出力に対してキーワード探索を行わない。未変更ファイルはバイト単位で一致する。
  • 記憶ポイント:行カバレッジは必要条件であり、決して十分条件ではない。

ドキュメントと長期的な保守:

  • verify-type-equiv ゲートを使い、ドキュメント内の型ブロックがソースシンボル + JSDoc と等価であることを保証し、変更があればエラーを出す。
  • 一つの事実に一つの家:ツール schema の真のソースは adding-a-tool.md にあり、他のページはコピーせず参照する。
  • バイリンガルペアの保守順序:まず pnpm run gen-doc-graphs を実行して英語を更新し、次に中国語を更新してペアを検証する。

最後に、シリーズ全体を貫くあの言葉に戻る:モデルは賢さを担い、Harness は信頼性を担う。制約は制限ではなく、Agent を予測可能で、監査可能で、再生可能にする基盤である。すべてはプラグインである——ポリシーをループに書き込むのではなく拡張ポイントに置くことで、システムは制御を失わずに進化できる。実際のエントリパスはあらゆるモックに勝る——カバレッジ、スナップショット、スモークテストがそれぞれの役割を果たし、共に「全部グリーンなのに壊れている」を防ぐ。そして障害は恐ろしくない、恐ろしいのはなぜか分からないことだ:振り返り文化は一つの事故を一連の防御に変え、今日のプラグインを、明日安心して依存できる資産にする。