コンテンツにスキップ

エディタモード — 単一ファイル高速応答解析

Status: Draft. まだスライスされていない。将来ADRが起票された場合に のみ取って代わられる。

今日のRigorはプロジェクト指向である。rigor check libpaths:配下の すべての.rbを走査し、Environmentを1つ構築し、診断を出力する。エディタ /IDE/LSPのユースケースはその逆であり、ユーザーは単一のバッファを編集 し、秒単位ではなくミリ秒単位のフィードバックを期待する。本ドキュメント は、今日フルLSPにコミットすることなくそのワークロードをサポートするた めにRigorが必要とするCLI表面とランナー側の置き換えを設計する。

契約の形はPHPStanの「Editor Mode」(references/phpstan/website/src/user-guide/editor-mode.mdを参照)を踏襲する。 設計上の問題が同じだからである。すなわち、エディタは未保存のバッファを 保持し、外部ツールがそれをテンポラリファイルへ書き出し、解析器はその テンポラリファイルがプロジェクト内の1つのファイルを置き換えたかのよう に振る舞わなければならない。

ADR-0はLSP統合を意図的に先送りし、CLIファースト の推論エンジンを成熟させることを優先した。その決定は今も有効である。 ただし、「CLIファースト」が「プロジェクト専用」を意味する必要はない。 エディタ拡張はバッファ保存ごと/デバウンスされたキーストロークごとに シェルアウトし、診断ストリームを1つ受け取ってインライン表示すればよい。 「エディタ駆動Rigor」のMVPは、バッファを受け取って当該バッファの診断 を高速に返すCLI起動である。

今日「高速」が意味すること。

  • コールドスタート(キャッシュなし、フルプリパス): rigor checkの プロジェクト全体セットアップコストに律速される。本設計の対象外。
  • ウォームスタート(キャッシュあり、他箇所のソース変更なし): 現行ラッ プトップ上の5Kファイル規模プロジェクトでバッファあたり1秒未満を 目標とする。単一ファイル解析パス自体はすでに高速である。ボトルネック は、バッファ1つだけ変更されたときでもRigorがプロジェクト全体を再走査 することにある。

rigor checkは、論理的なプロジェクトパスを物理的なバッファファイルに バインドする、ペアになった2つのオプションを得る。

Terminal window
rigor check \
--tmp-file=/tmp/9539itfeh2.rb \
--instead-of=lib/foo.rb \
lib

意味論。

  • --tmp-file=PATH — Rigorがバイトをパースしなければならない物理 ファイル。存在し読み取り可能でなければならない。ファイルが欠けている 場合は終了コード64(usage)。
  • --instead-of=PATH — バッファが表す論理的なプロジェクトパス。解析器 はlib/foo.rb/tmp/9539itfeh2.rbの内容を持っていたかのように振る 舞う。診断はpath: lib/foo.rbを報告しなければならない(MUST)。これ によりエディタは正しいバッファをハイライトする。
  • 2つのフラグは一緒に現れなければならない(MUST)。片方だけは使用法 エラー(EXIT_USAGE)。
  • --instead-of=PATHの元ファイルは解析されない。パス展開に含まれて いる場合(典型例)は、バッファを優先して暗黙にスキップされる。
  • マルチバッファ(--buffer A=B --buffer C=D)はv1の対象外。単一バッ ファコマンドはデバウンスされたキーストロークの合間にエディタが呼び 出すものである。マルチバッファはLSPデーモンが保存イベントを多重化 するようになって初めて興味深くなる。

同じフラグ対はrigor type-of(エディタがcheckよりも頻繁に呼び出す ホバーから型を得るユースケース)にも拡張される。type-ofはPrism境界で すでにsource:filepath:を分離しているため、そこでの置き換えは 1行の配管変更で済む。

rigor type-scanrigor explainrigor diffrigor sig-genは エディタモードのフラグを得ない。これらはプロジェクト全体またはストリ ーム形状のツールであり、バッファごとのプローブではない。

ランナーへの置き換えのマッピング

Section titled “ランナーへの置き換えのマッピング”

ランナーは今日、ファイルを3つのレイヤーで処理する。

  1. パス展開Analysis::Runner#expand_pathspaths:リストを 具体的な.rbファイルの[String]に解決する。
  2. ファイルごとのパースRunner#analyze_file(逐次)と WorkerSession#analyze(プールモード)が各パスに対して Prism.parse_file(path, version:)を呼ぶ。
  3. プロジェクトプリパスSyntheticMethodScannerProjectPatchedScanner、プラグインの#prepare、依存ソースウォーカー ーがそれぞれ自身の入力セットを読む。

