Skip to content

Latest commit

 

History

History
1330 lines (1013 loc) · 88.6 KB

decisions.md

File metadata and controls

1330 lines (1013 loc) · 88.6 KB

決定と理由 (ADR)

decopin-cli の設計で「なぜそうしたか」だけを置く。挙動そのものは書かない — 挙動は実装とテストが答えで、ここに書くと必ずずれる。

  • 使い方 → README.md
  • 挙動の約束 → test/contract/ (表はそこにしか無い)
  • 個々の API の理由 → 公開シンボルの JSDoc

理由は「決めた時点の事実」なので腐らない。同じ議論を最初からやり直さないために残す。


ADR 1: 自作の軽量レンダラーを使う (Ink は使わない)

JSX を再帰的に ANSI 文字列へ変換するだけで足りる。Ink は yoga-wasm を含み起動が 100ms を超え、CLI としては過剰。静的な出力に reconciler は要らない。

代償: 対話的 UI (プロンプト・スピナー) は自前で作ることになる。v1 では扱わない。

関連: src/core/renderer/。パイプラインは 評価 → レイアウト → 直列化 → 書き出し の 4 段。

ADR 2: argv と stdin をファイルで分ける

argv.tsx と stdin.tsx を別ファイルにする。stdin は argv と性質が違う:

  • 読むと入力が来るまでブロックする (端末で実行するとフリーズする)
  • 実行時に有無が変わる (パイプ / リダイレクト / 端末)
  • 一度しか読めない

「このコマンドは stdin を読む」という宣言を必須にし、宣言がないコマンドは stdin に 一切触らない。ファイルの有無でそれを表すのが、規約ベースでは一番分かりやすい。

ADR 3: ファイル名は POSIX 用語で統一する

argv.tsx / stdin.tsx / cmd.tsx (stdout) / error.tsx (stderr) が シェルの入出力と 1:1 対応する。シェルを学びながら書く人にとって、 「今書いているファイルはシェルのどの口か」が名前で分かる利点が大きい。

params.tsx のような一般語は、環境変数や設定ファイルの値も含みうる曖昧さがある。

ADR 4: v1 は静的出力のみ

装飾 (色・枠・表) の表現力に投資し、対話性は後回しにする。1 回描画して終了する 出力に絞ると、パイプ・リダイレクトとの相性も良い。

その後: 出力側の動的な領域は ADR 22 で導入した。対話的な入力は ADR 36 で choose() を 1 つだけ入れた。守り続けるのは「端末とだけ対話する」こと: パイプや MCP から呼ばれたら問い掛けず、使い方の誤りとして引数で渡す道を示す。

ADR 5: ビルド時にルートを生成する

app/ を走査して route manifest とエントリを生成し、単一の dist/index.js にする。 起動が最速で、配布も 1 ファイルで済む。実行時にディレクトリを走査しない。

ADR 6: バリデーションは valibot

軽量 (zod の約 1/10) で tree-shaking が効き、起動時間に有利。Standard Schema 準拠。

ADR 7: layout.tsx を導入する

上位ディレクトリの layout が子コマンドの出力を包む。共通のテーマやヘッダを 一箇所で定義できる。Next.js とのアナロジーが完成する。

ADR 8: --help は宣言から自動生成する

argv.tsx / stdin.tsx の宣言を唯一の情報源にする。スキーマと help の 二重管理が起きない。足りない場合だけ help.tsx で上書きする。

コマンド一覧にも既定値を出す (2026-09-04 に追加)。<Argv> の description の後ろに、default を持つ引数とオプションを (default: name="world", --loud=false) の形で添える。一覧は「どれを打つか」 だけでなく「打たないと何が起きるか」を選ぶ場面でもあり、--help まで降りないと 既定値が分からないのは暗黙の振る舞いになる。hidden なオプションは出さない。 summarizeSpec() が組み立て、test/features/conventions/help/help.test.tsx が固定する。

ADR 9: 型はビルド時の codegen で配る

JSX 式は型引数を運べない。 TypeScript 7.0.2 で実測した:

declare function Option<N extends string, T>(p: {
  name: N;
  type: T;
}): Node<{ name: N; value: T }>;
const a = <Option name="loud" type={true as boolean} />;
//    ^? any (JSX.Element 未宣言時) / JSX.Element (宣言時) — 型引数は消える

自作 jsx-runtime で jsx() factory の戻り値をジェネリックにしても変わらない。 props 方式でも children 方式でも同じなので、JSX で型を運ぶ設計は選択肢にならない。

代わりに Next.js の typed routes と同じく、ビルド時に .decopin/types.d.ts を生成し、 module augmentation で Routes / EnvVars を埋める。cmd.tsx は CmdProps<'hello'> で引く。

代償: decopin dev を回していないと型が古い。未生成のときは緩い型に フォールバックするが、「古い」と「未生成」は区別できていない。

ADR 10: schema は Type.* の children で組み、valibot は露出しない

利用者が valibot の書き方を覚えなくて済むようにする。Type.Array のように 型が再帰する場合に children の入れ子が自然に効く。valibot への依存は src/core/validation/ に閉じ込める。

valibot に変換層を寄せなかった理由 (実測):

試したこと 結果
v.pipe(v.string(), v.transform(Number), v.number()) に "3" ✅ 3 になる
v.optional(schema, 1) に undefined ❌ Invalid type: Expected string but received 1 — 既定値も wrapped schema を通る
"abc" のエラーメッセージ ❌ Invalid type: Expected number but received NaN
v.check の message 関数で元の値を出す ❌ received null (元の文字列が残らない)

決定的なのは 2 つ目。利用者は default={1} と書くのが自然なのに、内部が文字列 パイプになると数値の既定値が弾かれる。だから 変換 → 検証の 2 段構えを維持する。 --flag の存在・--no-flag・繰り返しによる配列化も argv の性質であって型の話ではない。

エスケープハッチ: <Stdin mode="json" schema={...}> だけ生の valibot を受ける。 深い JSON を Type.Object / Type.Field で書くと 3 階層で 9 行になるため。 argv / env には用意しない (型を失う道を増やさない)。

ADR 11: argv の検証は middleware より前

middleware が検証済みの args / options を受け取れる方が実用上の価値が大きい (--verbose による分岐など)。

代償: env / argv の検証エラーは middleware を通らない。middleware での ロギングや計測は「コマンドが実際に実行される場合のみ」走る。

一方 stdin の読み取りは middleware の内側に置く。next() を呼ばずに 打ち切れば標準入力を消費しないで終われる。

ADR 12: 配布は単一ファイル + 「評価のみ」遅延

bun build は動的 import を同一ファイル内の遅延評価ラッパーにインライン化するため、 「実行されないコマンドを読み込まない」ことは実現できない。遅延されるのは モジュール本体の評価のみで、トップレベルのパースは全量走る。

真の遅延読込には --splitting (複数ファイル配布) が必要だが、v1 は配布の単純さを取る。

実測 (Apple Silicon / Bun 1.4、bun run bench):

  • 総時間 12〜14ms。うち bun 自体のプロセス起動が 4.7ms、decopin の取り分が 7.7〜8.9ms
  • コマンド数の影響は小さい。1 コマンドだけの app でも取り分は 6〜8ms
  • --minify の効果は 0.4ms 程度。パースは律速でない
  • valibot の初期化 +1.8ms、renderer 一式 +2.6ms (ソースからの import を個別に測ったもの)
  • valibot を遅延 import する実験は、計測の揺れ (±1.5ms) に埋もれて差が出なかったので採用していない

目標の「10ms 未満」は decopin の取り分としては達成、総時間としては未達。

