コンテンツにスキップ

PHPStan 内部型演算(TypeCombinator / TypeUtils / 二項演算評価)と Rigor の比較

Status: research note, no design commitments. プラグインレベルでの型演算ギャップ調査。 Date: 2026-06-03. Rigor version: working tree(v0.1.x系、master @ 7d8000e6)に対する観察。 PHPStan version:配布pharはreferences/phpstan2.1.39-767)にvendor済みだがソースは入っていない(pharのみ)。内部クラスの引用はupstream phpstan/phpstan-src2.1.xブランチを直接参照した。references/phpstanを再grepしてもTypeCombinator.php等は見つからない点に注意。

Why:「プラグインレベルでPHPStanと同水準の型演算をしたい」という要求に対し、両者の型代数(type algebra)サーフェスを突き合わせ、Rigor側に不足している実装とテストカバレッジを特定するための土台。後続の移植検討/ADR起票はこのノートを根拠にする。

読み順. §1がPHPStan側のサーフェス棚卸し、§2がRigor側マッピング表、§3がギャップ分析(プラグイン視点)、§4がテストカバレッジ観点、§5がADR要否の判断。file:line引用はRigor作業ツリー / phpstan-src 2.1.xに対するもので、±数行ずれうる。引用前に再grepすること。


PHPStanの型演算は3層に分かれる。(1)型オブジェクトの代数を扱う静的ファサードTypeCombinator(union/intersect/remove)とTypeUtils(抽出ヘルパ群)、(2)各型が実装するTypeインターフェース本体のメソッド群(isSuperTypeOf / accepts / is*()述語 / get*()抽出 / to*()強制変換 / offsetアクセス)、(3)AST上の二項演算をMutatingScopeが評価するロジック(定数スカラの実評価+IntegerRangeTypeの抽象範囲算術+union直積分配)。プラグインはこの3層すべてを呼べるうえ、二項演算の結果型を宣言する専用拡張点OperatorTypeSpecifyingExtensionを持つ。

Rigorも対応する3層を持つ — Type::Combinatorlib/rigor/type/combinator.rb)、各キャリアのaccepts / capability predicate / projection(docs/internal-spec/internal-type-api.md)、ConstantFoldingの二項演算評価(lib/rigor/inference/method_dispatcher/constant_folding.rb)。代数ファサードと関係演算はほぼ同水準だが、プラグインから二項演算の結果型を差し込む拡張点が存在しない点が最大の構造的ギャップである。


1.1 TypeCombinator(正規化付き型代数ファサード)

Section titled “1.1 TypeCombinator(正規化付き型代数ファサード)”

PHPStan\Type\TypeCombinatorのpublic staticメソッド:

メソッド役割
union(Type ...$types): Type正規化union。重複除去・subtype吸収(supertypeが勝つ)・定数スカラの集約(`true
intersect(Type ...$types): Type正規化intersect。union上に分配(`A & (B
remove(Type $from, Type $toRemove): Type型差分。全消去でNeverType
removeNull / addNull / containsNullnull専用の便宜ラッパ
removeTruthy / removeFalsey真偽値によるnarrowing補助
countConstantArrayValueTypes定数配列の値エントリ総数(generalize閾値判定用)

正規化の要点:implicit-never除去 → benevolent union展開 → ネストunion平坦化 → スカラ集約 → enum case分離 → iterableマージ → subtype吸収 → 配列処理(processArrayTypes、値数が上限超でgeneralize)。

2.xでは多くがType本体のメソッドへ移管され、TypeUtils側は縮小済み。現存する主なもの:getConstantIntegers, getIntegerRanges, toBenevolentUnion, toStrictUnion, flattenTypes(power-set展開、巨大時に最適化), findThisType, findCallableType, getHasPropertyTypes, getAccessoryTypes, containsTemplateType, resolveLateResolvableTypesgetConstantStrings等の素朴な抽出はインターフェースのgetConstantStrings(): list<ConstantStringType>に移った。

1.3 Typeインターフェース本体の演算サーフェス

Section titled “1.3 Typeインターフェース本体の演算サーフェス”

これが「型演算」の本体。プラグインはScope->getType($expr)で得たTypeに対し直接呼ぶ。

  • 関係演算isSuperTypeOf(Type): IsSuperTypeOfResultプラグインが型問い合わせに使う推奨API —「$thisの値集合が引数を包含するか」)、accepts(Type, bool $strictTypes): AcceptsResult(PHPの暗黙強制を加味した代入可否。FloatTypeIntegerTypeをacceptする等、意味が複雑なので型判定には不向き)、equals(Type): bool
  • 3値述語(TrinaryLogicを返す)isString / isInteger / isFloat / isBoolean / isArray / isList / isCallable / isObject / isEnum / isNull / isScalar / isOffsetAccessible … およびisNumericString / isNonEmptyString / isNonFalsyString / isLiteralString / isLowercaseString / isClassStringのような精密string述語
  • 定数抽出getConstantScalarTypes / getConstantScalarValues / getConstantStrings / getConstantArrays / isConstantScalarValue
  • 強制変換to*()toBoolean / toNumber / toInteger / toFloat / toString / toArray / toArrayKey / toBitwiseNotType / toAbsoluteNumber / toCoercedArgumentTypeこれらは型→型の純関数で、二項演算評価や(string)$x等のキャスト解決に使われる。
  • offsetアクセスhasOffsetValueType(Type): TrinaryLogic, getOffsetValueType(Type): Type, setOffsetValueType(?Type,Type,bool): Type, setExistingOffsetValueType, unsetOffset。配列操作getKeysArray / getValuesArray / sliceArray / popArray / flipArray … が型レベルで多数。
  • 精度管理generalize(GeneralizePrecision): Type(型が複雑化しすぎたとき定数情報を落とす)。

TrinaryLogicyes/no/maybecreateYes等)が述語・関係演算の共通戻り値で、union/intersectionに内在する不確実性を表現する。

1.4二項演算の評価(MutatingScope

Section titled “1.4二項演算の評価(MutatingScope)”

ASTのExpr\BinaryOp\*Plus / Minus / Mul / Div / Mod / Pow / Concat / 比較 / ビット演算)をMutatingScope::getType()内で評価する。要点:

  1. 定数スカラの実評価:両辺が定数スカラなら、PHPの演算子で実際に計算してConstantIntegerType / ConstantFloatType / ConstantStringTypeを生む。intオーバーフロー時はfloatに昇格。
  2. IntegerRangeTypeの抽象範囲算術int<1,5> + int<10,20> → int<11,25>のように、定数でなくても範囲同士で加減乗除・比較を計算する。IntegerRangeType自体が範囲演算メソッドを持つ。
  3. union直積分配:オペランドがunionなら各メンバの直積で評価してunionに畳む。
  4. 文字列連結Concat:定数なら定数文字列、そうでなければnumeric-string / non-empty-string等の精密string型へ。
  5. これらの結果は最終的にTypeCombinator::unionで正規化される。

1.5 OperatorTypeSpecifyingExtensionプラグイン向け二項演算フック

Section titled “1.5 OperatorTypeSpecifyingExtension(プラグイン向け二項演算フック)”
interface OperatorTypeSpecifyingExtension
{
public function isOperatorSupported(string $operatorSigil, Type $leftSide, Type $rightSide): bool;
public function specifyType(string $operatorSigil, Type $leftSide, Type $rightSide): Type;
}

GMP / BCMath / Moneyなど演算子をオーバーロード(あるいは演算子的に振る舞う)オブジェクト型に対し、二項演算の結果型をプラグインが宣言できる。configのphpstan.neontags: [phpstan.broker.operatorTypeSpecifyingExtension]として登録する。これが「プラグインレベルの二項演算型演算」の中核であり、Rigorに直接対応物がない。

1.6プラグインの型構築イディオム

Section titled “1.6プラグインの型構築イディオム”

new ObjectType(...) / new ConstantStringType('x') / new UnionType([...])を直接newできるが、公式ガイドは「非正準形は単純化すべき」として構築後は必ずTypeCombinator::union/intersectで正規化することを推奨(直接new UnionTypeだとstring&intのような不正型を作りうる)。カスタム型を作る場合はdescribe / equals / isSuperTypeOf / acceptsの実装が必須で、isSuperTypeOfTypeCombinator経由で厳密にテストせよ、とされる。