エディタモードは3つのレイヤーすべてに適用される置き換えである。

レイヤー逐次的な変更理由
展開ファイルリストではlib/foo.rb/tmp/9539itfeh2.rbに置き換えるが、元のパスは論理的な同一性として記憶する。エンジンがバッファを他のファイルと同様に扱えるようにしつつ、論理パスを報告できるようにする。
パースPrism.parse_file(physical, version:)もしくはPrism.parse(File.read(physical), filepath: logical, version:)。後者が望ましい。パース結果ソースのfilepath:を最初から論理パスに等しくできるためである。診断の位置情報が自動的に論理パスを使う。
プリパス同じ置き換え。各スキャナのパースステップはバッファバインディングを認識するヘルパー1つを経由する。プリパスはバッファのバイトを参照しなければならない。さもなくばプラグインファクト/プロジェクトパッチレジストリ/合成メソッドが進行中の編集を見逃す。

配線ポイントはBufferBindingと呼ぶ1つの値オブジェクトであり、 Runner.new(... buffer: BufferBinding.new(logical:, physical:))を通じて スレッディングされる。デフォルトをnilにすることでレガシーパスはビット 単位で変わらない。

BufferBinding = Data.define(:logical_path, :physical_path) do
def resolve(path)
path == logical_path ? physical_path : path
end
def display_path(path)
path == physical_path ? logical_path : path
end
end

2つのヘルパー(読み込み用のresolve、診断出力用のdisplay_path)が、 現在パスを消費するすべての呼び出し箇所をカバーする。既存のワンショット Diagnostic.new(path: path, ...)呼び出し箇所は、論理パスを直接渡すか (パースがすでにfilepath: logicalを見ているため)、出力前のランナー 境界でbinding.display_path(path)を経由するかのいずれかになる。

PHPStanのエディタモードは結果キャッシュを復元するが保存はしない。 RigorのCache::Store(ADR-6)は内容アドレス指定でシャーディングされ、 エントリーごとにflockを持つ。読み込みはロックフリーで、書き込みはエント リごとにアトミックである。2つの変更を行う。

  • 読み取り専用モードCache::Store::ReadOnlyラッパー(または Storeのフラグ)が#fetch_or_computeの書き込み副作用をすべて抑制す る。プロデューサーブロックはミス時には引き続き実行される。結果は呼び 出し元に返されるが永続化されない。既存のディスク上エントリーはそのまま ヒットを提供する。
  • 並行安全性 — 同じキャッシュルートに対する複数のエディタモード 実行は安全である。書き込み手がいないためである。ADR-6のファイルごと flock不変条件は変わらない。

エディタモードは--tmp-fileがセットされたときキャッシュを自動的に読み 取り専用に強制する。--no-cacheは依然として機能する(ディスク読み込み もスキップする)。

今日のRigorはファイルごとの診断キャッシュを持たない。PHPStanの 「編集されたファイルのみが再解析される」速度はそれに依存しているため、 今日Rigorが提供できる最速のパスは「インクリメンタルなプロジェクト」で はなく単一ファイルスコープである。後述の「スコープ選択」セクション を参照。

スコープ選択 — 何が解析されるか

Section titled “スコープ選択 — 何が解析されるか”

実行可能な形は3つ。

  • (A)単一ファイルスコープ--tmp-fileがセットされたとき、 Rigorはバッファのみを解析する。paths:の残りはEnvironmentコンテキ スト(RBS、プラグインファクト、合成メソッドインデックス、プロジェク トパッチレジストリ)として読み込まれるが、他のファイルに対する診断は 出力されない。
  • (B)バッファ置き換えを伴うプロジェクトスコープ。PHPStan形。 プロジェクト全体が解析される。編集されたファイルは置き換えられる。 高速化にはファイルごとの診断キャッシュが必要だが、Rigorはまだそれを 持たない(ADR-17スライス3bがキューイングされたレバー)。
  • (C)単一ファイル+呼び出し側が宣言した依存先。エディタはバッファ の公開面(戻り型、定数値、エクスポートされたモジュール)に依存するこ とが分かっているファイルに対して--also=lib/bar.rb,lib/baz.rbを渡 す。

