本文へスキップ

ブログ

ターミナルエージェント

CLIの半分を静かに失っていたジェネレーター

このサイトのコマンドリファレンスは自動生成されている。ジェネレーターの最初のバージョンは、53個のサブコマンドが欠落したまま、きれいにソートされた、完全に説得力のあるJSONファイルを生成し——そしてエラーを出さなかった。

Chimeraのコマンドラインインターフェースには109個の呼び出しがある。ルートコマンドが56個、11個のグループにまたがるサブコマンドが53個だ。ドキュメントはそのうちのおよそ17個についてハッピーパスをカバーしており、それは人間の手で書かれたものだ。これは「はじめに」ガイドを書く上では正しいやり方だが、網羅的なリファレンスを維持する上では間違ったやり方でもある。手でコピーしたフラグの一覧が正しいのは、それが書かれた日ただ一度きりだ。

だからこのサイトのリファレンスは、CLI自体から生成されている。デスクトップアプリのTypeScriptの型がAPIスキーマから生成されているのと同じやり方で、CIには同じ「ドリフトゲート」が組み込まれている——再生成し、コミット済みのコピーと差分があれば失敗させる、という仕組みだ。

バグ

最初のバージョンは、コマンドツリーをこのように歩いていた。

root = typer.main.get_command(cli_app)
if not isinstance(root, click.Group):
    raise TypeError("the Chimera CLI is expected to be a command group")

このチェックは失敗した。失敗したのは走査ではなく、チェックそのものだ。typer.main.get_command() は、インストールされている click.Group のインスタンスではないオブジェクトを返していた。なぜならTyper 0.27は、typer._click という名前空間の下にClickの独自コピーをベンダリングしているからだ。TyperGrouptyper._click.core.Command を継承しており、自分のコードがインポートしている click は、まったく別のクラスオブジェクトなのだ。

例外を投げたのは、単なる幸運だった。その一つ前のバージョンは、代わりに一見もっともらしいことをしていた——click.Group でないものはすべて末端のコマンドとして扱う、というやり方だ。そしてそちらは例外を投げなかった。整った形式の、ソート済みで決定的なJSONファイルが生成され、そこには56個のコマンドが記述されていたが、すべてのグループが単一のエントリに平坦化され、53個のサブコマンドはすべて消えていた。

なぜそれが最悪の失敗なのか

クラッシュするジェネレーターは、失敗したことを教えてくれる。もっともらしい出力を生成するジェネレーターは何も教えてくれず、それより下流にあるものすべてがその沈黙を引き継いでしまう。リファレンスページは美しくレンダリングされていただろう。chimera kanban はヘルプテキスト付きで、サブコマンドなしのコマンドとして一覧に載っていただろう。それは、サブコマンドを持たないコマンドの記述として、まったく違和感なく読める。検索エンジンもそれをインデックスしていただろう。そのページを見ている誰にも、欠落は検出できなかったはずだ。なぜなら、欠落した状態は、完全な状態のページとまったく同じように見えるからだ。

この失敗は、見ることではなく数えることによって発見された。リファレンスは109個の呼び出しを示すべきところ、56個しか示していなかった。

修正、そしてそれより長く生き続けるテスト

ダンパーは今ではダックタイピングを使うようになった。オブジェクトが何のクラスであるかを問うのではなく、commands というマッピングを持っているかどうかを問うのだ。これなら、ベンダリングされたClickにも、Typerのアップグレードにも、Clickのメジャーバージョンの変更にも耐えられる。なぜなら「サブコマンドを持っている」というのはそのモノ自体の性質であり、「click.Group である」というのはインポートグラフの性質にすぎないからだ。

修正そのものよりも有用なのは、テストの形だ。わかりやすいアサーションは、ダンプが成功したことを検証するというものだ。

def test_dump_works():
    assert build()  # passes with 53 subcommands missing

この失敗を検出するアサーションは、何が見つかったかについてのものだ。

def test_finds_the_groups_and_not_just_the_leaves():
    groups = [c for c in build()["commands"] if "commands" in c]
    assert len(groups) >= 10

ジェネレーターが実行されたことだけを主張するテストは、ジェネレーターが間違っている間も通り続けてしまうテストだ。書く価値があるのは、それが見つけるべきだったものを主張するテストである。

参照先

https://chimeraagent.space