PHPStanRigor対応所在状態
TypeCombinator::unionType::Combinator.unioncombinator.rb:363✅ 同等(決定論的正規化 + lattice恒等則)
TypeCombinator::intersectType::Combinator.intersectioncombinator.rb:325✅ 同等
TypeCombinator::removeType::Combinator.differenceT - U演算子)combinator.rb:123✅ Rigorは明示的差分演算子を持ち、診断表示もD - U形を持つ(type-operators.md)。むしろPHPStanより表現が厚い
removeNull/addNull/containsNulldifference(t, nil) / union(t, nil) / nil_value述語combinator + 述語⚠️ 導出可能だが専用便宜メソッドは未提供
removeTruthy/removeFalsey(narrowingはCFA側)control-flow-analysis.md⚠️ 型代数ファサードとしては未公開
Type::isSuperTypeOfType#accepts(other, mode:)AcceptsResult各キャリアaccepts(例constant.rb:114✅ gradual consistencyとして実装。厳密subtype(subtype_of)はslice 5+ でSubtypeResult予定
Type::acceptsType#accepts同上✅ gradual mode実装、strict mode予約
TrinaryLogicRigor::Trinary(yes/no/maybe)✅ 同等
is*()述語群capability predicates(string / integer / array / callable …)internal-type-api.md✅ 概ね同等。ただしPHPStanの精密string述語(isNumericString / isLowercaseString等)はRigorではRefinedキャリア + 述語ID側に分散
getConstant*抽出projections(constant_strings / constant_integers …)internal-type-api.md✅ 同等
IntegerRangeType範囲算術try_fold_binary_rangeほか(additive / multiply / divide / comparison、corner計算 + 0×∞=0等の代数的配慮)constant_folding.rb:800同等水準。範囲 × 範囲の四隅積・除算ガード・無限端処理まで実装済み
定数スカラ実評価(二項演算)ConstantFolding(NUMERIC_BINARY / STRING_BINARY等の許可リスト + 実sendconstant_folding.rb✅ 同等。受け手/引数がConstantUnion[Constant]で許可リスト内なら実評価、外れたらDynamic[top]にfail-soft
union直積分配(二項演算)ConstantFoldingのCartesian fold(UNION_FOLD_INPUT/OUTPUT_LIMITconstant_folding.rb✅ 同等(入出力上限あり)
to*()強制変換(型→型純関数)型オブジェクトメソッドとして未公開。キャスト/coerceはConstantFolding内部に閉じている
offsetアクセス(getOffsetValueType等)indexed_access型関数 + ShapeDispatch(Tuple/HashShapeの[]/fetch/digshape_dispatch.rb, type-operators.md⚠️ エンジン内部では精密。プラグインから呼べるoffsetファサードはindexed_access程度に限定
generalize(precision)normalize(冪等正規化)のみnormalization.md⚠️ 精度を意図的に落とす generalizeは未提供(union/出力上限による暗黙widenはある)
OperatorTypeSpecifyingExtension対応物なし。プラグインの二項演算フックが存在しない
プラグインの型構築facadeservices.type(= Type::Combinatorservices.rb:43✅ 正規化必須のfacadeを注入(PHPStanの「newよりTypeCombinator」方針と一致)

プラグイン拡張点(plugin/base.rb)はnode_rule(86, 137行)/ dynamic_return(210行)/ type_specifier(239行)/ producer(86行)。PHPStanのDynamicMethodReturnTypeExtension ≈ Rigor dynamic_returnTypeSpecifierExtensiontype_specifierOperatorTypeSpecifyingExtensionに対応するフックだけが欠けているgrep -i operator lib/rigor/plugin/は空)。


3. ギャップ分析(プラグイン視点)

Section titled “3. ギャップ分析(プラグイン視点)”

PHPStan「同水準」に向けた不足を、影響度順に。

G1(要検証→スパイク済み・統合スペックで確定)— 二項演算プラグインフック

Section titled “G1(要検証→スパイク済み・統合スペックで確定)— 二項演算プラグインフック”

当初「Rigorにはプラグインの二項演算フックがない」と仮説したが、2026-06-03のコード・スパイク+統合スペックで反証・確定したspec/integration/plugin_operator_dynamic_return_spec.rb、4例green)。Rubyのa + bはPrismではname: :+Prism::CallNodeであり、通常の呼び出しと同じくcall_type_forMethodDispatcher.dispatchcall_node: node / method_name: :+ / scope:付きで流れる(expression_typer.rb:1233)。dispatchの優先順位はConstantFolding(precise tiers)→ try_plugin_contributiondynamic_return)→ RBSmethod_dispatcher.rb:74-97)。プラグイン所有のレシーバはNominal[CustomType]であってConstant / IntegerRangeではないためConstantFoldingはnilを返し、dispatchはplugin tierに落ちるdynamic_return_typeはreceiverクラスのみでゲートしmethod名は一切問わない(base.rb:382)。スペックは:+ :- :* :/すべてでブロックが発火し、寄与した型が(a <op> b)の結果型として確定することを確認済み。