v1は(A)を出荷する。速度目標を達成する最小のカットであり、前方互 換である。ファイルごとの診断キャッシュが存在するようになったとき、同じ CLI形状はフラグの改名なしで(B)にアップグレードできる。

エディタ拡張は単一ファイル起動を複数回(影響を受けるファイルごとに1回) 発行することで(A)の上に(C)を重ねられる。Rigorはファイルごとのキャ ッシュが存在するまで、呼び出し側に依存追跡を提供する責務を負わない。

プロジェクトプリパスとの相互作用

Section titled “プロジェクトプリパスとの相互作用”

SyntheticMethodScannerProjectPatchedScanner、プラグインの #prepare、依存ソースウォーカーはそれぞれ、ファイルごとの解析が走る前 にプロジェクト全体の状態を構築する。単一ファイルエディタモードでは3つ の条件が成り立たなければならない。

  • プリパスは論理パスにあるバッファのバイトを参照する。 BufferBinding.resolveがそれらのパースヘルパーを通じてスレッディング される。
  • プリパスは入力が変わっていない場合、悲観的にキーストロークごとに再実 行されない。v1は起動ごとに再実行する(小中規模プロジェクトでは安価寄 りだが無料ではない)。後続作業として、(プラグインマニフェストのダ イジェスト、プロジェクトファイルのmtime + サイズリスト)をキーとする プロジェクトコンテキストスナップショットキャッシュを設計する。
  • プラグインの#prepareはエディタモード起動ごとに1回実行される。これは 今日と同じである。プラグイン横断ファクトを公開するプラグイン (:dry_type_aliases:helper_table:model_index)は、繰り返し のエディタモード実行が収束するように冪等でなければならない(MUST)。 ADR-9の設計によりすでに冪等である。

ADR-15フェーズ4bのRactorプールはRBSキャッシュをウォームアップし、N個の ワーカーを生成する。1ファイル実行ではプールのウォームアップコストが ウォール時間を支配する。よってエディタモードは--tmp-fileがセットされ ているとき、--workers=NRIGOR_RACTOR_WORKERSparallel.workers: に関係なくworkers: 0(逐次)を強制する。このオーバーライドはサイレン トである。プールモードはプロジェクト規模のつまみであり、エディタモード はバッファごとのものである。

診断の順序とインライン無効化マーカー

Section titled “診断の順序とインライン無効化マーカー”
  • # rigor:disable <rule>の行末マーカーはパース結果ソースの parse_result.commentsから来る。これはバッファのソースそのものであ る。バッファの現在の行番号を自然に追跡する。特別な処理は不要。
  • プロジェクトレベルの.rigor.ymldisable:キーはパス非依存であり そのまま適用される。
  • 重要度プロファイル+ルールごとのオーバーライドはそのまま適用される。