Bun 1.4.2 への更新 (2026-09-07、issue #52):

  • CI / bench / release は 1.4.2 に統一。@types/bun は更新時点で公開済みの 最新 ^1.4.1 にする (1.4.2 は未公開)。固有 API を追加していないため engines.bun は >=1.4.0 のまま。
  • 1.4.1 の compile ローダー高速化と bytecode 縮小は既存の配布手順で利用できる。issue の同条件比較では compile + bytecode が 11.9 → 9.1ms、runtime floor を引いた取り分が 7.5 → 5.4ms。これは当時の 7 コマンドの実測。
  • 1.4.2 は 1.4.1 で入った bundler の var / let 等の名前衝突を修正しているため、更新先に選ぶ。 AsyncLocalStorage のメモリリーク修正もあるが、decopin 自体は使用していない。
  • 今回の macOS arm64 / Homebrew 1.4.2 / e35f0e0 の 12 コマンドでは、CI と同じ hyperfine --shell=none --warmup 20 --runs 50、NO_COLOR=1 で compile + bytecode が 19.0 ± 1.1ms、compile のみが 22.2 ± 1.3ms。 floor は 9.8 ± 3.7ms で外れ値あり。過去の数字との直接比較や速度向上の 判定には使わない。再現手順とサイズは Startup cost に記録する。
  • --bytecode-depth=1 は 19.5 ± 1.8ms、削減量は約 0.15 MB だけ。 issue の 1.4.1 比較でも 9.1 → 10.1ms と遅くなったため採用しない。
  • export * as / dynamic import の tree-shaking は利用者が zod / Effect 等を バンドルする場合の候補。decopin の valibot は flat export で、最小 CLI の minify は 80,594 B と issue の 1.4.1 測定と同じ。大幅な縮小は確認できない。 --splitting / minChunkSize / splitRequire は単一ファイル配布のため採用しない。
  • bun install --offline / --prefer-offline は依存キャッシュを CI に導入する時に 再検討する。bun test --isolate のメモリ・環境分離改善は分離実行が必要に なった時の候補。現状のテスト実行方式は維持する。
  • --env-file の pipe/FIFO 対応は CLI 利用者が秘密管理ツールと連携する際の候補。 stdin を使うコマンドとの併用では process substitution を使う。 import attributes の型付けは TypeScript 7.1 以降かつ当該構文を使う時に検討する。

ADR 13: middleware は children ではなく next を受け取る

layout.tsx の children は React と同じ「値」で、置くだけで済む。対して middleware は「コマンドの実行を包んで、終わったあとに何かする」ためのものなので、 呼ぶまで走らない関数でなければ成立しない (時間を測る・後片付けをする・ 例外を捕まえる、が全て next() を呼び終えた後にしかできない)。

同じ children という名前で「値」と「関数」の 2 種類を使い分けさせると覚えることが 増えるだけなので、React の語彙を借りず Koa / Hono / Express と同じ next にする。

ADR 14: 外に出るものは英語、内側の記録は日本語

対象 言語
CLI の出力 (--help、エラー文、decopin build の出力) 英語
生成されるファイル (.decopin/) のヘッダーとコメント 英語
README 英語
コード内のコメント / このファイル / テスト名 日本語

境界は利用者の手元に届くかどうか。公開したときにそのまま使えることを優先する。 生成物は利用者のリポジトリに残るし、README は npm で最初に読まれる。

内側の記録を日本語のままにしているのは、書く速さと精度が保守の質に直結するため。 英日の併記はしない (2 つの文書は必ずずれる)。

ADR 15: 仕様書を持たず、契約はテストに置く

以前は docs/SPEC.md (1266 行) に挙動の表・props 一覧・ファイル規約の詳細を 書いていたが、書いてあるのに実装されていない機能が 9 件発生した。仕様を書く側と 実装する側が別のタイミングで動くと、動かないドキュメントは必ずずれる。

そこで役割を 3 つに分けた:

中身 置き場所 崩れない理由
挙動の約束 test/contract/ の表駆動テスト 実行される
使い方 README.md 例をテストで実行する
決定と理由 このファイル 決めた時点の事実なので腐らない
API 個別の理由 公開シンボルの JSDoc 実装の隣にある

この方針で捨てたもの: 節番号による横断参照 (§4.8 など)。代わりに ADR 番号と テストファイルのパスで参照する。

ADR 16: 決定を守る仕組みをテストに置く

ADR 15 で「契約はテスト、理由はここ」と決めたが、このファイル自体が 形骸化するという失敗の仕方が残る。性質の違う 2 つをテストで守る。

守りたいこと 手段
決定が破られていない test/docs/decisions.test.ts
参照が切れていない (ADR 番号・テストのパス) test/docs/references.test.ts

エディタや CLI のフックには置かない。 Claude Code のセッション中しか効かず、 人が直接編集したときや CI では働かないため。テストなら全員に効き、ずっと残る。

ADR には守り方を必ず付ける

test/docs/decisions.test.ts は ADR 番号ごとに守り方を持つ。

  • lint — このファイルの中で形を検査する (valibot の閉じ込め、メッセージの言語など)
  • test — 別のテストが担保している (そのファイルの実在も確認する)
  • manual — 機械では守れない。理由を書く

ADR を足すと「分類されていない」で落ちる。 守り方を決めるまで通らないので、 「決めたが誰も守っていない ADR」が生まれない。逆に ADR を消すと「分類が孤立して いる」で落ちる。

落ちたときに直すのはコードとは限らない

決定そのものが変わったのなら、直すのは decisions.md。テストが「決定を 見直す」きっかけになるのが狙いで、それが ADR を生きた文書に保つ。

ADR 17: 公開は「別ディレクトリを組み立てて」出す

npm に出すのは publish/ に組み立てたもので、リポジトリの package.json は 開発用 (exports が src/*.ts を指す) のまま触らない。

なぜ組み立てるか: 開発中は自己参照 (decopin-cli) が src/ を指していないと、 テストが古いビルド結果を見てしまう。かといって exports を lib/ に向けると 開発のたびにビルドが要る。publishConfig で exports / bin を差し替える案は npm 11 で効かないことを実測で確認した (npm pack したマニフェストが元のまま)。

中身は tsc が出す JS + .d.ts。ファイル構成のまま出し、バンドルしない (利用者が一部だけ import したときに全部を読み込ませないため)。

それでも Bun は必須。Bun.build / Bun.stdin を呼んでいるので、JS にしても Node では動かない。engines.bun で明示する。そもそも生成される CLI 自体が #!/usr/bin/env bun なので、Bun 前提であることに矛盾はない。

認証は Trusted Publishing (OIDC)。長期の NPM_TOKEN を secrets に置かない。 --provenance で来歴も付く。タグと package.json の version が食い違ったら 公開前に落とす (公開してからでは取り消せないため)。

ADR 18: バージョンは時刻そのもの (YYYY.MMdd.HHmm、UTC)

SemVer をやめて時刻から決める。例: 2026.828.1430 (2026-08-28 14:30 UTC)。

なぜ: 番号を決めるのに何も調べなくてよい。時刻だけの関数なので、 レジストリへの問い合わせも、前回の番号の記憶も要らない。単調に増え、衝突もしない。

なぜ 4 つ組 (yyyy.MM.dd.HHmm) にしないか: npm がそれを 2026.8.2-8.1430 というプレリリースとして解釈する (実測)。エラーにならず、 通常のインストールで拾われなくなる。semver は数値 3 つと決まっているので、 月日と時分をそれぞれ 1 つに畳む。

先頭ゼロを付けない。2026.0828.0 も npm が黙って書き換える。 0905 ではなく 905、0000 ではなく 0。

UTC で読む。手元と CI で番号がぶれないため。時刻は Date ではなく Temporal.ZonedDateTime で受け取る (Bun 1.4 / TS の lib: esnext 実装済み)。 どの時間帯で読むかが値そのものに入るので、getUTC* を呼び忘れても ローカル時刻に落ちる、という間違え方ができない。

代償: ^ と ~ の意味が変わる。^2026.828.1430 は年内なら何でも、 ~2026.828.1430 はその日のうちなら何でも許す。利用者には固定を勧める必要があり、 README に書いてある。読みやすさも落ちる (2026.828.1430 は一見して日付に見えない)。

同じ分に 2 回出すと番号が衝突するが、npm が重複を拒否するので事故にはならない。

ADR 19: 日付は Type.Instant と Type.PlainDate に分ける

Type.Date (実行時 Date) を廃止し、瞬間 (Temporal.Instant) と暦日 (Temporal.PlainDate) を別の型にした。

なぜ: Temporal には Date のように両方の書き方を飲み込む型が無い (実測)。

入力 Temporal.Instant Temporal.PlainDate
2026-08-28T14:30:00Z 通る 例外
2026-08-28 例外 通る

どちらか一方を Type.Date に割り当てると、もう一方の入力が黙って表現できなく なる。Type.* はもともと「型を明示的に宣言する系統」なので、利用者にどちらの 意味かを言ってもらう方が筋が通る。境目は test/contract/coercion.test.ts に 表で固定してある。

副産物: new Date("nope") は Invalid Date を返して先に進んでしまうが、 Temporal は例外を投げる。読めない値が「読めたつもり」で通らない。 min / max もビルド時 (src/features/conventions/argv/parse.ts) に解釈して弾く。

valibot 1.1.0 に Temporal 対応は無い。v.instance() で受け、境界は v.minValue ではなくその型の compare で見る (Temporal の値を < で 比べると TypeError になる。実測)。

ADR 20: 破壊的変更は、非推奨にしてから 1 年後に削除する

いきなり消さない。後方互換を保ったまま非推奨にし、移行の手順を示し、 1 年後に削除する。

なぜ: CalVer の番号は日付なので、互換性を伝えられない (ADR 18)。 ^ を付けている利用者は、番号を見ても壊れることに気づけない。 番号で伝えられない分、時間で埋め合わせる。

忘れないための仕組みが要る。「あとで消す」は忘れるので、期限を src/core/deprecations.ts にデータとして置き、test/docs/deprecations.test.ts が 見張る。期限を過ぎるとテストが落ち、消すまで CI が通らない。 期限を延ばすのは決め直しなので、この ADR を書き換えることになる。

知らせ方は 2 経路。使う側にはビルド時の警告 (checkDeprecations) で 移行手順と期限を伝え、リリース時には GitHub Release の本文 (scripts/release-notes.ts) に破壊的変更と非推奨を出す。 gh release create --generate-notes は使わない。--notes-file と併用した ときにどちらが残るかが保証されておらず、破壊的変更が黙って落ちうるため。

最初の適用先は Type.Date (ADR 19)。2026-08-29 に非推奨、 2027-08-29 以降に削除する。

ADR 21: 補完の候補は CLI 自身が返す

zsh の補完は 2 つの部品で実現する。ビルドが生成する薄いシム (dist/completions/_<bin>) と、ランタイムの予約コマンド __complete (src/features/conventions/complete/runtime.ts)。Tab のたびにシムが <bin> __complete -- <words...> を実行し、CLI が候補を stdout に 1 行 1 つ (値<TAB>説明) で返す。最後の語が「補完中の語」。

なぜ問い合わせ方式か: 候補を補完ファイルに焼き込むと、コマンドを 足すたびにファイルが変わり、zsh の compdump キャッシュを無効化する きれいな手段がない。シムを「聞きに行くだけ」にすれば、ビルドし直した CLI が次の Tab からそのまま反映される。ただし反映は bundle の更新が条件: decopin build か、build を回し続ける decopin dev (ADR 43) の後に映る (シム自体は不変)。候補の情報源は argv.tsx ただ 1 つで、--help と同じ (ADR 8)。enum は値まで補完でき、hidden は出さない。

コマンド名は bin のキーから取る: help に出す program は package.json の name だが、利用者が実際に打つのは bin のキー (このリポジトリなら name は decopin-cli、コマンドは decopin)。補完はコマンド名に付くので、 シムのファイル名と中身は bin から解決する (resolveBinaryName)。

入口の判定は -- まで見る: __complete の 1 語目だけで判定すると、 ルートコマンドが __complete という文字列を位置引数に取れなくなる。 シムが必ず付ける 2 語目の -- まで一致したときだけ補完として扱う。

語の解釈はトークナイザを共有する: 単独の - (位置引数)、--name=value、 束ねた alias (-lv)、--no-flag の扱いを補完側で再実装すると必ずずれる。 確定済みの語は src/features/conventions/argv/tokens.ts の tokenize に渡し、 「補完中の語がどの位置に落ちるか」は実入力に現れない印 (\0) を差し込んで 同じトークナイザに聞く (probe 方式)。

名前の衝突は起きない: scanner は _ 始まりのディレクトリを共有コードの 置き場として無視する (ADR 3) ので、app/__complete/ がルートになることはない。 予約名の検査は要らない。

補完は決して落とさない: 宣言が壊れていても Tab のたびにエラーが出るのは 最悪なので、__complete は何があっても exit 0。候補が無ければ黙って空を返し、 シェル側がファイル補完に落ちる。

代償: Tab のたびにプロセスが 1 回起動する (起動は 12〜14ms、ADR 12)。 体感で問題になったら、構造 (コマンド名・オプション名) だけ静的に焼いて 値だけ問い合わせるハイブリッドにする余地を残す。

ADR 22: 動的な出力は AsyncIterable 駆動の島に限る

Next.js の PPR と同じ向きで、静的がデフォルト、動的はオプトイン。 この方式を Partial Repainting (PPR) と呼ぶ。頭字語は Next.js の Partial Prerendering から借りるが、CLI にはビルド時レンダーが無く、島は 一度埋めるのではなく何度も描き直すので、P は Repainting。README や リリースノートでは "Prerendering" とは書かない。 <Dynamic source={asyncIterable}>{(value) => JSX}</Dynamic> だけが時間で 書き換わり、それ以外は従来どおり静的に流れる。ドキュメントは上から順に flush され (ストリーミング)、島に当たったら source が尽きるまで領域を 描き換え、最後のフレームを残して続きが流れる。駆動は present() (src/core/renderer/present.ts)。

なぜ hooks ではないか: useState 方式はレンダラーに再実行スケジューラを 要求し、コンポーネントが「呼ばれて JSX を返すだけの純粋なデータ」(ADR 1) でなくなる。時間を「値の列」として渡せば、source はただの async generator なので単体テストは generator を呼ぶだけで済む。

島は stderr: UNIX の慣習どおり、パイプに流れるデータは stdout、 進捗のような一時的な演出は stderr (curl / cargo と同じ)。 cli deploy | tee log でエスケープ列がログに混ざらない。

TTY でないときはフレームを描かない: パイプ・CI では途中のフレームを 出さず、source 完了時の最終フレームだけを 1 回書く。

置ける場所はドキュメントの直下だけ: 測ってから組むコンテナ (<Box> / <Columns> / <Indent>) や <Line> の中の領域は消す行数の計算が壊れるので、 layout に到達した <Dynamic> は誤りとして落とす。

ADR 1 の精緻化: 「fd ごとに 1 回 write」は「静的チャンクごとに 1 回」に 広がる。各チャンクは改行で終わるので、行が割れる事故は起きない。 島の無いドキュメントは従来どおり 1 回書きのまま。

レビュー (PR #16) で固めた個々の契約 (評価の順序、repaint のライフサイクル、 シグナルでの後始末など) は、文章ではなく test/core/renderer/present.test.tsx に 置いてある (ADR 15)。破ればテストが気づかせてくれる。

代償と割り切り: リサイズ (SIGWINCH) は折り返しの変化で前の領域を正確に 消せないことがある (現フレームの描き直しだけ行う)。島の実行中に子プロセスが 直接 stderr に書くと領域が崩れる — 子プロセスの出力の取り回しは将来の課題。 永遠に待つ source を repaint 失敗で中断した場合、generator の finally は 走らない可能性がある (return を待たないため)。

ADR 23: アニメーションは時刻ではなく描き直しの回数で進める

<Spinner> のコマは Date.now() ではなく、layout に渡る tick (何回目の描き直しか) で決まる。tick を増やすのは present() で、 <Dynamic> の描き直しのたびに 1 つ進む。

なぜ: コンポーネントが時刻を読むと、同じ入力から同じ出力が出なくなる。 decopin のコンポーネントは「呼ばれて JSX を返すだけの純粋なデータ」(ADR 1) なので、そこに時計を持ち込むとスナップショットのテストが書けなくなり、 --help のような静的な出力まで実行のたびに変わりうる。 tick なら出力は入力だけで決まり、アニメーションは repaint ループの 副産物として落ちてくる (interval を付ければコマが進む)。

副産物: 静的な出力の中の <Spinner> は最初のコマで止まる。 「動いていないスピナー」は紙の上では正しい絵なので、これでよい。

<ProgressBar> は最初から純粋なので tick を使わない。ただし端の扱いだけ 決めてある: 0 は空、満了だけが全部埋まり、途中は必ず 1 桁以上・width 未満。 1% で「始まった」ことが、99% で「まだ終わっていない」ことが見えるため。

ADR 24: 起動時間は PR ごとに測り、base と同じジョブで比べる

README に貼った数字は貼った瞬間から古くなる。alias の代わりを名乗る以上 起動時間は看板なので、.github/workflows/bench.yml が PR ごとに測り直し、 結果を PR のコメントに貼る (毎回足さず 1 つを書き換える)。

同じジョブで base も測る。CI のランナーは速さが揃わないので、PR 間で 絶対値を比べても意味がない。base と PR を同じランナーで続けて測れば、 機械の差は引き算で消える。base のビルドに失敗したときは比較を諦めて PR 側の数字だけ出す (計測が壊れて何も見えなくなる方が困る)。

見出しの数字は「取り分」: 合計から空の Bun バイナリ (ランタイムの固定費) を引いた分。合計の 11ms のうち 4.7ms は Bun が起動するだけの分で、 そこは decopin の責任ではない。

ゲートにしない。閾値を置くと、ランナーのばらつきで落ちるテストになり、 やがて誰も見なくなる。数字を出して読み方 (数 % はノイズ) を添えるに留める。

組み立ては純粋な関数に置く (scripts/bench-report.ts)。退行の見え方も base 欠損時の振る舞いも、ランナーの上で動かさずにテストできる。

バンドルサイズも同じジョブで測る。対象は test/fixtures/hello-app (1 コマンド、<Line> だけ) を --minify したバイト数。ユーザーのコードが ほぼ無いので、ここの増減はフレームワーク側の増減そのもの。起動時間と違って バイト数は決定的なので、差が出たらノイズではない。それでもゲートにはしない (まず数字が見える状態を作る。閾値は必要になってから)。base 側は PR の fixture を持ち込んでビルドするので、fixture を足した最初の PR でも比べられる。

ADR 25: データは data.tsx で、表示と分ける

入力は宣言済み (argv / stdin / env) なのに出力は暗黙、という非対称を解く。 data.tsx がデータだけを作り (Remix の loader 相当)、cmd.tsx は data を props で受けて表示に専念する。

型は TypeScript の推論を借りる。argv と違って data.tsx の中身は ビルド時に評価できない (I/O を伴う)。そこで生成する型に Awaited<ReturnType<typeof import('../app/x/data.tsx').default>> を書き、 戻り値の型が cmd.tsx の props にそのまま届くようにした。 TS Compiler API は要らない (JSON Schema の生成には必要になる。それは別の話)。

--json は予約オプション。data.tsx があるコマンドで付けると、 view を呼ばずにデータをそのまま構造化して出す。<Json> に渡すだけなので、 端末では色が付き、パイプでは付かない (既存の色判定に乗る)。 data.tsx の無いコマンドで付けたら exit 2 で「置き場所」を教える。

パイプで自動的に JSON にはしない。「非 TTY なら view を捨ててデータを 出す」案は捨てた。cli user list | grep alice のような既存のパイプが 全部壊れるため。色を落とすのは表示の調整だが、出力の形式を変えるのは 別物で、これは明示的に頼まれたときだけやる。

--json の予約は破壊的変更。<Option name="json"> を宣言していた CLI はビルドが通らなくなる。非推奨の猶予を置けない類 (予約は足した瞬間に 効く) なので、ADR 20 の経路のうちリリースノートで知らせる方を使う。 移行はオプション名の変更だけ。

ADR 26: パイプを壊さないことは、覚えるのではなく掃いて確かめる

cli stats | grep README が通ることは CLI の一番外側の約束なのに、機能を 足すたびに壊しうる。しかも壊し方はいつも同じ形をしている: 出力の形式を 暗黙に変える (非 TTY なら JSON にする)、装飾を stdout に出す、画面制御を stdout に混ぜる。

そこで機能ごとに「今回はパイプを壊していないか」を思い出すのをやめ、 test/contract/pipe-safety.test.tsx が app/ の全コマンドを掃く。 守る不変条件は 4 つ:

  1. stdout は端末かどうかで変わらない (色 = SGR を除いて)
  2. stdout に画面制御 (カーソル移動・消去・表示切替) が出ない
  3. 非 TTY の stdout に ESC が 1 バイトも残らない
  4. stdout は改行で終わる (空なら空)

表に載せるのは義務。新しいコマンドを足すと網羅の検査が落ちるので、 「このコマンドはパイプでどう見えるべきか」を決めるまで通らない (ADR 16 で ADR に守り方を強制しているのと同じ形)。

色だけは変わってよい。色を落とすのは表示の調整で、形式の変更ではない。 不変条件 1 が SGR を除いて比べているのはそのため。ここを「完全一致」に すると、正しい挙動が落ちる。

効くことを確認済み: 非 TTY で自動 JSON 化する変更を仕込むと 1 件、 <Dynamic> の描画先を stdout に変えると 14 件落ちた。

ADR 27: --json に出せるかは、型ではなく実行前に見る

JSON.stringify は黙って壊す。関数と undefined は消え、Map / Set は {} になり、NaN は null になる。投げるのは bigint だけ。Date / Temporal は文字列になるので、宣言した型と JSON の中身が食い違う。 どれも「出してみたら欠けていた」で気づく類なので、--json の直前に見て どの経路が悪いかを名指しして止める (src/features/conventions/data/serializable.ts)。

型検査には頼らない。当初は生成した types.d.ts に AssertJsonData<CheckJsonData<...>> を書いて型エラーにする実装を入れたが、 skipLibCheck: true があると黙って消える (実測)。この設定はよくあるので、 利用者の tsconfig に依存する検査は約束にならない。型の計算自体は正しく 動いていたのに、エラーが報告されないという最悪の壊れ方をした。

取りこぼしが無い方を選ぶ。実行前の検査は、data が使われていなくても --json を打てば必ず通る。型側は data を使っている箇所でしか効かない。 JsonValue 型は公開してあるので、satisfies JsonValue と自分で書きたい人は 書ける (強制はしない)。

「skipLibCheck を切ればいい」で済まない。3 つ測った結果:

  1. このリポジトリで切ると、@types/node と bun-types の衝突で 直せないエラーが 6 件出る (時間は 0.145s → 0.290s で問題にならない)
  2. そもそも生成物が置かれるのは利用者のリポジトリで、そこの tsconfig は こちらが決められない。.d.ts に埋める限り約束にならない
  3. 生成物を .ts にすれば skipLibCheck は効かなくなる (実測)。ただし 制約に JsonValue を使うと interface が索引シグネチャを持たないため 正常な interface Report { count: number } まで弾く。キーを 1 つずつ辿る IsJsonSerializable なら interface も通るが、今度はエラー文に どのプロパティが悪いか出ない。両立させようとすると TS が 「循環した制約」か「既定値を検証できない」で止まる

型で正確にやろうとするほど、偽陽性か不親切な文面のどちらかに倒れる。

偽陽性の本当の害は、黙らされること。正しいコードが弾かれた利用者は as never や @ts-expect-error で通す。その抑制は偽陽性より長生きし、 そこを通る本物の問題まで隠す。検査が無い状態より悪くなる。 実行前検査は偽陽性が出ないので、黙らせる動機がそもそも生まれない。

1 件で止める。直す順番は上から下に決まっていることが多いので、 最初に見つけた経路だけを出す。

素の object だけを通す: プロトタイプが Object.prototype か null の ものだけ。クラスのインスタンスを弾くのは、toJSON があってもなくても 「型に書いてある形とは違うものが出る」ため。

ADR 28: output.tsx があれば、それが出力の正になる

data.tsx の戻り値の形を Type.* で宣言できるようにした。あれば その宣言が正で、2 つのことが起きる:

  • cmd.tsx の data の型は、推論ではなく宣言から作る
  • 実行時に data を検証し、外れたら表示にも --json にも流さない

自分のコードなのに検証するのはなぜか。data.tsx が await res.json() as User[] のように外から来た値を返すとき、TypeScript は 書いたとおりに信じるだけで、実物が違っても気づけない。境界で確かめるのは parse-don't-validate の要領で、ここが唯一の境界になる。

宣言が無ければ縛らない。output.tsx を置いていないコマンドは 今までどおり推論で型が付き、検証もされない。全部に強制すると <Type.Object> の記述量 (3 階層で 9 行) が割に合わない場面が出る。

schema prop の逃げ道は stdin と同じ形にした (ADR 9 の前例)。 込み入った形は valibot を直接渡せる。Type.* を主にするのは ADR 10 のとおりだが、逃げ道が無いと表現力で詰まる。

JSON Schema はまだ出さない。TypeNode から JSON Schema への変換は toTypeText と同じ形の switch で書けるが、読む相手 (MCP 輸出) がまだ 無い。使われない生成物は朽ちるので、輸出を作るときに一緒に足す。

ADR 29: --json を頼まれたら、失敗も構造化して返す

--json は「機械が読む」という宣言なので、成功だけ JSON にして失敗を 人間向けの文面で返すとパーサが壊れる。--json が付いていれば、失敗は stderr に { "error": { code, message, exitCode, issues? } } で出す。

error.tsx は通さない。あれは人が読むための表示なので、JSON を 求められた場面では飛ばす。cmd.tsx を飛ばすのと同じ理由。

stdout は空のまま。失敗時に stdout へ書かないのは元からの約束で、 test/contract/pipe-safety.test.tsx が失敗する --json も掃いて固定した。

code は分類、message は文面。機械が分岐する先を文面にすると、 言い回しを直した瞬間に相手が壊れる。いまの code は枠組み側の分類 (validation / runtime / stdin / env / unknown) で、 利用者が自分のコードを宣言する仕組みは次の段。

exitCode も入れる。パイプで受けた側はシェルの $? を見られない ことがある (cmd | jq では jq の終了コードが返る) ので、本文にも入れておく。

sysexits.h (EX_SOFTWARE = 70 など) は採らない。BSD 由来で普及も 一様ではなく、いまの 1 = 実行時の失敗 / 2 = 使い方の誤りの方が 現代の CLI の慣習に近い。番号を変えるのは利用者の if [ $? -eq 1 ] を 黙って壊すので、得るものが無い限りやらない。

ADR 30: 型の一覧を宣言させるより、よくある形を用意する

エラー契約の検討で、最初は「想定できる失敗の union を宣言させる」方向 (Effect の E チャネル / Java の checked exception) を考えた。調べた結果、 そちらは採らない。

言語側の結論: 二層に分ける (想定できる / できない) こと自体は Java・Rust・Effect・Zig・Go が全部同じで異論が無い。割れるのは 一覧を誰が書くかで、Java の checked exception は書く負担で崩れた (throws Exception / 空の catch / 包み直し)。Kotlin と Scala は捨て、 C# は採らなかった。typed throws を新たに入れた Swift 6 でさえ、 公式の指針は「untyped がほとんどのコードで良い既定のまま」「将来変えたく ない契約に自分を縛る」「case を足すのは source-breaking」である。

CLI では網羅性が強制できないという非対称もある。呼び出し側はシェル スクリプトやエージェントなので、コードを足してもコンパイルは壊れず、 実行時に未処理の分岐に落ちるだけ。つまり宣言の見返りが小さい。

代わりにやること: 利用者がライブラリに期待するのは型システムではなく **「よくある形が既に正しく用意されている」**こと。Next.js の notFound() が その形なので、そこに寄せた。

  • notFound() — どこからでも投げれば一番近い not-found.tsx が表示される。 available を渡せば「もしかして」は自動で付く。終了コードも決まる
  • not-found.tsx を継承可能にした (error.tsx と同じ近い順)。 コロケーションが効くのがこの形の要
  • help() — そのままでは進めないときに、このコマンドの使い方を見せて 打ち切る。--help と同じものを組み立てるが、求められて出すのでは ないので stderr + exit 2 (ルーティングの表と揃う)。理由の一行を message で先に出せる
  • <DidYouMean> と closest() を公開した。編集距離は枠組みが未知の コマンドに使っていたのに、利用者からは呼べなかった
  • 一覧を宣言していない失敗は今までどおり unknown に落ちる (ADR 29)。 thiserror (公開面は精密) と anyhow (その下は不透明) の使い分けと同じ

notFound() は失敗ではない。「探したものが無い」は正常な結末なので、 終了コードは既定 1 (使い方の誤りの 2 ではない)。未知のコマンドだけは 利用者の打ち間違いなので 2 のまま。

props で見分ける。what が 'command' か、notFound({ what: 'user' }) で 渡した値か。1 つの not-found.tsx が両方に使えるよう、requested / available / suggestion は共通の形にした。

ADR 31: 「環境が整っていない」は、直し方まで含めて 1 つの形にする

認証が切れている・必要なコマンドが入っていない、は CLI が必ず通る道で、 どちらも同じ骨格を持つ: 何が足りないか + 打てば直るコマンド。 authRequired() と missingTool() を用意した (ADR 30 の続き)。

新しい機構は足さない。notFound() / help() と違って、これは本物の 失敗なので error.tsx の経路にそのまま乗せる。やったのは 3 つだけ:

  • ErrorKind に auth / missing-tool を足した → error.tsx が場合分けでき、 ADR 29 の --json の code にもそのまま出る
  • CliError に hints を足した → 既定表示では message の下に並ぶ
  • --json のペイロードにも hints を出す

hints を機械にも渡すのが要。人が読むなら「gh auth login を打て」で 済むが、エージェントは次の一手を決めるために同じ情報が要る。 code: "auth" で「認証だ」と分かり、hints で「何をすれば直るか」が分かる。

終了コードは 1 のまま。認証切れ専用の番号を作りたくなるが、ADR 29 で 決めたとおり番号は変えない。分岐先は code であって番号ではない。

独自の error.tsx が hints を捨てうるのは避けられない (表示は利用者の もの)。せめて例アプリの global-error.tsx で「直し方は落とさない」書き方を 実演しておく。組み込みの既定表示は最初から出す。

ADR 32: 副作用は申告させず、ビルド時に数える

コマンド 1 つの入口が規約で決まっていて、ビルド時に閉じた世界を作る — この構造があるから、そのコマンドから到達しうる sink を機械が数えられる。 自己申告 (Effect の Requirements 的なやり方) だと宣言と実装がずれるが、 数えるならずれようがない。

判定は三値。none / detected / unknown。狙いは none、つまり 「無いことの証明」。eval や解決できない import に当たったら unknown に 落として、none を名乗らない。健全性を精度より優先する。

モジュールグラフの粒度から始める (ロードマップの第一弾スコープ)。 関数粒度の解析はしない。ただし import { readFile } from 'node:fs' の 名前までは見る — ここを見ないと fs を触る全コマンドが読み書き両方 detected になり、判定が意味を失う。名前空間 import は絞れないので両方採る。

fact が要る。中身を歩かずに「こう」と決めてよいものの表。最初の登録は 枠組み自身で、これが無いと全コマンドが process.mutate: detected になった (実測)。decopin-cli の中の process.exit は終了コードを返す仕組みであって、 コマンドの副作用ではない。

実際に動かして 3 つ直した:

  • Bun.Transpiler は tsconfig を読まないので、JSX 変換が react の import を勝手に足す。書いた人の依存ではないので数えない
  • Bun.resolveSync は絶対パスのディレクトリを要求する。相対のまま渡すと 素の相対 import まで「解決できない」= unknown に落ちた
  • Bun.$\ls`` を「バックティックが続くこと」で見ていたが、文字列を落とす 前処理でバックティックごと消えていて検出できていなかった。参照だけを見る

fact は 3 種類に増えた (2026-09 の実測から):

  • 枠組み自身 (decopin-cli)
  • Bun が自前で持つモジュール (node-fetch / undici / ws / bun:*)。 Bun.resolveSync が指定子をそのまま返してファイルに解決しないので歩けない。 放っておくと network のパッケージが none を名乗った (実測で見つけた穴)。 表に無い bun: は unknown
  • 歩くと unknown に落ちる有名パッケージ (axios / fs-extra / zx / lodash-es / yaml は中に require(変数) を持つ)。歩けば分かるものは載せない。 判定は公開された役割から書き、サブパス import も同じ判定にする

遅延 import() は解析の境界にしない。静的な指定子なら普通の import と同じく 追う。実行が届きうる先の副作用は、遅延させても届く。ロードマップの 「チャンク境界=解析境界」案は健全でないので採らない。

まず出す先はビルドの出力。MCP のヒントに変換するのはロードマップ通りだが、 読む相手 (MCP 輸出) がまだ無い。使われない生成物は朽ちるので、輸出を作るときに 一緒に足す (ADR 28 と同じ判断)。

ADR 33: MCP は宣言の輸出であって、新しい宣言ではない

<bin> __mcp で stdio の MCP サーバになる。ツールの定義に新しく書くものは無い: argv.tsx が inputSchema、output.tsx が outputSchema、data.tsx の戻り値が structuredContent、ビルド時の副作用の判定 (ADR 32) が annotations。 宣言から help・補完・型を導くのと同じ出口が 1 つ増えただけで、 宣言と MCP の定義がずれる余地がない。

公式 SDK は使わない。実測で 4.3MB・依存 17 個 (express, hono, zod, ajv ...)。 stdio で要るのは改行区切りの JSON-RPC と initialize / ping / tools/list / tools/call の 4 つで、依存 1 つで起動を ms で測るこの構造と釣り合わない。

実行は run() をそのまま呼ぶ。ツールの引数を argv に戻して --json を付ける。 検証・middleware・output.tsx の検査・エラーの構造化 (ADR 29) がすべて CLI と 同じ経路を通り、MCP のためだけの経路を持たない。argv に戻せるのは、argv が Type.Object を取れない (ADR 9) からで、JSON と文字列の往復で情報が落ちない。 stdin.tsx があるコマンドは、パイプの中身を stdin 引数で受ける。

annotations は none を確かめたときだけ既定より安全側に動かす。MCP の既定 (readOnlyHint: false, destructiveHint: true, openWorldHint: true) が最も 保守的なので、unknown が 1 つでもあれば何も言わない。導出の規則:

  • readOnlyHint: true ⇐ fs.write / process.spawn / process.mutate / network が すべて none。network は読みだけかもしれないが、モジュール粒度では GET と POST を区別できないので、書かない側に倒す (健全性を精度より優先。ADR 32)
  • destructiveHint: false ⇐ readOnlyHint が真。書くものは既定のまま
  • openWorldHint: false ⇐ network と process.spawn が none。spawn は任意の コマンドに届くので「外」に数える
  • idempotentHint は導かない。再実行の安全性は静的解析では言えない

仕様は「hint はサーバの申告で、信用できないサーバの hint は信用するな」と言う。 decopin の hint は申告ではなく解析結果で、これが他の MCP サーバとの違い。 ただし hint であって sandbox ではない。モジュール粒度の none は「その種類の sink が到達不能」の証明で、ホスト側の強制の代わりにはならない。

ツール名は / を _ にする (user/show → user_show)。仕様が ^[a-zA-Z0-9_-]{1,64}$ を求めるため。ルートコマンドはプログラム名。

valibot で書いた output.tsx も outputSchema を出す。Type.* は型の木から 変換すれば済むが、valibot スキーマ (ADR 9 の逃げ道) を直接渡した場合、以前は スキーマが消えていた。「宣言したのに MCP 側だけ痩せる」のは輸出の穴なので、 schemaToTypeText と同じ歩き方で JSON Schema も作る (valibot は import せず type 文字列で分岐する。ADR 10 のまま)。表せない節は {} (制約なし) にする。 annotations は unknown があれば黙る (ADR 32 の健全性) が、こちらは逆に倒す: JSON Schema の {} は「制約を知らない」の正しい表現で、制約を強める側に 間違えると通るはずの値を弾く形になる。

shell.tsx を持つコマンドはツールにしない。あれは親シェルへの指示 (ADR 35) で、受け渡しはシェル関数が渡す一時ファイルなので、MCP のホストの下には 届く先が無い。それでも一覧に出すと、モデルは呼んで成功の応答を受け取り、 cd は起きない — 黙って半分だけ効く。一覧から外すだけでは名前を知っている ホストが呼べてしまうので、tools/call も同じ絞り込みを通す。 対照的に choose() (ADR 36) を使うコマンドは外さない。あちらは端末が 無ければ exit 2 と「引数で渡せ」という文面で落ち、失敗が構造化されて返るので モデルが直せる。静かに効かないものだけを隠す、という線引き。

env.tsx の宣言は _meta に載せる。MCP のツール定義に環境変数の席は無い が、「動かすのに何が要るか」を呼んで失敗するまで分からないのでは遅い。 decopin-cli/env に <Var> の名前・必須・説明・型を写す。env.tsx は root-only なので全ツールで同じ並びになるが、置き場所はツール側にする: ホストが見るのは tools/list だけで、要求はツールの性質だからである。

ADR 34: ポリシーは strict だけ。副作用は申告させない

ロードマップには effects.policy: "declared" (申告と解析の矛盾だけをエラーにする) も挙げていたが、作らない。申告を導入すると ADR 32 の前提 — 「申告させず、機械が数えるからずれない」— を自分で崩す。申告と解析の矛盾を 検査するために申告を要求するのは、検査のために検査対象を増やしている。

残るのは strict 1 つ。decopin build --strict-effects で、解析が諦めた (unknown を含む) コマンドをビルドエラーにする。何に当たったか (eval、 解決できない import) と入口からの経路を全部まとめて言う。

既定は strict ではない。unknown は eval だけでなく、Bun が解決できない import (exports map の癖、ネイティブモジュール) でも出る。既定でビルドを 落とすと、利用者の依存の都合で枠組みが使えなくなる。保証が欲しい人が フラグで求める。

opt-out は export const unsafeEval = true を cmd.tsx に書く。 Next.js の route segment config、この枠組みでは skipLayout と同じ形。 副作用の種類は書かない (それは申告になる)。「このコマンドは unknown で よい」と言うだけで、判定は unknown のまま、MCP の hint も出ない (ADR 33)。禁止ではなく保証との交換。ビルド時にコマンドの本体を評価しない ために、export は文面で読む (文字列とコメントは落としてから)。

ADR 35: 親シェルへの指示は shell.tsx で宣言し、シェル関数が eval する

子プロセスは親シェルの cwd も環境変数も変えられない。z / direnv が シェル関数なのはそのため。この枠組みでも同じ形を取る: shell.tsx が 「シェルにしてほしいこと」を Shell.Cd / Shell.Export などで宣言し、 枠組みがシェルコードにして、eval "$(<bin> __shell zsh)" で定義した 同名の関数が CLI の終了後に source する。

stdout / stderr には触らない。受け渡しは関数が DECOPIN_SHELL_FILE で 渡す一時ファイル。stdout に流すと cli go docs | cat で cd が壊れるか、 出力にシェルコードが混ざる。パイプの約束 (ADR 26) を守るには別の口が要る。 これが5 番目のチャンネル (stdout / stderr / 終了コード / データに続く)。

成功したときだけ届ける。失敗して cd されると、エラーを読む前に足場が動く。

部品で宣言させ、文字列でシェルコードを組ませない。クォートを枠組みが 引き受けるため (' を含むパスで壊れる、が一番ありがちな事故)。Shell.Raw は逃げ道として残すが、クォートは書いた人の責任と明記する。

フックが無いときは stderr で言う。黙って何も起きないのが一番分かりにくい。

shell.tsx は cmd.tsx と同じ props を受ける。data.tsx を分けていれば、 表示と shell の両方が同じデータから決まる (ADR 25 の分離がここでも効く)。

ADR 36: 対話は端末とだけ。choose() は関数で、パイプの中では exit 2

ADR 4 の「対話的な入力を読まない」に 1 つ例外を作る。候補から 1 つ選ぶ choose()。stdin と stderr が両方端末のときだけ問い掛け、それ以外 (パイプ、リダイレクト、MCP) では exit 2 の使い方の誤りにして、 「引数で渡してください」の一行 (hint) を添える。同じコマンドが端末では 聞き、パイプでは説明する — deploy の例がそれ。

描くのは stderr だけ。stdout は結果のためのもので、問い掛けの画面が 混ざるとパイプが壊れる (ADR 26)。選び終えたら描いた分を消し、選んだ値の 一行だけ残す (後から読み返せるように)。

JSX の <Choose> ではなく関数。JSX 式は型引数を運べない (ADR 9) ので、 values を as const で渡して戻り値を literal union にするには関数しかない。 notFound() / help() (ADR 30) と同じ形になる。

Esc / Ctrl+C は 130 で、何も出さない。raw mode の間は SIGINT が来ないので、 読んだ側が interrupt() を投げて run() が拾う。エラーではないので文面は無い。

同じ規則で ask() (一行の入力) と confirm() (はい / いいえ) も持つ。 実在の CLI (ecs-pf) を載せ替えたら、選択のほかにポート番号の入力とリトライの 確認が要った。どれも端末とだけ対話し、stderr に描き、パイプでは exit 2。

端末は run() が差し込む (RunOptions.terminal)。targets を差し替えている (= テスト) なら既定で端末ではない扱いにして、テストが問い掛けで止まらない。

ADR 37: --dry-run は渡すだけ。差し替えて止めることはしない

--dry-run を予約し、dryRun: boolean を command / data / middleware に渡す。 枠組みがするのはそれだけで、fs / fetch / spawn を差し替えて止めることはしない。 従うのはコマンドの責任。

ロードマップの案は「実行時に sink をインターセプトして、何に触ろうとしたかを 記録する」だった (静的解析 ADR 32 の上界に対する、実行時の下界)。 実測して諦めた (Bun 1.4.0):

  • require('node:fs/promises').writeFile を差し替えても、 import { writeFile } from 'node:fs/promises' の静的束縛には届かない。 ESM の import はリンク時に値を取り、後からの差し替えを見ない。 node:fs の同期 API、Bun.write、fetch、Bun.spawn、Bun.$ は プロパティ参照なので差し替わる — つまり最もよく使う書き込み API だけが 素通りする
  • Bun.plugin は読み込み済みの builtin を置き換えない
  • Bun.build の plugin onResolve に builtin の specifier は一度も渡らない (external として先に処理される)。バンドル時にシムへ寄せる道も無い

穴のある差し替えは、--dry-run を信じた人のファイルを実際に書く偽陰性になる。 保証できないものを保証の顔で出すより、「渡すだけ」と明記する方が安全。 静的解析 (ADR 32) は「何に届きうるか」を既に見せているので、実行時の下界は 追わない。

help には出さない。全コマンドの --help に --dry-run を並べると、従って いないコマンドで嘘になる。README に書く。

ADR 38: 実行時にしか決まらない補完候補は complete.tsx が返す

Type.Enum の値は宣言から補完できる (ADR 21) が、クラスタ名やブランチ名は 宣言に書けない。app/<cmd>/complete.tsx が (props) => 候補 を default export し、Tab のたびに枠組みが呼ぶ。返した候補は enum の値と同じ扱い (前方一致、 値<TAB>説明) になる。zoxide / docker の補完と同じ「CLI 自身に聞く」方式で、 補完シム (ADR 21) は何も変わらない。

argv.tsx には書かせない。宣言ファイルはビルド時に評価される (ADR 5) ので、 AWS SDK のような重い import や副作用を持ち込ませたくない。別ファイルにすれば 実行時 (Tab を押したとき) にしか読まれない。

枠組みが前方一致で絞る。書く側は全部返せばよく、enum と振る舞いが揃う。

壊れても遅くても補完は落ちない。投げたら空、5 秒返ってこなければ諦める (ADR 21 と同じ「Tab のたびにエラーが出る」を避ける)。打鍵を待たせるのは 書く側の責任で、遅い候補はキャッシュするなり範囲を絞るなりする。

渡すのは生の文字列。ここまでに打たれたオプションと位置引数は検証も変換も していない (途中なので検証は通らない)。--cluster を見て task を絞る、といった 使い方には十分。

ADR 39: コマンドの本体は cmd.tsx。command.tsx は旧名として 1 年受ける

規約ファイルは argv.tsx / stdin.tsx / data.tsx / help.tsx / shell.tsx と 短い名前で揃っているのに、いちばん多く書く本体だけが command.tsx で長かった。 cmd.tsx に短縮して、ファイル名の並びを揃えた。

キーもファイル名に揃える。route.files.cmd、生成される cmd: () => import(...)、RouteLoaders.cmd まで一斉に変えた。「ファイル名が そのまま機能の名前」(ADR 1) が崩れると、cmd.tsx を探して command を grep する ことになる。二重名称を作らないほうが安い。

旧名は 1 年受ける (ADR 20)。scan は cmd.tsx → cmd.ts → command.tsx → command.ts の順に探し、旧名で見つけたら deprecatedFiles に載せる。 decopin build が新しい名前と削除期限を警告する。新名が常に勝つので、 移行中に両方置いても挙動は決まる。

旧名の非推奨は既存の仕組みに乗せた。DEPRECATIONS (ADR 20) に kind: 'filename' を足しただけで、削除期限の見張りとリリースノートは そのまま効く。checkDeprecations はソースの中身を検索するので、 ファイル名は deprecatedFileWarnings が別に見る。

フィクスチャを 1 つ旧名のまま残す (test/fixtures/scan-app/legacy/command.tsx)。 リポジトリ内の command.tsx を全部リネームすると、フォールバックが黙って 壊れても誰も気付かない。

ADR 40: 端末より高い <Dynamic> のフレームは末尾を優先して切り詰める

ESC[nA は最上段で止まる。フレームが端末より高いと、スクロールアウトした 行には消去が届かず、repaint のたびに前フレームの頭がスクロールバックに 堆積して可視域も崩れる。長いログを流すデプロイ系のコマンドが最初に踏む。 そこで present() は描く前にフレームを rows - 1 物理行に収め、消す行数も 切り詰め後のテキストで数える (描いたものと消去の数学を一致させる)。

なぜ末尾優先か: 進捗系は最新の情報が末尾に来る。頭を捨てて末尾を残せば 「今どこか」が見えたまま、捨てた分は省略行 1 行 (… (12 more lines)) で示す。 省略行も予算に含める。切り詰めは論理行単位で、折り返した論理行を途中で 切ることはしない (予算を 1 行余らせるほうを選ぶ)。

なぜ rows - 1 か: 最終行の改行でカーソルが最下段を越えると画面が 1 行スクロールし、次の ESC[nA が 1 行ずれる。カーソル行を 1 行残せば スクロールが起きない。

なぜ決着後の最終フレームは切り詰めないか: 消去の数学が要るのは「次に 消される予定のフレーム」だけで、決着したフレームは誰も消さない。だから source が尽きたら、切り詰めて描いていた TTY でも最後の中間フレームを消して 完全形を描き直す (PPR の「決着したら static へ flush」そのもの)。スクロール してもそれはもう静的な出力で、崩れる理由が無い。非 TTY (パイプ・CI) は 最終フレームだけを 1 回書く (ADR 22) ので最初から切り詰めない。結果、同じ コマンドの確定した出力は TTY とパイプで一致する。切り詰めが起きなかった島は 描き直さない (消去の回数を増やさない)。エラーやシグナルでの中断時は 切り詰めたまま残す。省略行は本文と見分けるため dim で描く。

rows は columns と対称に扱う: PresentOptions.rows で明示でき、明示しない ときだけ process.stderr.rows を読み、SIGWINCH で追従する。取れなければ 24 (terminalWidth の 80 と同じ VT100 の既定)。

既知の限界: (a) 暗黙 columns でのリサイズ後は previousRows が旧 columns 基準なので、リワラップする端末 (iTerm2 / kitty) では消去がずれうる (best-effort、ADR 22 の代償と同じ)。(b) frameRows の ceil(width / columns) は全角文字が行末に収まらず早期に折り返す分を数えないため、極端な条件 (columns=5 に全角 12 文字など) で物理行数を undercount する。(c) 1 論理行 だけで予算を超えるとき (columns=80, rows=5 に幅 400 の 1 行など) は「予算を 余らせる」のではなく、その 1 行を予算超過のまま残す。何も描かないよりは 崩れが小さい。

ADR 41: core は features を呼ばない。呼んでよいのはディスパッチする 6 ファイルだけ

src/ は features (規約) / core (基盤) / cli (入口) の 3 つに分けている (ADR 1 の「ファイル名がそのまま機能の名前」を、ディレクトリの粒度でも守る)。 名前で分けただけでは境界は保たない。誰でも core から features を import できてしまい、少しずつ「どちらでもいいもの」が増える。

向きは features → core の一方向。core は「規約が何であるか」を知らずに 成り立つ部品 (レンダラー、JSX、バリデーション、ビルド基盤) の置き場で、 features は規約 1 つ 1 つの実装。逆向きの依存が増えると、規約を 1 つ足すたびに core を編集することになり、規約の追加・削除が局所で済まなくなる。

例外は 6 ファイル: runtime/run.tsx、runtime/mcp.ts、build/ の 4 つ。 core が規約をディスパッチする以上、この 6 つは features を知らざるを得ない。 消すにはレジストリへの反転が要るが、run.tsx を上から読めば実行順が全部 分かるという可読性を失う。規模に対して割に合わないので、例外として 明示するほうを選んだ。

再エクスポートだけの窓口は許す。core/runtime/help.tsx のように export { Help } from '../../features/...' だけのファイルは、公開 API の 置き場であって振る舞いを持たない。lint は文が import か export かを見て 区別する。import() での遅延読み込みも抜け道になるので同じく禁じる。

置き場所が間違っているだけのものは core に移した。DeclarationError、 CliError (もともと core/runtime/exit.ts の EXIT_CODE に依存していた)、 closest() (規約を知らない文字列の道具) は features にあったが、どの規約にも 属していなかった。共有フォルダを作ったり両側で重複させたりせずに済む。 とくに前 2 つは公開 API のクラスなので、重複させると利用者の instanceof が 静かに false になる。

ADR 42: エラーの見分けは出自ではなく印で行う

instanceof は「そのクラスが どのファイルから来たか」を問う。同じコードでも 実体が 2 つあれば false になり、しかも壊れ方が静かな false になる。 toCliError() が取り逃すと kind / exitCode / issues / hints を落として 包み直すので、「使い方の誤りは exit 2」(ADR 30) が exit 1 に化ける。例外が 飛んだわけでもログが出るわけでもないので、気付く手がかりが無い。

テストでは守れない。テストは CliError を 1 回 import して投げる側と受ける側の 両方に渡すため、2 つの実体が出会う配線を再現しない。契約テストは全部通ったまま 本番だけが壊れる。バグがコードではなくモジュールグラフにあるから。

そこで Symbol.for で印を付ける。Symbol.for('decopin.CliError') は グローバルな登録簿から引くので、実体が 2 つあっても realm が違っても同じ シンボルになる。isCliError() / isDeclarationError() はこの印を見る。 React が要素を Symbol.for('react.element') で見分けるのと同じ手口。

いつ 2 つになるか: 利用者の node_modules に decopin-cli が 2 バージョン 入るとき (decopin 製のライブラリを併用する等)。今日は起きないが、公開 フレームワークとしては想定内で、起きたときに直すのは難しい部類のバグになる。 印を付ける費用は 1 行なので、先に払っておく。

instanceof も引き続き動く。クラスは残っているので、利用者の catch (e) { if (e instanceof CliError) } は壊れない。isCliError() は それより広く当たる版として公開する。

Error.isError() を使う (Bun 1.4 / TS 7 で利用可)。error instanceof Error は Object.create(Error.prototype) に騙され、realm をまたぐと取り逃す。 Error.isError は内部スロットを見るのでどちらも正しい。Array.isArray と 同じ立ち位置。ただし種類は区別しないので、CliError かどうかの判定は 印と併用する。

印は 1 つのシンボルに種類の名前を載せる (2026-09-04 に変更)。当初は Symbol.for('decopin.CliError') のように種類ごとに別のシンボルを置いていたが、 RenderError / RpcError にも印を付けようとすると同じ 3 行が増え続ける。 Symbol.for('decopin.error') に 'CliError' などの名前を載せ、errorTag() で 読む形にした。isCliError() は errorTag(e) === 'CliError' の薄い包みで、 toCliError() は switch (errorTag(e)) の 1 本で全種類を扱う。 valibot の v.instance() は使わない。中身は instanceof なので、 この ADR が避けたい弱点をそのまま持ち込む。

包み直しても投げた場所は失われない。toCliError() は元のエラーを cause に残す。既定の表示には出さないが、DECOPIN_DEBUG=1 で連鎖とスタックを stderr に足す (--json では error.trace)。フラグではなく環境変数にしたのは、 --verbose / --debug は利用者が自分のオプションとして宣言しがちな名前で (README の middleware の例、ADR 11)、予約すると既存の宣言を壊すため。 test/features/inherited/error/handle-error.test.tsx が「包んでも場所が潰れない」ことを固定する。

旧い印は 1 年付け続け、見続ける (ADR 20)。新バージョンが投げたエラーを 旧バージョンが受ける (逆も) 場面があるため、新しいインスタンスは旧いシンボルも 持ち、errorTag() は旧いシンボルも読む。2027-09-04 以降に消す。期限は src/core/deprecations.ts が見張る。

ADR 43: decopin dev は build を回し続ける (型だけでなく dist/ も追従させる)

当初の decopin dev は .decopin/ (routes / entry / 型) だけを更新し、 バンドルはしなかった。型の鮮度が目的で、dist/ は decopin build の 仕事という分担だった。

実際の使われ方と合わなかった。bun run dev を回しながら app/ を編集し、 別の端末で bun ./dist/index.js を叩いて確かめる、というのが素直な開発の 回し方になる。このとき dist/index.js が古いままなので「保存したのに変わらない」 と見え、原因が「dev はバンドルしない」だと気付くには README を読み直す必要が あった。変更が映らない開発サーバは開発サーバとして機能しない。

そこで dev は build() をそのまま呼ぶ。watchApp が回すのは generate() ではなく build() になり、保存のたびに .decopin/ と dist/index.js と 補完シムが揃って更新される。--out / --minify も build と同じく受ける。 費用は 1 回あたり数十 ms のバンドル (demo/app で 20ms 前後) で、debounce (50ms) の中に収まる。バンドルが失敗しても watch は止めず、エラーを出して次の保存を待つ。

.decopin/ の更新は内容が変わったときだけなのは変わらない (writeIfChanged)。 dist/index.js は毎回書き直すが、これは読む側が tsc ではなく bun の起動なので 途中の状態を見る心配が薄い。test/intent/dev/dev.test.ts が「保存で型も バンドルも追従する」ことを固定する。

ADR 44: decopin dev --annotate は cmd.tsx の props に生成型を書き足す

CmdProps<'hello'> はコマンド名を手で書く。名前はディレクトリ名と同じで 情報としては冗長だし、.decopin/types.d.ts を生成する側はどの cmd.tsx が どの名前かを知っている。それでも「型注釈を書かないと args が unknown に 落ちる」ことに気付くのはエディタの補完が効かなくなったときで、原因が 注釈漏れだと分かるまで少し迷う。

そこで dev に --annotate を足す。型を生成した直後に、default export が 型注釈を持たない cmd.tsx (function Command(props)) を見つけたら、 props: CmdProps<'hello'> と import { type CmdProps } を書き足す。 分割代入 ({ args, options }) も同じ扱いで、閉じ括弧の直後に入れる。

ユーザーのソースを書き換えるので、既定では動かさない。build にも付けない。 init が出す package.json の dev スクリプトにだけ付け、始めた人には 黙って効き、既存プロジェクトは自分で選ぶ。書き換えは default export の 1 行と import 行に限り、それ以外のバイトには触らない。既に何か注釈がある (生成型でも手書きでも) ものと、引数を取らないものは対象外。

AST は使わない。文字列とコメントを除いた同じ長さの view から export default (async) function の候補を正規表現で探し、Bun の構文 scan で JSX text ではなく実際の export だと確かめ、括弧の対応を数えて引数を切り出す。 TypeScript を依存に足すほどの仕事ではない (ADR 5 の「宣言は import して呼ぶ」と同じ節約)。

自分の書き換えで watch が 1 回余分に回るが、2 回目は差分が無いので止まる。 test/core/build/annotate.test.ts が「注釈が無いときだけ足す」「import を足す」 「既にあれば触らない」を固定する。

ADR 45: 実行例は example.tsx で宣言する。decopin docs はそれだけを実行する

Issue #3 の「app/ の構造からドキュメントを生成し、コマンド単位で実行可能に」を 入れるにあたり、押したら結果が返る HTML は採らなかった。押せる形にすると、 任意のコマンドを引数付きで起動するサーバーを配ることになる。ドキュメントは 読むものなので、読むために実行環境を要求するのは釣り合わない。

代わりに、生成する側が実行して結果を貼る。decopin docs は Markdown を 出し、例を実際に走らせて、打った行と返ってきた出力を並べる。読む人は打たずに 結果を知る。Issue が求めていたのは押せることではなく、打たずに何が返るかが 分かることだと読み替えた。

何を走らせるかは example.tsx でだけ決まる。help (ADR 8) が argv.tsx から 呼び方を組み立てるのと同じ形で、example.tsx が CommandExample[] (args と任意の description) を default export する。宣言が無いコマンドは 実行しない。ドキュメントを作るだけで副作用が起きると、生成そのものが危険な 操作になる。--no-run で全部止められる。

失敗した例も載せる。終了コードと出力をそのまま「失敗」として書き、生成は 止めない。例が腐っていることは、ドキュメントに出ているほうが分かる。

組み立ては src/cli/docs/document.ts に置く。core には置かない。argv・ example・help の規約を読む以上、core に置くと ADR 41 を破る。使うのは decopin docs だけなので、cli/gen/generate.ts と同じ形にした。

test/intent/docs/docs.test.ts が「一覧・呼び方・実行結果・宣言のない コマンドに触らないこと・失敗の載せ方・--out」を固定する。


ADR 46: 宣言ファイルの返り値型を配り、--annotate が書き足す

cmd.tsx には CmdProps<'hello'> があるのに、argv.tsx や env.tsx には 何も無い。「この関数は何を返せばいいのか」がコードから読めず、decopin build を通すまで分からなかった。

ADR 9 を測り直した上で入れている。 TypeScript 7.0.2 でも JSX 式の型は JSX.Element に潰れる。jsx() factory を jsx<T>(type: T, ...): Element<T> としても変わらない (実測)。したがって EnvDefinition のような別名は どれも同じ型で、「env.tsx に <Argv> を書いた」取り違えは型では 捕まえられない。そこは評価時の DeclarationError が見る。

それでも入れるのは、型として言えることが 2 つあるから:

  • 要素を返すこと。null・文字列・配列の返し間違いは型検査で落ちる
  • 書き方が揃うこと。規約ファイルの形が import した型名で読める

本当に型検査が効くのは data.tsx × output.tsx の 1 か所。ここは JSX を 経由しないので ADR 9 に縛られない。output.tsx があれば宣言が正 (ADR 28) な のに、食い違いは実行時検証まで分からなかった。生成する .decopin/types.d.ts に DataResults を足し、DataResult<'stats'> で引けるようにする。

DataResults を Routes と分けたのは自己参照を避けるため。output.tsx が無いコマンドの data は data.tsx の戻り値から ReturnType で引いている (ADR 25)。そこへ DataResult<'go'> と書くと型が自分を参照する。だから DataResults には output.tsx があるコマンドだけを並べ、--annotate も その場合しか書かない。未生成のときは unknown に落ちる (ADR 9 の代償と同じ 扱いで、注釈が型検査を止めないことを優先する)。

--annotate を拡張する。新しいフラグは足さない (ADR 44)。cmd.tsx の props を補うのと同じ条件 — 注釈が無いものだけ・書き換えは 1 行と import 行 だけ — を宣言ファイルの返り値へ広げる。async の宣言には Promise<...> を 書く。フラグを分けると「props だけ補って返り値は補わない」状態ができ、 どちらが効いているのか説明が増える。

test/intent/returns/returns.test.ts が「型が配られていること」 「注釈を足すこと」「既にあるものに触らないこと」「output.tsx が無い data.tsx を対象にしないこと」を固定する。


ADR 47: decopin build はコマンドごとの組み立てを木で出す

「ファイルの有無 = 機能の有無」が一番外側の契約なのに、build の出力は コマンド名しか出していなかった。名前だけ並んでいても、そのコマンドに argv.tsx があるのか、上の layout.tsx が効いているのかは app/ を開き直す まで分からない。契約を、契約が効いた直後に読み返せる形にする。

木はコマンド中心にする。app/ のディレクトリ構造そのままにはしない。 打つ側から見える単位はコマンドで、user/ のように cmd.tsx を持たない 中間ディレクトリは単位ではない。1 コマンド = 1 ノードにして、そのノードに 置かれた規約ファイルを読む順 (CONVENTION_FILES の順) に並べる。

継承ファイルは、どの階層のものかまで書く。layout.tsx とだけ出すと、 直しに行く先が分からない。ただし app/ 直下のものは Root の節に 1 度だけ 出し、木には出さない。全コマンドに効くものを全コマンドの行に書いても情報は 1 件も増えず、本当に近い階層のものが埋もれる。

種別は行の位置ではなく、ファイル名の前の記号で示す (ƒ 規約 / ↑ 継承 / ¤ ルート専用)。置かれたファイルと継承ファイルを別の行に分けていたときは、 行が何を意味するのかが出力のどこにも書いていなかった。記号にすると 1 行に 混ぜられ、読み方を木の下の凡例に 1 度書けば済む。絵文字は使わない。幅が 2 桁で、異体字セレクタの有無でも変わる (src/core/renderer/width.ts に実測の 覚書がある)。UTF-8 でない端末には、src/core/renderer/data.ts の SYMBOLS と同じく ASCII の対 (f / ^ / *) を用意する。

フラグにはしない。--tree を付けたときだけ出すと、既定では新しい情報が 何も見えない。ADR 32 の副作用ブロックと同じで、build のたびに読み返せて 初めて、契約とずれたときに気付ける。

組み立ては src/cli/build/tree.ts に置く。規約の一覧 (CONVENTION_FILES・ INHERITED_FILES・ROOT_ONLY_FILES) と継承の解決を読むので、core には 置けない (ADR 41)。cli/docs/document.ts (ADR 45) と同じ形。

test/cli/build/tree.test.ts が木の形を、test/intent/build/build.test.ts の shows-what-each-command-is-made-of が CLI を通した出力を固定する。


ADR 48: 目的は test/intent/ に Intent として置き、証明はテストが担う

intent.txt の開発モデル (Intent → Behavior → Implementation、Test = Evidence) を experiments/intent/ で 7 つの Intent・45 の Behavior に当てて試した (2026-09-10〜14、記録は test/intent/README.md)。既存のテストが見ていなかった Behavior が 3 件出た (init が次の一手を出すこと、build の出力 2 件)。実装を書く 前に Intent を決めた 2 回は Behavior の書き直しが 0 回で、目的を 1 文にする段で 実装の選択肢が削れた。実験の置き場から本線に移す。

置き場は test/intent/。ランタイム (core.ts / evidence.ts / evidence.bun.ts / doc.ts) と、Intent ごとのディレクトリ (intent.ts / behavior.ts / implementation.ts / <name>.test.ts。宣言を JSX で書く Evidence は .tsx)。src/ に置かないのは、 配布物に Intent ランタイムが入るからで、build:package の rootDir が実測で それを拒否した (TS6059)。依存は test/intent/ → src/ の一方向で、src/ は Intent を知らない。test/ の下なら衛生 (制御文字) と参照切れの検査も届く。

report() はファイル末尾で 1 回。後ろに describeBehavior が来たら throw する。 「不要な waived()」の判定はファイル内の証明が出揃った後で走る前提で、後ろに 置いた証明は判定をすり抜け、ドキュメントには ✓ で出た (実測 2026-09-14)。 順序の約束を人が守るのではなく、ランタイムが見張る。

ドキュメントは断片から組むが、宣言と突き合わせる。doc.ts は .decopin-intent/ の断片を intent.ts の宣言と比べ、宣言の無い Intent・ 宣言に無い Behavior・断片の無い Intent があれば何も出さずに落とす。消した Intent の断片が残り、6 つの Behavior が ✓ で出たことがある (同日)。CI は bun test の後に doc.ts を走らせ、ローカルの bun run intent:doc は断片を 消してフル実行から作り直す。

断片のファイル名には Behavior id を含める (shardName())。証明名だけで 分けると、partial: true で分担した 2 ファイルが同じ証明名を使っているとき 同名になり、プロセスを分けて並列に走らせると後勝ちで片方の証明が消える (実測 2026-09-20)。Evidence はモジュールに貯まるので同一プロセスでは起きない。 消えても doc.ts は未証明として落ちるが、テストは全部通っているので原因が 読めない。id を含めれば衝突せず、同じ分担なら同じ名前 (冪等) のまま。

新しいサブコマンド (src/cli/<name>/) は Intent から書く。既存の機能は 触るときに回収する (Intent Recovery)。すべてのテストを Evidence にはしない。 test/core/build/ のように、Behavior ではなく実装の形を見るテストは普通のテストの まま隣に置く。

結末を見ているテストは、動かさずに分担させる。test/features/inherited/error/handle-error.test.tsx のように規約の隣にあるテストは、describeBehavior で包んで report(impl, { partial: true }) を末尾に置くだけで Evidence になる。分担する Intent は元のファイルも partial にする — 非 partial の report() が先に走ると、 後ろの describeBehavior が throw する。describeBehavior の中に describe は 入れない (bun:test の body の呼ばれ方のせいで、中の proves が判定をすり抜ける)。

test/intent/ の § は intent.txt の節番号。ADR 15 で捨てた仕様書の節参照と 形は同じだが、指す先は決定ではなく開発モデルの理論なので、ここだけ許す。 intent.txt が消えたら参照ごと落ちる。

代償: テスト時間 (Intent の Evidence は 102 件で 3.1 秒、全体 4.3 秒の 7 割。 build / runtime / dev がそれぞれ demo を組み立てるため) と記述量 (Behavior あたり 18〜34 行)。Intent が増えて bun run intent:doc の時間が効いてきたら、断片を 分担する report(impl, { partial: true }) の側を見直す。

test/docs/decisions.test.ts の lint が「各 Intent ディレクトリに 4 ファイルが揃う」 「report() が末尾に 1 回」「src/cli/* のサブコマンドごとに Intent がある」を 見る。ランタイム自身の性質 (順序・免除の自壊・落ちた証明の扱い) は test/intent/evidence.test.ts が固定する。


未決 / 保留

決めていないことをここに置く。ADR ではないので、test/docs/decisions.test.ts の分類の対象にはならない。決まったら ADR に昇格させる。

  • 型が古い (生成済みだが宣言と食い違う) 場合は素直に型エラーになる。未生成の ときのフォールバックは実装したが、「古い」と「未生成」は区別できていない。 decopin dev を回す運用でしのいでいる
  • <Type.Object> / <Type.Field> は 3 階層で 9 行になる。深い JSON では schema prop を勧めるが、内省できないノードがあると型が unknown に落ちる
  • <Arg variadic> と <Type.Array> は別物 (前者は位置引数を複数取る、後者は 1 つの値が配列) だが、生成される型が両方 string[] になり混同しやすい
  • ADR 11 の代償 (env / argv の検証エラーが middleware を通らない) が実用上 問題になるなら、計測・ロギングの責務を global-error.tsx に寄せる形を検討する
  • 起動時間は総時間 12〜14ms で、目標の 10ms は未達 (ADR 12)。さらに削るには 6〜8ms が何に使われているかのプロファイルが要る
  • middleware の args / options は Record<string, unknown> のまま。middleware は 複数コマンドにまたがるので、生成された型を当てるには「そのディレクトリ以下の コマンドの union」が必要
  • <Table> のセルは文字列だけで JSX を入れられない。セルに色を付けたい要望が 出たら Cell に { text, style } の形を足す