結論:PHPStanのOperatorTypeSpecifyingExtension相当はすでに既存契約で実現できる

dynamic_return receivers: ["Money"] do |call_node, scope|
next nil unless %i[+ - * /].include?(call_node.name)
right = scope.type_of(call_node.arguments&.arguments&.first)
# ... Money 同士なら Money、Money×Integer なら Money など
services.type.nominal_of("Money")
end

つまり新フックは不要。ギャップは「契約の不在」ではなく以下の3点に縮小する:

  • G1a(ドキュメント)dynamic_returnが演算子糖衣も捕捉できることがどこにも明記されていない。ADR-37 / examplesいずれも演算子ユースケースを示していない。
  • G1b(エルゴノミクス):receiver-onlyゲートのため、ブロックが手動でcall_node.nameを分岐し、scope.type_ofで右辺型を取り出す必要がある。PHPStanのspecifyType(sigil, left, right)のような(演算子記号,左型,右型)を直接渡す糖衣がない。operator_return operators: %i[+ -], receivers: [...]のような薄い宣言糖衣は検討余地。
  • G1c(coerce方向、設計上の真のギャップ):Rubyはa + baに対してディスパッチする。1 + moneyのように左辺が組み込み型のケースはIntegerがレシーバになり、ランタイムではmoney.coerce(1)を経由する。プラグインがIntegerを所有しないと左辺起点の演算に介入できない。スペックで確定した実挙動(上記スペックのcoerce例):1 + moneyはDynamicにfail-softするのではなく、左オペランドの組み込み型(Integer)として型付けされる。したがって下流(1 + money).custom_methodはInteger上で未定義判定され、ランタイムでcoerce経由で動くコードに対し狭いが偽陽性が出うる(scalar-first coerce+ 結果への独自メソッド呼び出しという少数派条件が重なったとき)。PHPStanはisOperatorSupported($left, $right)双方向で左右どちらの型からでも判定できる点で構造的に勝る。Rigorでcoerce方向を扱う選択肢:(i)coerce呼び出しをdynamic_return receivers: ["Integer"] …は所有衝突で不可、(ii)エンジン側で「引数がプラグイン所有型なら左辺組み込み算術をプラグインに委譲」する新経路、(iii)より単純なFP緩和 — 引数が非Numericの未知/独自型のとき結果をInteger左偏重ではなくDynamicに倒す(プラグインフック不要のエンジン小改修で偽陽性だけ消える)。ここだけはADR級の設計判断が残る

ユースケース(Ruby生態系での実需):

  • BigDecimal / Rational / Complexの算術結果型(既にoss-library-surveyでBigDecimal-coerceのFP修正実績あり — 演算子経路の需要は実在。これらはcoerce方向G1cの典型)。
  • Money/Unit系(examples/rigor-unitsが単位付き数値を扱う — 演算子オーバーロードの典型。同型同士ならG1既存契約で対応可)。
  • ベクトル/行列、Set|/&/-Pathname#/等(同型/自型レシーバが多くG1既存契約でカバー可)。

G2 — 強制変換to*()サーフェスの不在

Section titled “G2 — 強制変換to*()サーフェスの不在”