RunStatsはデフォルトで有効である。エディタモードでもオンのままにし、 エディタのログ表面が「analysed lib/foo.rb (buffer) in N ms, wall: Xs, RBS classes: K」を表示できるようにすべきである。統計オブジェクトに新た なフィールドが1つ加わる: buffer_logical_path: String(エディタ以外の 実行ではnil)。テキストサマリーは存在するとき(editor mode: lib/foo.rb) を末尾に追記する。JSON消費者はフィールドを直接見られる。

  • --tmp-file=X--instead-of=Yを伴わない、もしくは逆 → 終了コード 64、usage: --tmp-file and --instead-of must appear together
  • --tmp-file=XXが読み取り不可 → 終了コード1、読み込み失敗を説明 するDiagnostic(path: '.rigor.yml', severity: :error)が1つ。
  • --instead-of=YYがどのpaths:ディレクトリ配下にもない場合 → バッファの有効な論理同一性として扱われ、バッファは解析される。これは 意図的である。エディタは時として、paths:に正式に属さないファイル (例: spec/paths:にない状態で解析されるspec/配下のファイル) に対してRigorを呼び出す。
  • バッファのパースエラーは今日のパースエラー診断として、path: lib/foo.rbで出力される。
  • マルチバッファ(--buffer A=B --buffer C=D)。
  • LSPデーモン、永続プロセス、ファイル監視。
  • ファイルごとの診断キャッシュ(ADR-17スライス3bの領分)。スコープ形状 (B)のブロックを解除する。
  • プリパス再利用のためのプロジェクトコンテキストスナップショットキャッ シュ。LSPパスについては着地済みである。Rigor::Analysis::ProjectScanRunner#prepare_project_scanRunner.new(prebuilt:)(v0.1.6)と して。LSPのProjectContextはスナップショットを遅延構築し、 DiagnosticPublisherは発行ごとのRunner.newそれぞれにそれをスレッデ ィングする。CLI rigor check --tmp-fileはまだスナップショットを消費 しない。各起動は新しいプロセスである。(プラグインマニフェストの ダイジェスト、プロジェクトファイルのmtime + サイズリスト)をキーと するディスク裏付けのスナップショットキャッシュがあれば、ワンショット CLI起動でもプリパスをスキップできる。需要駆動。
  • 呼び出し側が宣言した依存ファイル(--also=...)。(A)が出荷された 後は些細なCLI拡張。エディタ拡張が実際に必要とするまで先送り。
  • rigor type-of境界でのキャッシング。type-ofのエディタモードは、 既存の呼び出しごとのパスがすでに十分安価であるのと同程度に安価である べきである。
  1. BufferBinding値オブジェクトRunnerパラメータの配管。デフォ ルトnil。既存テストはグリーンのまま。
  2. Runner#analyze_fileWorkerSession#analyzeがバインディングを 尊重すること(パース+診断出力)。単一ファイルエディタモードの統合 specがハッピーパスをカバーする。
  3. Cache::Store::ReadOnlyラッパー+CLIが--tmp-file下で自動的に 有効化する。Spec: 空のキャッシュルートに対するバッファモード実行は 後でもキャッシュルートを空のままにする。
  4. rigor checkへのCLIフラグ+使用法/エラーエンベロープ。Specは ペア欠落/ファイル欠落のケースをカバーする。
  5. 単一ファイルスコープモード--tmp-fileがセットされたとき、 ランナーはバッファのみを解析する(オプションA)。他のファイルは Environmentコンテキストにのみ寄与する。プリパスはバッファ置き換えを 伴って起動ごとに1回再実行される。Spec: Nファイルを持つプロジェクト に対するバッファモード実行はバッファに対してのみ診断を生成する。
  6. rigor type-ofのエディタフラグ — 同じ--tmp-file--instead-ofの意味論。Spec: バッファ内(line, col)をホバーすると ディスク上のファイルではなくバッファのバイトから導出された型を報告 する。
  7. Ractorプールが逐次に降格すること(エディタモード下)。CLIは警告 を表示しない(オーバーライドは契約の一部であり、プール故障ではない)。 Spec: --workers=4 --tmp-file=...は逐次で動く。

スライス7のあとでv1の契約は完成する。キューイングされた項目(プロジェ クトコンテキストスナップショットキャッシュ、ファイルごとの診断キャッ シュ、--also、マルチバッファ)のスライスは、エディタモードを消費する エディタ拡張が具体的な必要を示したときに別途切られる。

  • エディタモード下のプラグイン信頼ポリシー。バッファファイルは許可 された読み取りルートのいずれにも属さないかもしれない。今日のプラグイ ンはPlugin::IoBoundaryを通じてI/Oポリシーを強制する。決定: 物理 ファイルの読み込みはRigorのランナーが行い、プラグインは行わない。 バッファのバイトはScopeレベルの状態を通じて流れ、 IoBoundary#read_fileを通らない。よって信頼ポリシーには影響しない。 プラグインがバッファの論理パスを再度読み込む必要がある場合(稀 — rigor-actioncable/rigor-rails-i18nは読まない)、ディスク上のファイル を見ることになりバッファは見ない。これはv1の設計問題ではなく、文書化 されたエッジケースである。
  • --cwd=PATHフラグを公開するかどうか。これによりエディタはプロ ジェクトルート外からRigorを実行できる。PHPStanは公開している。Rigor はConfiguration.discover経由で設定を解決する。エディタ拡張が作業 ディレクトリの仮定が自分たちには合わないと報告するまで決定を先送り する。
  • --instead-ofがディスク上でパースエラーを持つファイルを指したとき にどうするか(ディスク上のバージョンにエラーがあるがバッファでは 修正されている場合)。今日のランナーはpaths:を展開し、ディスク上 ファイルのパースエラーを含めてしまうだろう。決定: エディタモード下 では論理パスのディスク上ファイルは丸ごとスキップされる。バッファのみ がパースされる。

© 2026 TypedDuck. Licensed under CC BY-SA 4.0.