ほとんどすでにあったパッケージシステム

自分自身をコンパイルし、ブラウザで走る言語が、それでも共有できなかった —— 他人のコードに依存する手段がなかった。修正は実の consumer から、その pain を逆算して来た —— そして驚きは、新しい部分がいかに少なかったかだ。resolver はすでに cross-package import を正しく解決していた;足りなかったのは言語でも semantics でなく、tooling だった。manifest、lockfile、そして runtime host を vendor する手段が、released バイナリを「コンパイラの checkout なしで app を build も run もできるもの」に変えた。

merepackagestoolingdogfoodlanguage-design

八つ目の Part が終わるころ、Mere は驚くほど多くのことができた:自分自身をコンパイルし、ブラウザで 走り、HTTP を serve し、Postgres と話し、四つの backend を厳密な一致に保つ。それでいて、言語に 期待される最もありふれたことができなかった —— あるプログラムが、他人の書いた別のプログラムに依存 すること。「このライブラリが要る」と言えばそれが現れる、という手段がなかった。この gap はコンパイラを 建てている間は見えない。コンパイラは丸ごと自分が所有する一つのツリーだからだ。gap が見えるのは、 最初のものの上に二つ目のものを建てようとした瞬間だ。

形は設計でなく、consumer から来た

パッケージシステムの誘惑は、それを抽象で設計することだ —— レジストリ、バージョン範囲、解決 アルゴリズム —— そして誰かが使う前に大きなものに辿り着く。Mere は逆の道を取った。実の アプリケーション、Mere で書かれた小さな共同編集ノートサーバを、コンパイラ自身のライブラリに依存させ、 そこから生じた pain を読んで要件とした。

pain は具体的だった。install ステップがなく、依存は monorepo から手でコピーされた。バージョンを pin する手段がなく、実際に何に依存しているかは VENDOR.md に散文で書かれ、即座にずれた。そして 名前が衝突した:ライブラリは相対パスで互いを import し、vendor が生む on-disk のディレクトリ名は それらの import が期待する名前と一致せず、cross-package import が壊れた。三つの問題、それぞれ一文、 それぞれ実在するプログラムから。そのリストが仕様そのものだった。

resolver はすでに十分だった

示唆に富む部分は次に来た。要件を並べた上での自然な仮定は、それらを満たすとは言語を変えることだ、と いうものだった —— 新しい import 構文、解決 pass、バージョンのためのコンパイラ支援。そうでは なかった。すでにある resolver がすべてを正しく解決していた。

その resolver は、パッケージシステムの最初のバージョンから、import するファイルからファイルシステムの root に向かって歩き上がり .mere_modules/ ディレクトリを探す —— Node が node_modules を探して 歩き上がるのとまさに同じやり方だ。一つ見つけると、"pkg/module.mere" の import は .mere_modules/pkg/module.mere を読み、vendor されたモジュールの内側からの入れ子 import はその 位置から歩き上がって同じトップレベルのディレクトリを見つける。cross-package import は、すべてが一つの プロジェクト root の下にある限り、すでに Just Worked だった。dogfood がそれを偶然に証明していた。 だから新しい仕事の全体は semantics でなく tooling だった:.mere_modules/ を正しく populate し、 あとは道を空ける何か。コンパイラはファイルがどうやってそこに来たかを気にしない。

manifest と lockfile、それ以外は何もない

その tooling が必要としたものは小さかった。manifest —— mere.toml —— がパッケージとその依存を 宣言する。各依存は正確なコミットにおける git リポジトリ、任意でそのリポジトリのサブディレクトリ、 だから一つの monorepo が多くのパッケージを host できる。mere install は manifest を読み、各依存を pin されたコミットで fetch し、パッケージ間の相対 import を辿って推移的なものも引き入れ、それらを .mere_modules/ に書く。それから何をしたかを mere.lock に記録する —— 各パッケージの解決済み フルコミットハッシュ、加えてセット全体のコンテンツハッシュ —— だから二度目の install は一度目を正確に 再現する。

意図的な省略は、あるものと同じくらい重要だ。依存は正確なコミットを pin し、バージョン範囲でない、 だから解決すべきものがない —— 充足可能性の探索も、解くべきダイヤモンドもない。二つのパッケージが 共有依存の違うリビジョンを望んだら、歩き上がりが決め、修正は solver でなく deployment 層で片づける。 中央レジストリはない。これらはスケジュールに沿って埋められるのを待つ gap ではない;プログラムが 必要とするまで建てないまま残されたものだ、言語の残りが建てられたのと同じ規律で。

名前の結び目

元の三つの pain の一つは、それ自身の注記に値する。全体が機能するかどうかを静かに決める類の細部 だからだ。レジストリは自然に名前空間つきの名前を望む —— mere-httpmere-db —— 違う作者からの パッケージが衝突しないように。だがライブラリ自身は裸の相対パスで互いを import する、一つのツリーに 並んで住んでいたときに書かれたものだ:../mere-log/log.mere でなく ../log/log.mere。もし installer が mere-log というディレクトリにパッケージを vendor したら、log を期待するすべての相対 import が 壊れる。

解決は、二つの命名の世界を分けることだった。ディスク上の名前、そして import の中の名前は、裸のものだ —— httpdblog —— だから .mere_modules/ はソースツリーをミラーし、すべての相対 import が 解決し続ける。mere- prefix はリポジトリ座標にだけ住む:それはパッケージがどこから fetch されるか、 将来の standalone repo 名か monorepo サブディレクトリで、コンパイラが見るパスには決して現れない。 manifest は二つが結びつけられる唯一の場所で、そういう対応づけがまさに属すべき場所だ。

ソースだけでなく、runtime を届ける

最初のものに隣接して、ソースコードのためのパッケージシステムが明らかにはカバーしない二つ目の gap が あった。コンパイルされた Mere プログラムは .wasm ファイルで、.wasm ファイルはそれ自身では何も しない:putsread_filehttp_serve のような関数を host から import し、それら host 関数は コンパイラ自身のリポジトリに住む小さな JavaScript の断片だ。だからすべてのソース依存を vendor しても、 コンパイルされた app はコンパイラの checkout なしにはまだ走れなかった —— それをコンパイルする意味の 多くを損なう。

修正は manifest を [host] セクションで拡張し、それら host 断片も fetch し、内部参照を平坦化して bundle が .mere_host/ ディレクトリで独立して立つようにし、それに対してコンパイルされたサーバを走らせる mere serve を足す。効くのはこの帰結だ:released mere バイナリ、加えて mere install が、いまや アプリケーションをbuild することもrun することも十分にできる、runtime にコンパイラのソースツリーが まったくない状態で。配布は、結局、ソースだけの話では決してなかった;runtime host がそれと共に旅する 必要があり、そうなった瞬間、loop が閉じた。

小ささが買ったもの

パッケージシステム全体は意図的に小さい —— レジストリなし、バージョン solver なし、正確なコミットを fetch する install と、すでに書かれていた resolver。それを未完成と読むのは容易だろう。連載を通じて 実践してきたのと同じ抑制と読むほうがいい:実の現在の pain を除く最小のものを建て、その形を、 パッケージシステムが含むべきという観念でなく、実際にそれを必要とするプログラムに決めさせる。 consumer が仕様を書いた。言語は、ほとんど、すでにそれを満たしていた。残ったのはそれに気づくこと、 そして残りだけを建てることだった。

← Back to Mere: 言語を作る