PHPStanのtoNumber/toString/toBoolean/toArray型→型の純関数としてプラグインから呼べる。Rigorでは同等のロジックがConstantFoldingの内部に閉じ、プラグインが「この型をboolish/integerにcoerceしたら何になるか」を型代数として問えない。boolishの扱い(special-types.md)はあるが、汎用coercion facadeではない。

評価2026-06-03 — 却下(需要なし)。 Rubyのキャストはx.to_i / Integer(x)等のメソッド呼び出しで、既にdispatch経由で精密化される。真偽値の扱いはnarrow_truthy/narrow_falseynarrowing.rb:67、エンジン内蔵)+ プラグイン拡張可能なtype_specifierが等価機構。型→型のcoercion facadeを欲する消費者はexamples/pluginsに皆無。

PHPStanは精度を意図的に落とすgeneralizeを持つ。Rigorはnormalize(冪等・情報保存)+fold出力上限による暗黙widenのみで、プラグインが「複雑になりすぎたので定数情報を捨ててIntegerに上げる」を明示要求できない。

評価2026-06-03 — プラグインfacadeとしては却下。意図的精度低下はADR-41(inference budget)の領分であり、プラグイン公開面ではない。union/出力上限による暗黙widenで実害なく回っており、プラグインから明示generalizeを要求する消費者はゼロ。必要が生じればADR-41に吸収する。

G4 — null/truthy便宜メソッドの不在

Section titled “G4 — null/truthy便宜メソッドの不在”

removeNull / addNull / containsNull / removeTruthy / removeFalsey相当がfacadeに無い。difference/union + 述語で導出できるので純粋にDX(便宜)の問題。プラグインコードの冗長さに直結する。

ShapeDispatchはエンジン内部でTuple/HashShapeのoffsetを精密に解くが、プラグインに公開された型代数APIはindexed_access型関数程度。PHPStanのgetOffsetValueType / setOffsetValueTypeのような型レベルoffset操作の純関数群はplugin facadeに揃っていない。

評価2026-06-03 — 保留(条件付き)ShapeDispatchはTuple/HashShape限定のclosed tier(shape_dispatch.rb)で、独自コンテナ型を持つプラグインは現状ゼロ。将来カスタムコレクション型を定義するプラグインが現れたときの最有力拡張候補ではあるが、消費者が出るまでは着手しない。

同等で問題ない領域(移植不要)

Section titled “同等で問題ない領域(移植不要)”

union/intersect/difference、accepts(gradual)、capability predicates、定数抽出、定数スカラ実評価、IntegerRange抽象算術、union直積分配、正規化必須facadeの注入方針。これらはPHPStanと同水準かそれ以上(差分演算子と診断表示はRigorが厚い)。


PHPStanはisSuperTypeOfTypeCombinator経由で網羅テストするのが規範。Rigor側で強化したい軸:

  1. 二項演算 × union直積Union[Constant]同士の算術が直積で正しく畳まれ、上限超でfail-softする境界テスト(UNION_FOLD_INPUT/OUTPUT_LIMIT直近)。
  2. IntegerRange算術の代数的エッジ0×∞、除数が0を跨ぐ範囲、片側無限、減算での端入れ替え(range_additive:-分岐)。既に実装は配慮済みだが回帰テストの明示化。
  3. acceptsのgradual非推移性relations-and-certainty.mdの「consistentは推移的でない」を突くケース表。
  4. 差分演算子の正規化String - "" - "x"の平坦化、Refinedとの相互作用、診断表示のD - (U|V)形。
  5. プラグインfacadeの正規化保証services.type.union(...)が直接Union.newを許さず常に正規化を通すことの契約テスト(PHPStanの「new回避」方針のRigor版)。
  6. 演算子糖衣 → dynamic_returnLANDED 2026-06-03 — spec/integration/plugin_operator_dynamic_return_spec.rb):Nominal[Custom] <op> Customdynamic_return receivers: ["Custom"]のブロックにcall_nodeとして届き、結果型がplugin tierで確定すること(:+ :- :* :/全数 + 宣言外演算子の辞退 + coerce方向の左偏重型付けの4例)。観測にはcore型センチネルが必要(ユーザー定義クラスは偽陽性回避のため未定義メソッド診断が出ない)。

