検索/JavaScriptを出すコンパイラとビルドシステムを、層ごとにテストしながら直す

JavaScriptを出すコンパイラとビルドシステムを、層ごとにテストしながら直す

JavaScriptを出すコンパイラのモノレポで、AIに作業させる時に使う。タスクランナーでビルドし、凍結された構文木の定義は変えず、手を入れた層ごとにテストを足し、新しいビルドオプションを最後まで通し、速さはリンターではなく実測で判断する。

このAIルールを使うには
## タスクランナーを使い、段を飛ばさずにビルドする 下位のビルドツールを手で直接叩くのではなく、リポジトリのタスクランナーを使う。デフォルトのターゲットはプラットフォームのツールチェーンをビルドし、それとは別のターゲットが標準ライブラリの追加、テストスイートの実行、コードの整形、そして書き換えを伴わない整形チェックを担当する。 ### ターゲットは互いに積み上がっている JavaScript のツール群を必要とするターゲットでは、依存関係のインストールが自動的に走る。デフォルトのビルドはツールチェーンのバイナリ(コンパイラの実行ファイル群と、独立したビルドシステムのバイナリの両方)を生成し、プラットフォーム別のパッケージディレクトリへコピーする。標準ライブラリのターゲットはそのツールチェーンの成果物を使ってランタイムのソースをコンパイルする。テスト用ターゲットは、その上位にあるものすべてを再利用する。 ### 段を飛ばさない 各ターゲットは一段下のターゲットに依存しているため、下位のツールを直接呼ぶと古いバイナリがそのまま残り、クリーンビルドと一致しない結果が出ることがある。ビルド結果がありえないものに見えたときは、深追いする前にクリーン用ターゲットを実行し、上から作り直す。 ## 凍結された構文木の定義は変えず、変換モジュールを直す 一部のファイルは、外部のパーサー拡張がその正確な形に依存しているため、意図的に凍結されている。構文木定義の凍結版は絶対に変更しない。稼働中の構文木が変わったときは、代わりに、稼働版と凍結版を相互変換する2つのマッピングモジュールを更新する。そうすれば拡張は動き続け、メインの構文木は自由に進化できる。 ### 元のフォーク元との互換性は制約ではない このコンパイラは別のコンパイラのフォークとして始まったが、プロジェクトは独自の構文木を保守しており、元のプロジェクトに対して破壊的変更を行ってもよい。上流プロジェクトがどうしているかではなく、このコンパイル対象にとって何が最善かで判断する。 ### 新しいキーワードの予約は避ける 予約語を1つ増やすと、その名前を既に使っている既存コードが壊れるため、新しい予約語を必要としない実装を優先する。新しいキーワードは最終手段とみなし、まずは既存の構文要素を再利用する書き方を探す。 ## 警告を黙らせたり仮のワイルドカードで済ませたりせず、原因を直す コンパイラを黙らせるために警告抑制の属性を使うことは絶対にしない。代わりに根本の問題を直す。抑制された警告は、その警告が拾うためにこそ存在する種類の間違いを、そのまま隠してしまう。 ### ワイルドカードは決定であって近道ではない 新しい言語機能のプレースホルダーとしてワイルドカードパターンを使い、そのまま忘れ去るようなことをしない。ワイルドカードは、その具体的なケースで値を無視してよいと確信できるときだけ使い、新しい言語機能がすべてのコンパイラ層で完全に扱われていることを確認する。 ### 結果は明示的に捨て、状態は通していく 関数を副作用のためだけに呼ぶ場合は、結果をワイルドカードに束縛するのではなく、明示的な無視用の関数を使う。状態を持つ結果は絶対に捨てず、呼び出しの連鎖の中を通していく。 ### パターンを正直に保つ小さな慣習 必須のラベル付き引数を既に持つ関数に、末尾のユニット引数を足さない。ラベルがある時点で、意図しない部分適用は防げている。レコードパターンの末尾のワイルドカードは、それが対象とする警告がこのコードベースで無効化されている場合には書かない。異なる中間表現に似た名前のコンストラクタがないか注意する。同じバリアント名でも、コンパイルのフェーズが違えば意味が違うことがある。 ## 直す前に、どのコンパイラ層を変えるのかを確かめる パイプラインは、パーサーでソーステキストを表層構文木に変え、フロントエンドの変換を適用し、型検査器で型付き木を作り、ラムダレベルの中間表現へ下ろし、JavaScript レベルの中間表現へ変換し、最後にコードを出力する。何かに手を付ける前に、自分が編集している層を特定する。 ### 層はディレクトリに対応する syntax ディレクトリはパースと表層構文を担当する。frontend ディレクトリは構文木の変換と外部関数インターフェイスの処理を担当する。型検査器のディレクトリは型検査と型付き木の変換を担当する。core ディレクトリはラムダレベルと JavaScript レベルの両方のモジュールを保持し、ファイル名の接頭辞で区別されている。拡張ユーティリティとデータ構造、対象エコシステム向けの型定義生成、そして言語サーバーとツール群は、それぞれ別のディレクトリに置かれている。 ### 変更はたいてい層をまたぐ 新しい言語機能は通常、パーサーの作業、構文木定義の更新、型検査の対応、ラムダレベルの処理、JavaScript の生成、そしてテストを、この順で必要とする。どこか1つの層を飛ばすと、パースは通るのにコンパイル結果が誤る機能ができあがる。 ## 各段階の中間表現を出して、バグの原因を探す コンパイラのバイナリは、各段階を出力するフラグを受け付ける。前処理後のソース、パース後のパース木、型検査後の型付き木、そして最適化前のラムダ表現である。コンパイラのソースを読み始める前に、各段階のダンプを自分の予想と突き合わせる。 ### 症状を、それを担当する層へ振り分ける 出力の整形に関する問題はプリティプリンタのモジュールに属する。型エラーは型検査器のモジュール群に属する。最適化後にだけ現れる誤った結果は、ラムダレベルの解析パスに属する。壊れた出力コードは JavaScript 生成のモジュール群に属する。 ### 小さく再現する 試行錯誤している間は、プロジェクト全体ではなく最小の1ファイルだけをコンパイルする。表現を覗く必要があるときはラムダプリンタのモジュールに一時的な出力を足し、その計測用コードは変更を確定させる前に取り除く。 ## 層を変えるたびに、その場でテストを足す 各コンパイラ層に手を入れたら、すべての変更が終わってからではなく、その時点でテストを足す。テストを後回しにすると、どの層がリグレッションを持ち込んだのかが隠れ、小さな二分探索が変更全体の調査に膨らむ。 ### 必ずテストが要るケース 新しい言語機能とバグ修正には必ずテストが要る。解析パスへの変更や JavaScript 生成の変更にも必ずテストが要る。生成された JavaScript は、正しさだけでなく品質も必ず確認する。 ### 適切なスイートをカバーする パーサーのテストは構文を検証する。統合テストは端から端までの挙動をカバーする。ユニットテストはコンパイラの個々の関数をカバーする。ビルドテストはエラーケースと境界ケースをカバーし、単一ファイルの型エラーや、実装ファイルとインターフェイスファイルを分けて用意する必要があるモジュール横断のエラーも含む。ラウンドトリップ、生成された型、解析、ツール群の各スイートはそれぞれ独自のターゲットを持っており、対応する領域を変更したときに実行する。 ## エラーと警告の一覧を、コードと同じ変更で更新する リポジトリは、コンパイラが出しうるすべてのエラーと警告を、モジュールごとにまとめたカタログを保持している。各項目は、フィクスチャに紐づけられているか、到達不能である理由が文書化されているかのどちらかである。このカタログはカバレッジの穴と、不要コードを整理する候補を見つけるための主要な道具なので、項目が古くなると、その両方の作業がやりにくくなる。 ### カタログはコードと同じ変更の中で更新する エラーバリアントを追加したり取り扱いを終えたりするときは、対応するモジュールの節でその行を追加または取り除き、状態を「カバー済み」「到達不能」「未対応」のいずれかに設定し、カバーしているフィクスチャへのリンクを張るか、そのバリアントに到達できない理由を記録する。フィクスチャを追加したり対象から外したりするときは、影響を受ける行のフィクスチャのパスと状態を更新し、カタログとテストスイートが決してずれないようにする。 ## コンパイルの速さと、出すJavaScriptの読みやすさを守る このコンパイラは速いフィードバックループのために設計されており、大規模なコードベースまでスケールする必要があり、その生成物は人間に読まれる。この両方はレビューの基準であり、後回しにしてよい事柄ではない。 ### 出力コードを読める状態に保つ 意味のない生成シンボルの出力を避け、出力を読める状態に保つ。正しいが読めない出力を生む変更は、まだ完成していない。 ### 前後を計測する すべての変更についてコンパイル速度への影響を検討し、ラムダレベルと JavaScript レベルで適切な最適化パスを使い、性能を目的とした変更ではコードから推論するのではなく前後でプロファイルを取る。 ## 名前の付け方の決まりに合わせ、利用者に見える変更を変更履歴に書く 自分が書いている言語の命名規則に合わせる。コンパイラの実装言語はスネークケースを使い、コンパイル対象の言語そのもので書かれたソースはキャメルケースを使う。コードベースの既存パターンに従い、車輪の再発明より既存のユーティリティ関数を優先し、複雑なアルゴリズムと自明でないロジックにはコメントを付け、合理的な範囲で後方互換性を保つ。 ### コミットには義務が伴う プロジェクトが要求する形式でコミットにサインオフし、すべての変更に適切なテストを添え、コミット前にビルドが通ることを確認する。 ### 利用者から見える変更を記録する バグ修正・機能追加・破壊的変更のいずれであれ、利用者から見える変更には変更履歴の項目を追加する。現在の未リリースバージョンの、対応する節の下に置き、行の末尾にはそのコントリビューションへのリンクを付ける。提出物にはこれが含まれていることが期待される。 ## ビルドシステムの新しいオプションを、通る場所すべてに足す 併設のビルドシステムはシステム記述言語で書かれており、高速な差分ビルドとより良いエラー報告を提供する。そのモジュールはきれいに分かれている。ビルド状態やモジュールレコードといったコアのデータ構造、コンパイルとコンパイラ引数の生成、構文木の生成とパーサー引数、パッケージの探索と依存関係の解決、モジュール依存グラフと循環検出、成果物の後始末、ビルドのログ出力とエラー報告、コマンドラインインターフェイス、プロジェクト設定のパース、ファイル監視、そしてエントリポイントである。 ### オプションは、それが通るすべての場所に足す ビルド用とウォッチ用の両方の引数型でフラグを宣言し、両者の変換処理を更新する。新しいプロジェクト設定の項目があれば設定パーサーを拡張する。関係するビルドモジュールを修正する。そのうえで、エントリポイントからビルド関数へ、ビルドの初期化へ、そしてビルド状態へと、パラメータを連鎖全体に通し、ウォッチ側の経路にも同様に通す。新しい挙動にはユニットテストを追加する。 ### 優先順位とエラー処理を先に決める コマンドラインのフラグはプロジェクト設定ファイルを上書きする。パニックさせるのではなく、プロジェクト標準のエラーラップ付き結果型を通じてエラーを伝播させ、開発時の診断にはデバッグレベルのログを使う。 ## 手元のコンパイラを指定して、ビルドシステムを直接動かす サブプロジェクトへディレクトリを移動するのではなく、継続的インテグレーションがそうしているように、リポジトリのルートからマニフェストファイルを指定して、ビルドシステム自身のツールチェーンコマンドを実行する。タスクランナーにも、ビルドシステムをビルドするショートカットと、その統合テストを走らせるショートカットが用意されている。 ### 開発用バイナリをローカルのツールチェーンへ向ける そのまま直接実行すると、バイナリはインストール済みのリリース版を拾ってしまう。実行する前に、コンパイラ実行ファイルのパスとランタイムのパスの環境変数を、どちらも現在のチェックアウト内の絶対パスに解決したうえでエクスポートする。なお、開発用バイナリはツール名ではなくパッケージ名で名付けられていることがあるので、決めつけずに実際に生成されたファイル名を確認する。 ### 挙動がはっきりしないときは詳細度を上げる ビルドシステムは、情報レベルとデバッグレベルのログのために、段階的な詳細度フラグを受け付ける。ウォッチのセッションが実際にどのフォルダを監視しているかといった詳細は、こうして確認する。 ### トレーシングは標準仕様に従う 分散トレーシングは、標準のテレメトリ環境変数だけで設定する。コレクタのエンドポイント、トレース専用のエンドポイント上書き、エクスポータのヘッダ、サービス名、リソース属性、ログレベルであり、プロジェクト独自のつまみはない。エンドポイントを設定することがテレメトリを有効にする条件で、どれも設定されていなければトレーシングは何もしない。トレースに現れてほしいイベントは、素のログ用マクロではなくトレーシング用マクロで発行する。両者は別々の仕組みだからである。 ## CIのテストは、決まった時間ではなく条件がそろうまで待つ 継続的インテグレーションのランナーは開発マシンより遅く、そのため固定の待機時間は壊れやすい。当て推量の時間だけ眠るのではなく、自分が本当に気にしている条件(たとえばファイルが現れるのを待つこと)をポーリングする。 ### 非同期の終了要求は完了ではない ロックファイルを取り除くなどしてウォッチャに終了を知らせるヘルパーは、シグナルを送るだけであり、プロセスの終了を待つわけではない。終了直前に設定変更のイベントを引き起こすことは避ける。ウォッチャが、こちらの想定しない再ビルドを並行して開始することがあるからだ。 ### プラットフォーム依存のテキスト編集を自作しない インプレースのストリーム編集は OS ごとに挙動が違うので、シェルテストではエディタを生で呼び出すのではなく、リポジトリの置換ヘルパーとパス正規化ヘルパーを使う。個別の統合スクリプトは、実行ファイルとランタイムのパスをエクスポートし、共有ヘルパーファイルを読み込み、そのスクリプト1本を呼び出すことで単独実行できる。これはスイート全体を走らせずに試行錯誤したいときに役立つ。 ## リンターの提案は、実際の性能で判断する リンターの提案は入力であって判決ではない。提案されたリファクタリングが性能や明瞭さを損なう可能性があるときは、機械的に適用するのではなく、そのトレードオフを明示的に判断する。 ### 複雑さは、見合うところでだけ受け入れる パラメータ用の構造体は、引数を複数並べるより自動的に優れているわけではない。本当に多くのパラメータが必要で、構造体にしても形式的な手間が増えるだけの関数では、そのリントを局所的に許可する。コピーは、小さく、一度きりで、所有権のルール上必要で、かつホットパスの外にあるなら許容できる。そこを回避するために過剰な作り込みをしない。 ### 最適化は根拠に基づいて行う 最適化の前にプロファイルを取る。ビルドシステムにおける性能上の懸念は、その多くが実際のコンパイル時間に比べれば無視できる。効率が本当に効いてくる場面では、境界のたびに所有型の文字列と借用型の文字列を変換するのではなく、呼び出しの連鎖に沿ってパラメータの型を揃える。そうした変換は、何の利点もなくアロケーションを増やすからだ。再コンパイルは変更のあったモジュールだけに限り、独立した作業は並列化し、大規模プロジェクトではビルド状態のメモリ使用量に注意し、ファイルシステムの操作を最小化する。