プラグイン機構 1.0 前最終レビュー — 過不足・ペインポイント・ボイラープレート
Status: Research / pre-1.0 optimization review. 非規範。受理された項目は個別ADR (主にADR-2の改訂)とinternal-specにgraduateする。 本ノートは「正式リリース前に直すべきか/1.xに送るか」を仕分けるための棚卸し。
対象: plugins/ 31エントリ + examples/ 6ウォークスルー + コア
(lib/rigor/plugin/, lib/rigor/source/)のプラグイン向け表面。
2026-06-01時点のツリーに対する横断調査。各指摘はfile:lineで裏取り済み。
0. エグゼクティブサマリ
Section titled “0. エグゼクティブサマリ”プラグイン契約そのもの(ADR-2が約束したScope / Type / Reflection / FactStore /
IoBoundary / 各manifestフィールド)は実装されており機能している。
Scope#type_ofをはじめADR-2が約束したエンジンクエリはpluginに渡る
scope:経由で実際に露出している(gateされていない)。
問題は契約の有無ではなく、契約と作者の間に「著者向けユーティリティ層」が無いこと。 その結果、ほぼ全プラグインが同じ補助コードを再実装し、しかも再実装の過程で 微妙な差異(インフレクタ2種、camelize 2種、describe判定2種…)と 実害のあるキャッシュ不整合バグを生んでいる。
優先度の高い順に:
- 【バグ・要修正】 factorybot / pundit / sidekiqが
cache_forにdescriptor:を渡さず、プロセス跨ぎでdiscovery indexが無効化されない (ファイル編集してもウォームキャッシュがstaleを返す)。 - 【契約ギャップ】コアに
Source::NodeWalker等が存在するのにpluginに 露出しておらず、diagnostics_for_fileのdocstringは「自分でrootを走査せよ」と 明示。著者向けヘルパー層(walker / diagnosticビルダ / リテラル抽出 / did-you-mean / config既定値)の欠如が全ボイラープレートの根本原因。 - 【契約ギャップ】
Manifest#with(**overrides)が無く、rbs-inlineが manifest 20フィールドを手書きコピーしている(フィールド追加で確実に腐る)。 - 【1.0前に判断】 produced-but-unconsumedなADR-9 fact(graphql ×4 / dry-validation / dry-schema)と、docstringが約束するのに未実装の診断が複数。 「公開契約として1.0に載せるか」を意図的に決める必要がある。
- 【アーキテクチャ・1.0前に判断】現行のfat
Plugin::Base(多数の任意 フックを持つ単一クラス)をPHPStanのようにnarrow interfaceへ分割すべきか → §6。結論を先取りすると、Rigorは既にmanifest宣言フィールド10個で PHPStan型の分割を達成しており、残るimperativeフック2個 (flow_contribution_for/diagnostics_for_file)だけが「全員呼び出し・自前 ゲート」のholdout。AIエージェントの把握しやすさ・テスト容易性の最終目標は、 この2個を同じ宣言的・engine-gatedパターンへ寄せることで最もよく満たせる。 フックのシグネチャは1.0で公開契約として凍結されるため、分割するなら今。 - 【拡張種別の選別取り込み】 PHPStanの拡張種類のうちRubyで実需がありRigorに
未実装のものを選別 → §7。最有力は
AdditionalConstructorsのRuby版 =additional_initializers:(ivar型シードをinitialize以外のrspecbefore/ minitestsetup/ Rails callbackにも開く小機能、FP規律に直撃)。次点がsealed / 網羅性(AllowedSubTypes版、ADR-36を完遂)。ResultCacheMeta等は実装済みなので 作らない。
1. ボイラープレート(最大の発見)
Section titled “1. ボイラープレート(最大の発見)”1.1重複の規模(機械計測)
Section titled “1.1重複の規模(機械計測)”| 再実装パターン | 件数 | 代表箇所 |
|---|---|---|
AST再帰ウォーカー(def walk / compact_child_nodes.each) | 25プラグイン | statesman.rb:152, actionpack(4コピー/1ファイル) |
Rigor::Analysis::Diagnostic.new直接構築(column: start_column+1) | 23プラグイン | 全diagnostic系 |
| PrismノードからリテラルSymbol/String抽出 | 20プラグイン | statesman.rb:145,コア内でも4重複 |
config.fetch("x", DEFAULT_X) + DEFAULT_*定数 | 17プラグイン | statesman.rb:59-67 |
rescue StandardError → @load_error一回限り発行 | 10プラグイン | pundit.rb:102-118 |
levenshtein / did_you_mean自前実装 | 4プラグイン | statesman.rb:159-192, routes ↔ activerecordは逐語コピー |
定数パスserializer(constant_path_name/qualified_name_for) | ~12箇所(pundit/sidekiq/rspecは1ファイル内2コピー) | sorbet ×4, lisp-eval, units |
discoverer骨格(walk_for_X+visit_class+read_safely+ruby_files_under) | activejob/actioncable/activestorage/actionmailerほぼ逐語 | job_discoverer.rb |
indexクラス(frozen @by_name + find/known?/empty?/size/names) | JobIndex/ChannelIndex/MailerIndex/WorkerIndex/PolicyIndex/FactoryIndex | worker_index.rb:12が「同じ封筒形」と自認 |
1.2根本原因 — コアにあるのに露出していない
Section titled “1.2根本原因 — コアにあるのに露出していない”| ヘルパー | コアに存在? | pluginに公開? |
|---|---|---|
| ASTウォーカー | あり Rigor::Source::NodeWalker(node_walker.rb:17-35、.each(root) Enumerator) | ❌ Services非注入・drift spec非掲載。base.rb:168-171が「自分で走査せよ」と明示 |
| ノード→Diagnostic行 | 部分的(Analysis::Diagnosticはあるがfrom_node無し。コアもcheck_rules.rbで15+ 箇所インラインstart_column+1) | ❌ ヘルパー無し |
| リテラルSymbol/String抽出 | ロジックはコア内で4重複(observation_collector.rb:310, generator.rb:895, return_type_heuristic.rb:78, synthetic_method_scanner.rb:544) | ❌ 抽出されていない |
| levenshtein / did-you-mean | 無し(Ruby標準DidYouMean::SpellCheckerはある) | ❌ net-new |
| config既定値 | 無し(config_schemaはkind検証のみ、defaultスロット無し) | ❌ |
→ コアが既に持つ #1(walker)・#3(リテラル抽出)を露出するだけで、plugin側の コピペ表面の大半が消える。しかも #3はコア内の4重複も同時に解消できる (双方向で元が取れる、最高ROI)。
1.3提案する著者向け層
Section titled “1.3提案する著者向け層”ADR-2改訂として、以下をRigor::Plugin::Baseのインスタンスヘルパー
(またはPlugin::AstSupport mixin / services.アクセサ)で提供:
walk(root) { |node| }/each_node(root)←Source::NodeWalkerを再エクスポートdiagnostic(node, rule:, severity:, message:)←start_column+1規約を内包。 併せてDiagnostic.from_node(...)をコアにも入れてcheck_rules.rbのインラインを統一literal_symbol(node)/literal_string(node)/symbol_arguments(call)←Rigor::Source::Literalsを新設、コア4重複も巻き取りsuggest(name, candidates)←DidYouMean::SpellCheckerラップ。 statesman/routes/activerecordの自前levenshteinを全廃- config既定値:
config_schemaのエントリ形を{kind:, default:}に拡張し、Base#configがconstruct時に既定値をマージ。DEFAULT_*定数イディオムを撤廃 (Manifestスキーマ変更 → ADRノート必須)
1.4抽出すべき共通抽象(より大きな単位)
Section titled “1.4抽出すべき共通抽象(より大きな単位)”著者向けヘルパーの上に、繰り返される「プラグインの型」を基底クラス化:
ProtocolContractChecker基底(ADR-28系)— hanamiActionCheckerと webProtocolCheckerはpath_matches?/class_nodes/direct_defs/collect_direct_defs/singleton_def?/walk/class_nameが逐語一致。 ADR-28プラグインが増えるほど線形に重複。arityチェック有無も揃う。ClassDiscoverer基底 +NameKeyedIndex— Rails discovery系 (activejob/actioncable/activestorage/actionmailer)のdiscoverer + indexを base + 小さな抽出ブロックに圧縮。約4ファイル分のAST走査が消え、 将来のPrismノードバグを4重に直す必要がなくなる。SourceScannermixin(宣言収集系)— dry-types/dry-schema/dry-validation/ graphql/statesmanがscannable_paths/scan_file-rescue /tree_walk/constant_name_forを再実装。しかもnil返し /::前置 / tail-matchと 挙動が割れており、それ自体がcorrectnessリスク。1つの正規実装に統一。Plugin::Testing::Narrowing— rspecMatcherAnalyzerとminitestAssertionAnalyzerがliteral_value_for/nominal_type_for/FlowContribution::Fact構築を逐語重複(ソースコメントが重複を自認)。Plugin::Inflector— routesが2つ・activerecordが1つ・ actionmailer/actionpackがunderscoreを計4コピー。 routes_parser.rb:1498-1534は「片方が他方を採用できるまで同期」と自認。
2. キャッシュ・I/O・信頼境界
Section titled “2. キャッシュ・I/O・信頼境界”2.1【バグ】descriptor無しdiscoveryキャッシュ(要修正)
Section titled “2.1【バグ】descriptor無しdiscoveryキャッシュ(要修正)”factorybot / pundit / sidekiqはcache_for(:index, params: {})を
descriptor:無しで呼ぶ(factorybot.rb:142, pundit.rb:105, sidekiq.rb:99)。
するとcache keyは「同プロセス内でIoBoundaryが既に読んだファイル」だけに依存し、
フレッシュプロセスでは空 → policy/worker/factoryファイルを編集してもウォーム
rigor checkがstaleを返す。base.rb:298-310のdocstring自身がこれを
「discovery系は必ずglob_descriptorを渡せ」と警告している。
修正: cache_for(:index, descriptor: glob_descriptor(@search_paths, "**/*.rb"))。
factorybotの自前prime_io_boundary_for_index(glob_descriptorの劣化再発明)は削除。
2.2信頼境界バイパス
Section titled “2.2信頼境界バイパス”- rbs-inlineの
Synthesizer#callがFile.readを直接使用 (rbs_inline.rb:62-67)。他プラグインが守るio_boundary/TrustPolicyを 経由しない契約ギャップ。 - 一方examples/rigor-routes(routes.rb:98-106)は「read_file → digest記録 → cache_for」の順序依存を正しく教えるが、順序を崩すとサイレントに無効化が 壊れる脆さ。コアに「このproducerが依存するファイル群」を宣言的に渡すAPIが あれば順序依存自体が消える。
2.3 2つのキャッシュ起動イディオム
Section titled “2.3 2つのキャッシュ起動イディオム”glob_descriptor(...)渡し(i18n/actionmailer/actioncable)と
read-then-cache_for(routes/activejob/activerecord/actionpack)が混在。
同じ「初回descriptor空」問題を別々に解いている。1つに標準化すべき。
3. Manifest / 契約面
Section titled “3. Manifest / 契約面”3.1 Manifestフィールドの肥大とwithの欠如
Section titled “3.1 Manifestフィールドの肥大とwithの欠如”Manifestは21フィールド・各validate_*!を持つまで成長
(manifest.rb:43-83)。これ自体は段階的拡張の結果で妥当だが、コピー手段が無い。
rbs-inlineはsynthesizerを後付けするためmanifest 20フィールドを
手書きで逐語コピーしている(rbs_inline.rb:136-158)。新フィールド追加で確実に腐る。
→ Manifest#with(**overrides)をコアに追加(最優先の小修正)。
併せてrbs-inlineは唯一initでなくinitializeをoverrideしており
(rbs_inline.rb:111-122)テンプレとして悪い前例 — init規約へ寄せる。
3.2 RBS-onlyプラグインのセレモニー
Section titled “3.2 RBS-onlyプラグインのセレモニー”activesupport-core-extは「signature_paths: ["sig"]だけの空Baseサブクラス +
register」(activesupport_core_ext.rb:23-33)。ADR-25の正規形ではあるが、
analyzerコード皆無の純RBSバンドルに約12行の定型クラスを強制している。
.rigor.ymlのgem列挙だけでsignature_pathsを取り込める宣言的経路を検討。
3.3 ADR-2が約束して未提供の表面
Section titled “3.3 ADR-2が約束して未提供の表面”ContextInfocompanion(ADR-2 §Scope Object)が未実装。pluginはpath/scope/rootのみ受け取り、lexical context(現在クラス/メソッド/ 可視性/assertion文脈)は自分でrootを走査して導出するしかない。- loggerサービスはdeferred明記(services.rb:24-26)。許容。
4. 機能の過不足
Section titled “4. 機能の過不足”4.1 produced-but-unconsumedなADR-9 fact(1.0前に判断)
Section titled “4.1 produced-but-unconsumedなADR-9 fact(1.0前に判断)”- graphqlの4 fact(
:graphql_type_tableほか)は現状すべて読者なし (graphql.rb:30-39が将来のdemand-driven消費者を挙げるのみ)。 - dry-validationの
:dry_validation_contractsもproduced-but-unconsumed (消費するslice 2自体がdeferred、dry_validation.rb:29-40)。 - dry-schemaの
:dry_schema_tableも実消費者はdry-validation slice 2待ち。
→ 「1.0の公開契約としてfactを載せるか、消費者が来るまでinternalに留めるか」を 意図的に決める。produced-but-unconsumedのまま公開すると後方互換負債になる。
4.2 docstringが約束して未実装の診断(drift)
Section titled “4.2 docstringが約束して未実装の診断(drift)”- sorbet:
dynamic.sorbet.unsupported/degradedが未実装で、T.proc/T::Struct/T::Enum/type_parametersのDynamic[top]降格が完全にサイレント (type_translator.rb:43-48)— ユーザーは型が落ちた事実を知れない。 - dry-types:
dry-types.unknown-alias/alias-shadow(dry_types.rb:46-58)未実装。 - dry-schema:
unknown-predicate/unknown-type(dry_schema.rb:69-76)未実装。 - statesman: docstring表に
event :sym検証があるが実装はstate/transition_toのみ(statesman.rb:43 vs collect/validate)。 - graphql: alias解決をdocstringが示唆するが未実装(
BaseObject = …; class X < BaseObjectは素通り)。
→ 各docstringを実装に合わせて下方修正するか、診断を実装する。1.0で docstring=契約と読まれると約束違反になる。
4.3 lossyなbool/Boolean → TrueClass写像
Section titled “4.3 lossyなbool/Boolean → TrueClass写像”dry-types(alias_scanner)/ dry-schema(schema_scanner.rb:24)/ graphql
(type_scanner.rb:23)がboolをTrueClassに写像。falseを誤型付けする
プロジェクト横断の精度床。適切なboolキャリアに統一すべき。
4.4常時:infoノイズ
Section titled “4.4常時:infoノイズ”factorybot / pundit / sidekiq / statesmanが正しい呼び出し全件に:info
診断を出す(factory-call / policy-call / worker-call / known-state)。実プロジェクトで
出力を埋める。verbosityノブ裏に隠すか既定オフに。
4.5個別の過不足(多くはscoped deferral、優先度中〜低)
Section titled “4.5個別の過不足(多くはscoped deferral、優先度中〜低)”- devise: 合成メソッドが全て
Dynamic[T]返し(return精度なし、slice 6待ち)。 かつcurrent_user等コントローラヘルパーはscope外 — Deviseプラグインに ユーザーが最も期待する箇所が未提供(ADR通りの割り切りだが期待ギャップ最大)。 - activestorage: manifestが
consumes: model_indexを宣言するが実際には読まない (常にstandalone discovery、activerecordと同じapp/modelsを二重パース)。 誤解を招く宣言 → 削除or実消費。 - pundit: 名前空間モデル(
Blog::Post)のポリシ名解決が完全修飾形を仮定し、 flat policy名のアプリで誤検知しうる(analyzer.rb:91-99)。 - actionpack:
unknown_helper_diagnostic/wrong_arity_diagnosticが定義のみ未使用 (~20行dead code)、STRONG_PARAMS_RECEIVER_NAMESの2名がdead config。 - dead data: i18n
value_kinds、activejobkeyword_required、actioncableaction_methodsが収集されるが未参照(キャッシュスライスに無駄に載る)。 - vendoredテーブルのdriftリスク: rspec-railsのRackステータス表、 deviseのmodules表 — gemバージョンに対し検証なし。
4.6 examples(テンプレ)のanti-pattern教育
Section titled “4.6 examples(テンプレ)のanti-pattern教育”examplesは「プラグインの書き方」正典なので、ここのボイラープレートが実プラグインに コピーされる。特に:
- deprecationsが
receiver:照合をソース文字列等価で教える(deprecations.rb:97-101) —::User/ 改行 / 空白で取りこぼす。型ベースでないことをREADME明示すべき。 - lisp-eval/units/routesのコメントが「return-type contributionはv0.1.x待ち」と 書くが実コードは実装済み — ドキュメントが実装に追いついていない。
- contract surfaceのカバレッジが例間で不均一(webはreturn-type conformanceまで あるがarity無し、hanamiはarityあり)。
5. 推奨アクション(優先度順)
Section titled “5. 推奨アクション(優先度順)”1.0前に入れるべき(小さく・高ROI)
Section titled “1.0前に入れるべき(小さく・高ROI)”- factorybot/pundit/sidekiqのcache descriptorバグ修正(§2.1)— correctness。
Manifest#with(**overrides)追加(§3.1)— rbs-inlineの20フィールド手写し撤去。- 著者向けヘルパー層の最小セット露出(§1.3)—
Source::NodeWalker再エクスポート、Diagnostic.from_node/Base#diagnostic、Source::Literals新設(コア4重複も解消)。 ADR-2改訂1本で済む。これだけで25プラグインのコピペが消え、テンプレも健全化。 - docstring driftの一掃(§4.2, §4.6)— 未実装診断の約束を下方修正、 examplesのコメント/実装の齟齬を解消。コード変更ほぼ無しで契約の正直さが上がる。
- produced-but-unconsumed factの去就を決定(§4.1)— 公開or internal留保。
1.0直後(中規模リファクタ)
Section titled “1.0直後(中規模リファクタ)”- 共通基底の抽出(§1.4)—
ProtocolContractChecker/ClassDiscoverer+NameKeyedIndex/SourceScanner/Testing::Narrowing/Inflector。 - config既定値スキーマ(§1.3末尾)—
DEFAULT_*イディオム撤廃。 - boolキャリア統一(§4.3)、infoノイズ既定オフ(§4.4)、dead code/data除去(§4.5)。
1.x以降(要追加設計)
Section titled “1.x以降(要追加設計)”ContextInfoの提供(§3.3)、信頼境界の宣言的ファイル依存API(§2.2)、 RBS-onlyプラグインの宣言的経路(§3.2)。
6. インターフェイス分割の検討 — PHPStan型vs現行fat Plugin::Base
Section titled “6. インターフェイス分割の検討 — PHPStan型vs現行fat Plugin::Base”最終目標: AIエージェントが規則と機能を把握しやすく、テスト・検証しやすい プラグインアーキテクチャ。パフォーマンス低減は副次(キャッシュで緩和可、非クリティカル)。
6.1現状の正確な再構成 — Rigorは既に「2/3」分割済み
Section titled “6.1現状の正確な再構成 — Rigorは既に「2/3」分割済み”「現行インターフェイスのままで十分か」を論じる前に、現状を正確に分類する。 Rigorのプラグイン拡張点は2つのスタイルが併存している:
| スタイル | 拡張点 | エンジンの扱い | ゲート | PHPStan型か |
|---|---|---|---|---|
| A. 宣言的manifestフィールド(10個) | block_as_methods / trait_registries / heredoc_templates / nested_class_templates / type_node_resolvers / protocol_contracts / hkt_registrations / hkt_definitions / source_rbs_synthesizer / owns_receivers / open_receivers | 各フィールドをregistry.pluginsからflat_mapで集約し、エンジンがindex化(SyntheticMethodScanner・ResolverChain・Registry#contracts_for_path等)。verb/receiver/class/pathでエンジンがゲート | エンジン側 | ✅ 既にPHPStan型 |
| B. imperativeフック(2個) | flow_contribution_for(call_node:, scope:) / diagnostics_for_file(path:, scope:, root:) | 全pluginを全node/fileに対して呼ぶ。registry.plugins.filter_map { … }(method_dispatcher.rb:663とstatement_evaluator.rb:1379に逐語2重コピー) | plugin内の自前if | ❌ fan-out + self-gate |
→ 論点の正しい立て方: 「PHPStanの思想を採用すべきか」ではない。Rigorは 拡張点12個中10個で既に採用済み。問うべきは「残る2個のimperativeフックも 同じ宣言的・engine-gatedパターンに揃えるか(= 分割を完遂するか)」である。
6.2 PHPStanから移植すべき不変条件(1点だけ)
Section titled “6.2 PHPStanから移植すべき不変条件(1点だけ)”PHPStanは ~50のnarrow interfaceを持つが、本質は1つの不変条件:
cheapなgate述語(bool/
nil-decline)とexpensiveなpayload(Type/error/ dataを返す)を分離し、エンジンがgate値(getClass()/getNodeType())で 拡張をindex化して、payloadは一致node/receiverにだけ呼ぶ。
- 型推論3兄弟:
getClass()+isMethodSupported()でゲート →getTypeFromMethodCall()は通過後のみ。 - ルール:
getNodeType()でAST nodeクラス別にindex → 一致nodeにだけprocessNode()。 - magic member: built-in reflectionのmiss時のみ
hasMethod()ゲート →getMethod()。 - 唯一のcatch-all(
ExpressionTypeResolverExtension、ゲート無し)は明示的に非推奨の 最終手段。 - 1クラス1インターフェイスが支配的。frameworkパッケージは多数のnarrow拡張を登録する。
- per-interfaceのテスト基底(
RuleTestCase= fixture+期待エラー集合、TypeInferenceTestCase= fixture中assertType()で推論型を文字列一致検証)。
現行RigorのBは、この不変条件を唯一満たせていない部分。
6.3提案 — 残る2フックをnarrow interface化
Section titled “6.3提案 — 残る2フックをnarrow interface化”Bの2フックを、Aと同じ「manifest登録・エンジンindex・gate/payload分離」型へ割る。 PHPStanの対応関係をRubyに写すと:
flow_contribution_forを2つに分割:
# (1) 戻り値変更(PHPStan DynamicMethodReturnTypeExtension 相当)class DynamicReturnExtension def supported_receivers = ["ActiveRecord::Base"] # gate: エンジンが receiver で index def supports?(method_name) = method_name == :find # gate: cheap def return_type_for(call, scope) = ... # payload: 一致時のみend
# (2) 述語/表明による narrowing(PHPStan TypeSpecifyingExtension 相当)class TypeSpecifyingExtension def supported_methods = [:present?, :blank?] # gate def specify(call, scope, edge) = ... # payload → truthy/falsey/post_return factsendエンジンはreceiverクラスでindex(既存のowns_receivers index機構を再利用可)。
現行の「全pluginを全unresolved CallNodeに呼び、FlowContribution::Mergerを毎回走らせる」
が消える。
diagnostics_for_fileを2つに分割:
# (3) node 単位ルール(PHPStan Rule<TNode> 相当)— これが要石class NodeRule def node_type = Prism::CallNode # gate: エンジンが node クラス別に index def check(node, scope) = [...diagnostics] # payload: 一致 node にだけend
# (4) ファイル単位ルール(escape valve, ExpressionTypeResolverExtension 相当)class FileRule def check(path, root, scope) = [...] # 真にクロスファイル/index 検証が要る時だけend(3) NodeRuleが要石: エンジンがASTを1回だけwalkし、各nodeを
そのnodeクラスに登録されたruleにだけ配る。現状フックがraw rootを渡して
「自分でwalkせよ」(base.rb:168-171)と言うからこそ §1の25個の自前walkerが
存在する。エンジンがwalkを所有すれば、その存在理由ごと消える。
(4)は真に全ファイルを要するケース(cross-file index照合)の最終手段として残すが、
「最後の手段」と明示し既定の表面にはしない。
prepare / produces / consumes(FactProvider)は既に半宣言的 + topo順
(loader.rb:230 Kahnソート、missing-producer/cycleをLoadError化)で、PHPStanの
Collectorに近い。名前付きinterfaceとして整える程度でよい。
6.4 3つの目標をどう満たすか
Section titled “6.4 3つの目標をどう満たすか”- AIエージェントの把握しやすさ ← 最重要。manifestが機械可読なcapability宣言に
なる。「このpluginは
ActiveRecord::Base#findの戻り値を変え、CallNodeにruleを 出す」がgrep / 列挙可能になり、self-gatingのifに埋もれない。さらにrigor plugins --capabilities型のcatalogueを生成可能で、これはPHPStanが 持たない「interface → gate → test harnessの機械可読インデックス」を提供できる (PHPStanを上回れる差別化点 — 調査で「PHPStanにinterface↔tagの機械可読 レジストリは無い」と確認済み)。 - テスト・検証容易性 ← interface分割と不可分。各narrow interfaceに専用ハーネス:
NodeRule → node+scopeを与えdiagnosticsをassert(
RuleTestCase相当)、 DynamicReturnExtension → call+scopeを与えTypeをassert(TypeInferenceTestCase相当)。現状は唯一のharnessがrun_plugin(demo dirに書いてフルRunnerを回し、 downstreamのcall.undefined-method文字列で間接検証 —plugin_helpers.rb:109、 lisp-eval specが実例)。per-hookの単体検証手段が存在しないのが今の最大の弱点。 - ボイラープレート低減(§1と直結)— 25 walkerが消え、dispatch loopの2重コピーも 単一indexed registryに集約。§1の著者向けヘルパー層は「分割しない場合の緩和策」、 §6の分割は「ヘルパーが要る理由自体を消す」上位の解。
- パフォーマンス(副次)— エンジンがindexして非該当pluginをskip。現状の
plugins × files × nodesfan-out(pre-filter皆無)が解消。ユーザー言及の通り クリティカルではないが、分割すれば追加コストなしで付いてくる。
6.5やり過ぎない規律
Section titled “6.5やり過ぎない規律”PHPStanの ~50 interfaceを全移植しない。今Rigorに要るのは3〜4の新narrow interface(DynamicReturn / TypeSpecifying / NodeRule + FileRule escape valve)だけ。
- magic-member / dynamic reflection系 → macro substrate(ADR-16)が既にカバー。新設不要。
- dead-code(always-used)/ restricted-usage系 → demand-drivenで1.xに後置。
- catch-all(現
diagnostics_for_file相当のFileRule)は残すが非推奨の最終手段と明示。
6.6移行とタイミング
Section titled “6.6移行とタイミング”- 対象31 pluginsだが大半は機械変換可能。「単一walk → name一致でdiagnostic」型 (statesman / pundit / sidekiq / factorybot / 多くのRails系)はNodeRuleにほぼ そのまま落ちる。Aの宣言系(sinatra / devise / dry-struct / typescript-utility-types / hanami・webの一部)は既に分割済みで無改修。
- 後方互換: 旧fatフックをdeprecated-but-supportedなFileRule(catch-all)として
残せば一括移行は不要。新interfaceを推奨経路にし、旧
diagnostics_for_fileは FileRuleにリネーム + 非推奨マーク。 - タイミングが決定的論点: フックのシグネチャは1.0で公開契約として凍結される。 1.xでfatフックを割るのは破壊的変更。やるなら今(pre-1.0)。これが 「現行のままで十分か」への最大の答え — 機能的には十分だが、分割の窓は今しか開いていない。
- 1.0前: (a) NodeRule + engine-owned walkを導入(boilerplate/テスト両面で最大
効果、§1と直結)、(b)
flow_contribution_forをDynamicReturn + TypeSpecifyingに 分割、(c)旧diagnostics_for_fileをFileRule(非推奨catch-all)として残す。 - 同時にper-interfaceテスト基底(NodeRule用・DynamicReturn用)を出す — テスト容易性の目標はinterface分割と同時にしか達成できない。
- 機械可読capability catalogue(manifest集約のdump /
rigor plugins --capabilities) を出し、AIエージェントが拡張種別と各gateを列挙できるようにする。 - dead-code / restricted-usage / 追加magic-member系はdemand-drivenで1.x。
→ これはADR-2の改訂1本(「imperativeフック2個のnarrow-interface化とFactProvider の名前付け」)として起票するのが収まりがよい。§1の著者向けヘルパー層は、この分割を 段階導入する間の橋渡しとして先行投入できる(NodeRule化が済んだpluginから walkerヘルパー依存が落ちていく)。
7. PHPStan拡張型の選別取り込み(型分割とは別軸)
Section titled “7. PHPStan拡張型の選別取り込み(型分割とは別軸)”§6は「フックの形をPHPStan化するか」。本節は「PHPStanが持つ拡張の種類のうち、
Rubyで実需がありRigorにまだ無いものはどれか」。全 ~50 interfaceのうち、Rigorの
現状をfile:lineで裏取りした結果、取り込み価値があるのは少数に絞られた。
ユーザー言及のAdditionalConstructorsExtensionがまさに最有力だった。
7.1選別マトリクス
Section titled “7.1選別マトリクス”| PHPStan拡張型 | Rigor現状(裏取り) | 取込価値 | FP規律との整合 | 判断 |
|---|---|---|---|---|
| AdditionalConstructors → Ruby「追加initializer」 | PARTIAL: ivar型シードがinitialize のみ(scope_indexer.rb:79, :214-220, :411) | 高 | ◎ | 取り込み推奨(小・先行) |
| AllowedSubTypes → sealed / 網羅性 | ABSENT: case/in網羅性なし(statement_evaluator.rb:539-541)。ADR-36 WD3でsealed-parent factは既にspec済・is_a?網羅narrowingはdeferred(nested_class_template.rb:61-69) | 高 | ◎(網羅漏れを正しく検出) | 取り込み推奨(中・ADR-36と統合) |
| Collector<TNode,TValue> | PARTIAL: FactStore+prepareはあるがper-node収集primitive無し、各pluginが自前re-walk(base.rb:166-178) | 中 | ○ | §6のNodeRuleに統合(cross-file集約版) |
| MethodParameterClosureType(yield引数型) | PARTIAL: block_as_methodsはself型のみ(block_as_method.rb:47-51)。yield引数型はbuiltin+RBSのみ、plugin field無し | 中 | ○ | manifestにyields:追加を検討(demand-driven) |
| *AlwaysUsed / ReadWriteProperties**(dead-code FP抑制) | PARTIAL: dead-codeは局所変数/分岐のみ(check_rules.rb:74, :1058)。メンバ単位の未使用検出は無い | 中(条件付き) | ◎(抑制側が要) | メンバdead-codeを入れる時に抑制hookを同梱(単体では入れない) |
| RestrictedUsage系(内部API / test-only) | PARTIAL: Rubyのprivate + Liskov overrideのみ(check_rules.rb:69-70)。呼出元制約は無し | 低〜中 | ○ | demand-drivenで1.x |
DiagnoseExtension(-vvv troubleshooting) | ABSENT(plugin寄与なし)。rigor triage(ADR-23)はconsumer側で別形 | 低 | — | §6.4のcapability catalogueと抱き合わせで小さく |
| ResultCacheMetaExtension | EXISTS: Cache::Descriptor::ConfigEntry + cache_for(descriptor:)で任意外部状態をhash可能(descriptor.rb:120-141, base.rb:249-260) | — | — | 作らない(実装済) |
| ExpressionType / Operator catch-all | N/A(§6.2の通り非推奨) | 低 | — | 見送り |
| magic-member reflection系 | macro substrate(ADR-16)でカバー | — | — | 作らない |
7.2最有力 — 「追加initializer」拡張(AdditionalConstructorsのRuby版)
Section titled “7.2最有力 — 「追加initializer」拡張(AdditionalConstructorsのRuby版)”PHPStanのadditional-constructorsは「setUp()等もconstructor扱いして
未初期化プロパティの誤検知を消す」小さな拡張。RigorにはPHPの未初期化
プロパティ検査そのものは無い(Rubyのivarは既定nil)が、同じ構造のハードコード境界が
既にある:
scope_indexer.rb:79build_class_ivar_indexがivar型をdef_node.name == :initializeの 本体からのみシードし、read-before-write→nil寄与もそこにgate(:223-234)。- rspec
before/let、minitestsetup、Railsのコールバック(after_initialize等)で ivarを確立するコードはシード対象外 → 「initializeで代入していないivar」を nil含みと推論し、テスト/RailsコードでFPを生む温床。
→ manifest宣言フィールド additional_initializers:(PHPStanの宣言的
additionalConstructors:パラメータに対応)を追加し、receiver_constraint + メソッド名
集合で「このクラスではこれらも型シード源」と宣言できるようにする。§6.1のAスタイル
(宣言的・engine-gated)にそのまま乗る小機能で、§0のfalse-positive disciplineに最も
直接効く。rigor-rspec / rigor-minitest / rigor-railsが即座に恩恵を受ける。
動的ロジックが要る稀なケース用に、scope_indexer側のseeding-site解決を
pluginが拡張できるhookも残せる(PHPStanが「単純例はconfig、動的例はextension」と
二段にしているのと同じ割り方)。
7.3高価値 — sealed / 網羅性(AllowedSubTypesのRuby版)
Section titled “7.3高価値 — sealed / 網羅性(AllowedSubTypesのRuby版)”case/in / case/whenの網羅性検査は現状ABSENT(statement_evaluator.rb:539-541が
「no exhaustiveness tracking yet」と自認)。ADR-36 WD3が既にsealed-parent factを
spec済みで、is_a?横断の網羅narrowingはEnvironment#class_ordering配線待ちで
deferred(nested_class_template.rb:61-69)。
→ PHPStanのAllowedSubTypesClassReflectionExtension(supports? + getAllowedSubTypes)
に対応するfact channel(pluginが「この親型の許容サブ型は {A,B,C}」を宣言)を入れれば、
union減算の精度向上 + 網羅漏れ検出が両取りでき、rigor-mangroveのEnum / dry-struct /
ADR-36のペンディングが一気に解ける。FP規律とも整合(網羅していれば黙り、漏れだけ
報告)。engine側作業はやや重いが、既にspec済みの線をplugin契約に出すだけで設計の
新規性は低い。
7.4統合・demand-driven・作らない
Section titled “7.4統合・demand-driven・作らない”- Collector(cross-file per-node収集)は §6のNodeRuleのcross-file集約版として 自然に入る(engineが1回walkしてnodeを配る基盤の上に「集めてから消費」を足す)。 独立機能にせず §6に畳む。
yields:manifest field(block引数型)は、静的RBSで書けないcontext依存の yield型を持つDSL向け。block_as_methodsのself型と対になる。demand-driven。- メンバdead-code + AlwaysUsed抑制はペアでのみ価値がある。Rubyは メタプログラミングでFPリスクが極端に高いので、検出だけ入れて抑制hookを欠くと §0の規律に反する。入れるなら「Rails callback / DSL登録メソッドを常時使用扱い」する 抑制拡張を同時に出す前提。優先度は7.2/7.3の後。
- RestrictedUsage / Diagnoseはdemand-driven(1.x)。
- ResultCacheMetaは実装済み(
ConfigEntry)— 再実装しない。唯一の差は 「専用コールバックが無くConfigEntryを手組みする」ergonomicsのみ。
7.5推奨(§6との関係)
Section titled “7.5推奨(§6との関係)”§6(フックの形=narrow-interface化)と §7(拡張の種類)は独立に進められる。 1.0前の取り込み候補を优先度順に:
additional_initializers:(7.2) — 小・宣言的・FP規律直撃。最優先。- sealed/AllowedSubTypes fact(7.3) — ADR-36を完遂しMangrove/dry enumを解放。中。
- Collectorは §6に統合、
yields:とメンバdead-code+抑制はdemand-driven。
7.2は単独の小PR、7.3はADR-36の続き、両者とも §6のADR-2改訂とは別チケットに割ける。
付録: 健全なお手本(増やすべき形)
Section titled “付録: 健全なお手本(増やすべき形)”- rigor-sinatra — 最もクリーンなmanifest(BlockAsMethod 1つ + 9 verb)。 walker/Diagnostic/indexコード皆無。substrateに荷を預けた理想形。
- rigor-pattern(example)—
services.type.literal_string_compatible?/scope.type_ofでエンジン協調し「文字列伝播を自前再実装しない」最良テンプレ。literal-unknowninfoのfalse-positive disciplineも見本。 - rigor-devise — 宣言的TraitRegistryでアナライザコードゼロ (return精度の床は別途課題だが、構造としては他が目指すべき形)。
これらの共通点は「substrate / エンジンクエリに荷を預け、自前ASTコードを書かない」。 §1の著者向け層と §1.4の基底クラスが揃えば、walker系プラグインも この水準のコード量に近づける。
© 2026 TypedDuck. Licensed under CC BY-SA 4.0.