スパイク(§3 G1)で前提が一つ崩れた:自型/同型レシーバの二項演算は新フックなしで既に対応できる。これによりADRの必要範囲は当初想定より小さい。

  • G1a/G1b(ドキュメント + エルゴノミクス)はADR不要・LANDEDdynamic_returnの演算子捕捉を[docs/internal-spec/plugin.md] のdynamic_return節 + [examples/rigor-units/README.md] に明記し、回帰スペック[plugin_operator_dynamic_return_spec.rb] で固定済み。薄い宣言糖衣operator_returnは欲しければ後続の小改善(ADR不要、CHANGELOGレベル)。
  • G1c(coerce方向)— ADR-42として起票済み・低優先demand-gated。2回の訂正を経た現在地:(1)当初「実需あり(BigDecimal-coerce survey)」は誤り。surveyのFPはoverload順序問題でacc9882(ReceiverAffinity)により解決済み、本件と無関係。(2)次に「無害なfail-soft(Dynamic・無診断)」としたが、これもスペックで反証された1 + moneyはDynamicではなく左偏重でInteger型になり、結果への独自メソッド呼び出しは狭いが偽陽性を生みうる(§3 G1c)。よって「精度のみ・安全性に無関係」ではなく、少数派条件下でFPが出る。(3)ただし最安の解はADR-42の新フックではなく、§3 G1cの選択肢(iii):引数が非Numericの独自型のとき結果をInteger左偏重ではなくDynamicに倒すエンジン小改修で、プラグインフックなしにFPだけを消せる。さらに精度まで欲しければexamples/rigor-units自身が示すADR-20 lightweight HKT + RBS型関数が本筋。→ ADR-42はProposedのまま。まず(iii)のFP緩和を検討し、精度はHKT経路を優先。新フックは両者で足りないと実消費者で判明したときのみ
  • G2/G3/G5 — 2026-06-03評価で却下/保留(§3各項参照)。G2(to_*)= 需要なし・narrowingが等価機構で却下。G3(generalize)= プラグインfacadeではなくADR-41 budgetの領分、却下。G5(offset facade)= カスタムコンテナ消費者が出るまで保留。いずれもADR不要。
  • G4はADR不要。Type::Combinatorに便宜メソッドを足すだけのDX改善で、CHANGELOGレベル。

総括(再評価後):PHPStan同水準を目指す未実装で新しいプラグイン拡張点を要するものはゼロ。自型/左辺の演算子は既存dynamic_returnで対応済み(スペックで確定)、coerce方向(G1c)は少数派FPが出うるが最安の緩和はエンジン小改修(§3 G1c(iii))で精度はHKT経路、to_*/generalize/offset facadeは需要なし。価値ある残作業は §4のテスト整備とG1a/G1bのドキュメント化、そして必要ならG1c(iii)のFP緩和(いずれも新フックADR不要)。

推奨と着手状況

  1. LANDED(ADR不要):G1a/G1b(§4-6の演算子↔dynamic_return回帰スペック[plugin_operator_dynamic_return_spec.rb] + [docs/internal-spec/plugin.md] のdynamic_return節への演算子注記 + [examples/rigor-units/README.md] への演算子ポインタ)。これで「自型/左辺の演算子型演算」はPHPStanと同水準に並んだ。
  2. LANDED(ADR不要):§4テスト軸のうち実在ギャップ3本(STRING_FOLD_BYTE_LIMIT、IntegerRange符号付き無限大、Difference連続減算)を既存スペックに追加。残り(accepts非推移性の明示ケース等)は既存カバレッジが厚く優先度低。
  3. ADR-42起票済み(Proposed・低優先・demand-gated):G1c(coerce方向)。スペックで「狭いFP」と確定。最安の緩和はWD-D(エンジン小改修、フック不要)、精度はADR-20 HKT。実消費者が出るまで未実装。
  4. 却下/保留(ADR不要):G2/G3/G5(§3各項)、G4(CHANGELOGレベル)。

→ 当初の「G1全体をADR」案は過大で、実際に残ったADRはG1cに絞った1本(ADR-42)のみ。それ以外は文書・テストで決着済み。


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