CLIエディタモード — ディスクバック`ProjectScan`スナップショットキャッシュ
ステータス:設計ノート。2026-05-18に著作。具体的なエディタ拡張需要が表面化するまで実装は先送り。LSPパス(インメモリProjectContext + Analysis::ProjectScan、v0.1.6で着地)はすでに典型的なエディタケースに対処する;このノートはCLIシェルアウトニッチの実装パスを記録し、次の実装者が冷たい状態から拾い上げられるようにする。
rigor check --tmp-file=X --instead-of=Y libはPHPStanスタイルのエディタ拡張がバッファ保存ごとにシェルアウトするCLIサーフェスである(docs/design/20260516-editor-mode.md §「CLIサーフェス」を参照)。各呼び出しは新鮮なプロセスなので、呼び出し全体で共有するインメモリProjectContextは存在しない。rigor自身のlib/に対するウォームキャッシュ呼び出しでの計測内訳(2026-05-17ベンチマーク):
| フェーズ | コスト |
|---|---|
| Ruby + bundlerブート + rigorライブラリロード | 約200 ms |
Environment.for_project(ディスクキャッシュヒット) | 約300 ms |
プレパス(Plugin::Loader.load、プラグイン#prepare、DependencySourceInference::Builder.build、SyntheticMethodScanner.scan、ProjectPatchedScanner.scan) | 約500 ms(プロジェクトサイズ依存) |
バッファパース + analyze_file | 約50〜100 ms |
合計: 約1050 ms。ブートフロア(累積約500 ms)は根本的;約500 msのプレパスコストが対処可能なヘッドルーム。
ディスクバックのスナップショットはウォームヒットでプレパスコストを削り取り、CLIエディタモードを約500 msの壁に持ってくる — PHPStanのバッファごとのフィードバックと競争力がある。LSPパスはすでにインメモリProjectScanキャッシュ経由でpublishあたり≤5 msを達成しているので、この最適化はCLIシェルアウトニッチのみ向け(LSPサポートのないエディタ、またはバッチツーリング)。
フェーズA — Marshalフレンドリーなスナップショット
Section titled “フェーズA — Marshalフレンドリーなスナップショット”既存のRigor::Analysis::ProjectScan(v0.1.6)値オブジェクトは6つのスロットをバンドルする;5つはMarshalフレンドリー、1つはそうでない:
| スロット | Marshalフレンドリー? | 備考 |
|---|---|---|
dependency_source_index | ✅ | プレーンなHash-of-CatalogEntryデータ。 |
synthetic_method_index | ✅ | 凍結されたSyntheticMethod Data値の凍結Hash。 |
project_patched_methods | ✅ | 凍結されたHash。 |
plugin_prepare_diagnostics | ✅ | Diagnostic Data値のArray。 |
pre_eval_diagnostics | ✅ | プレーンHashのArray。 |
plugin_registry | ❌ | プラグインインスタンスはPlugin::Servicesを保持し、Cache::Store(Mutex)、IoBoundary(Mutex)、Plugin::FactStore(これも可変)を持つ。 |
解決策。Rigor::Analysis::MarshalableProjectScanを導入する — 5つのMarshalフレンドリーなスロットPLUS plugin_registryが間接的に運んだプラグインごとに公開されたファクトスナップショット(#prepare実行後のPlugin::FactStoreの状態)。これはライブのプラグインインスタンスをドロップし、下流のディスパッチ層が実際に参照するデータのみを保持する。
MarshalableProjectScan = Data.define( :dependency_source_index, :synthetic_method_index, :project_patched_methods, :plugin_prepare_diagnostics, :pre_eval_diagnostics, :fact_store_snapshot # Hash[plugin_id => Hash[fact_name => marshalable_value]])ウォームロード時:
- スナップショットをMarshalロードする。
Plugin::Loader.load(configuration:, services:)を呼んでplugin_registryを再構築する(gemは前の実行からすでにrequire済み —Kernel.requireはfalseを返す;コストはPlugin.register/Blueprint作業によって支配される、約5〜20 ms)。- スナップショットされたファクトを再アタッチ、各プラグインの
services.fact_storeに、そのためディスパッチ消費者は#prepareを再実行せずに公開されたファクトを見る。 - 再水和された
plugin_registry+ 不変のスナップショットスロットからランタイムProjectScanを構築する。 Runner.new(prebuilt: ...)に渡す。
フェーズB — キャッシュキー導出
Section titled “フェーズB — キャッシュキー導出”スナップショットは、プレパス出力に影響するプロジェクト入力のいずれかが変更されたときに無効化されなければならない。キャッシュキー候補は:
SHA256( configuration_digest # .rigor.yml + bundler / collection軸 + plugin_manifest_digest # プラグインgemバージョン + プラグインごとの設定 + project_paths_digest # `paths:`-expanded .rbファイル、mtime + サイズ + pre_eval_paths_digest # pre_eval:ファイル、mtime + サイズ + dependencies_digest # dependencies.source_inference:設定)project_paths_digestが支配的なコスト: すべてのCLI呼び出しでmtime+sizeのためにpaths:を歩く。5000ファイルでFile.stat呼び出しに約250 ms。これは大規模プロジェクトでプレパス節約をほぼ相殺する。2つの緩和策:
- (α)ディレクトリmtime経由の安価な事前チェック。ほとんどのファイルシステムは、エントリーが追加または削除されたとき(内容が変更されたときではない)にディレクトリのmtimeを更新する。
paths:ディレクトリのみをmtimeのために歩く → 約ms。キャッシュされたスナップショットのビルド時間から変更されていない場合、ファイルリストが変更されていないと仮定し、ファイルごとのmtime走査をスキップする。ファイルごとのmtime+sizeはディレクトリのmtimeが変更されたときのみキックインする。これは実装者が採用前にベンチマークすべき高速パス最適化。 - (β)バッファのみのパス引数のキー導出をスキップ。
rigor check --tmp-file=X --instead-of=Yがpaths:を設定のプロジェクトルートにデフォルトして呼ばれるとき、導出は避けられない。呼び出し元が単一のファイルパス(rigor check --tmp-file=X --instead-of=Y lib/foo.rb)のみを渡すとき、キーはプレパスが消費するものだけをカバーすればよい — それでもなおpaths:下のプロジェクト全体、スキャナがプロジェクトを歩くため。だから(β)は役に立たない;(α)が正しいレバー。
フェーズC — ストレージ
Section titled “フェーズC — ストレージ”新しいプロデューサーでRigor::Cache::Storeを再利用する:
module Rigor module Cache class ProjectScanSnapshot PRODUCER_ID = "analysis.project_scan_snapshot"
def self.fetch(loader:, store:, configuration:) descriptor = build_descriptor(configuration) store.fetch_or_compute( producer_id: PRODUCER_ID, params: {}, descriptor: descriptor ) { build_fresh(configuration) } end
def self.build_descriptor(configuration) Descriptor.new( configs: [config_entry(configuration)], files: project_path_file_entries(configuration) + pre_eval_file_entries(configuration), plugins: plugin_entries(configuration) ) end
def self.build_fresh(configuration) # プロジェクトのみのRunnerをスピンアップ(バッファなし)、 # prepare_project_scanを呼び、fact_storeをスナップショット、 # MarshalableProjectScanを返す。 end end endendCache::StoreはすでにMarshalラウンドトリップ、シャード化ストレージ、ファイルごとのflock、ディスクリプタベースの無効化を処理する。新しいプロデューサーはディスクリプタ + 新鮮なビルドを提供するだけでよい。
フェーズD — Runner統合
Section titled “フェーズD — Runner統合”Runnerはすでにprebuilt:を受け入れる。CLIコマンドパスは次のようにスレッド化する:
def cli_run_check(configuration:, buffer_binding:) snapshot = Cache::ProjectScanSnapshot.fetch( loader: nil, store: cache_store, configuration: configuration ) prebuilt = rehydrate(snapshot, configuration: configuration, cache_store: cache_store) Runner.new( configuration: configuration, cache_store: cache_store, buffer: buffer_binding, prebuilt: prebuilt ).run([buffer_binding.logical_path])endrehydrateはフェーズAで記述されたPlugin::Loader.load + fact_store再アタッチを行う。
フェーズE — 書き込み時のスナップショットの鮮度
Section titled “フェーズE — 書き込み時のスナップショットの鮮度”キャッシュキーがミスを示すとき、新鮮なビルドはプロジェクトのみのrunnerに対してRunner#prepare_project_scanを実行し、それからシリアライズする:
- 5つのMarshalフレンドリーなスロットをそのままスナップショット。
- プラグインごとのfact_storeをスナップショット:
plugin_registry.pluginsを反復、plugin.services.fact_store.factsをキャプチャ(またはFactStoreのアクセサが公開するもの —Plugin::FactStoreに#snapshot_for_cacheメソッドが必要かもしれない)。 - Marshalフレンドリーな値のみ。非Marshalフレンドリーなファクト(Mutex、Procなど)を公開するプラグインはスナップショットを壊す — プロデューサーはレスキューし「この設定にはキャッシュなし」に縮退するか、FactStoreのスナップショットメソッドが違反するプラグインを指す明確なエラーをraiseすべき。
実装者へのオープン質問
Section titled “実装者へのオープン質問”- FactStoreスナップショットAPI。
Plugin::FactStoreは現在「すべての公開されたファクトをシリアライズ」サーフェスを公開しない。正しい形は、ストアがプラグインごとにファクトをキー付けするかどうか(する、ADR-9に従い)と、値がMarshalフレンドリー型に制約されるかどうか(今日明示的な制約はない)に依存する。小さなPlugin::FactStore#to_snapshot/.from_snapshotペアが統合をスコープする。 - Marshalバージョン安定性。
Cache::StoreはすでにSCHEMA_VERSIONでキーするので、Rubyバージョンバンプはエントリーを無効化する。MarshalableProjectScanスナップショットはこの不変条件を継承する。 - プラグインgemバージョンのピン留め。プラグインアップグレードはスナップショットを無効化すべき。今日の
Cache::Descriptor::PluginEntryはversion:+config_hash:を含む — プロデューサーのディスクリプタはプラグインごとにこれらのいずれかを含まなければならない。 - プレパス診断の再発行順。
plugin_prepare_diagnosticsスナップショットは、ソースプラグインが発行した順序を保持しなければならない、そうすればCLI診断ストリームはコールド / ウォーム実行をまたいで安定したまま。MarshalラウンドトリップはArray順を保持する — specで検証。 - キャッシュ書き込み競合。スナップショットを書く2つの
rigor check呼び出しがプロデューサーのキャッシュファイルで衝突する。Cache::Storeはファイルごとのflock経由ですでにこれを処理する;最初のライターが勝ち、2番目は計算した値を破棄する。
期待される勝利
Section titled “期待される勝利”| プロジェクトサイズ | CLIエディタモードのウォーム壁(今日) | スナップショットキャッシュ後 | Δ |
|---|---|---|---|
| 些細(プラグインなし) | 約500 ms | 約500 ms | 0(プレパスはすでに安価) |
| 小Rails(5プラグイン) | 約700〜900 ms | 約500〜550 ms | -200〜-350 ms |
| 中Rails(10プラグイン + 基板) | 約1000〜1500 ms | 約550〜650 ms | -450〜-850 ms |
| 大規模モノレポ(5000+ファイル、基板使用プラグイン) | 2+秒 | 約700 ms | > -1.3秒 |
勝利はプラグイン / 基板 / ファイル数でスケールする。
なぜこれが先送りされるか
Section titled “なぜこれが先送りされるか”- LSPがエディタケースの90%+をカバー。
rigor lsp(v0.1.6)が推奨されるエディタ統合。publishごとの作業はウォームで≤5 ms。LSPを話せるエディタ拡張はそのパスを使うべき。 - 実装サーフェスエリアが実質的。プラグインファクトのMarshalフレンドリーさはプラグイン契約が公開する(または不透明に壊す)新しい不変条件。FactStoreがMarshalフレンドリーさを強制するか単に優雅に縮退するかの決定は実質的なADRレベルの質問。
- 具体的なエディタ拡張消費者は今日存在しない。CLIエディタモードのCLI形はv0.1.6(
--tmp-file/--instead-of)で出荷されたが、我々が知るエディタ拡張は保存時にそれにシェルアウトしていない。1つが表面化し>500 msの壁をUX問題として報告したとき、このスライスは改善のブロックを解除する。
隣接するレバー(優先順位低)
Section titled “隣接するレバー(優先順位低)”- LSP / CLI全体のインメモリ
Environment.for_projectキャッシュ。v0.1.6はすでにLSPProjectContextでEnvironmentをキャッシュする。CLIはそのキャッシュを共有できないが、Environment構築自体は既存のオンディスクCache::RbsEnvironmentからのMarshal.loadによって支配される — すでにウォームキャッシュ最適化済み。 - CLIブートコストを削減。約200 msのブートはRuby + bundler + rigorライブラリロード。それを削除するには永続デーモン(= LSP)が必要。スコープ外。
このスライスが拾い上げられるとき:
docs/CURRENT_WORK.md§「オープンエンジニアリング項目」を更新。CHANGELOG.mdに[Unreleased]§ Performance下のエントリーを追加。- コミットでこの設計ノートを参照。
spec/rigor/public_api_drift_spec.rbスナップショットにPlugin::FactStore#to_snapshot/.from_snapshotを追加。
© 2026 TypedDuck. Licensed under CC BY-SA 4.0.