rigortype 0.2.0 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (142) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +82 -20
  3. data/data/core_overlay/numeric.rbs +33 -0
  4. data/data/core_overlay/pathname.rbs +25 -0
  5. data/data/core_overlay/string_scanner.rbs +28 -0
  6. data/data/gem_overlay/activesupport/core_ext.rbs +473 -0
  7. data/data/vendored_gem_sigs/ast/ast.rbs +130 -0
  8. data/data/vendored_gem_sigs/bcrypt/bcrypt.rbs +47 -0
  9. data/data/vendored_gem_sigs/bundler/bundler.rbs +238 -0
  10. data/data/vendored_gem_sigs/cgi/cgi_extras.rbs +34 -0
  11. data/data/vendored_gem_sigs/did_you_mean/did_you_mean_extras.rbs +34 -0
  12. data/data/vendored_gem_sigs/idn-ruby/idn.rbs +54 -0
  13. data/data/vendored_gem_sigs/mysql2/client.rbs +55 -0
  14. data/data/vendored_gem_sigs/mysql2/error.rbs +5 -0
  15. data/data/vendored_gem_sigs/mysql2/result.rbs +31 -0
  16. data/data/vendored_gem_sigs/mysql2/statement.rbs +5 -0
  17. data/data/vendored_gem_sigs/nokogiri/nokogiri.rbs +2332 -0
  18. data/data/vendored_gem_sigs/nokogiri/nokogiri_html5.rbs +47 -0
  19. data/data/vendored_gem_sigs/pg/pg.rbs +212 -0
  20. data/data/vendored_gem_sigs/prism/prism_supplement.rbs +44 -0
  21. data/data/vendored_gem_sigs/redis/errors.rbs +50 -0
  22. data/data/vendored_gem_sigs/redis/future.rbs +5 -0
  23. data/data/vendored_gem_sigs/redis/redis.rbs +348 -0
  24. data/data/vendored_gem_sigs/redis/redis_extras.rbs +130 -0
  25. data/data/vendored_gem_sigs/rubygems/rubygems_extras.rbs +226 -0
  26. data/docs/handbook/01-getting-started.md +311 -0
  27. data/docs/handbook/02-everyday-types.md +337 -0
  28. data/docs/handbook/03-narrowing.md +359 -0
  29. data/docs/handbook/04-tuples-and-shapes.md +321 -0
  30. data/docs/handbook/05-methods-and-blocks.md +339 -0
  31. data/docs/handbook/06-classes.md +305 -0
  32. data/docs/handbook/07-rbs-and-extended.md +427 -0
  33. data/docs/handbook/08-understanding-errors.md +373 -0
  34. data/docs/handbook/09-plugins.md +241 -0
  35. data/docs/handbook/10-sorbet.md +347 -0
  36. data/docs/handbook/11-sig-gen.md +312 -0
  37. data/docs/handbook/12-lightweight-hkt.md +333 -0
  38. data/docs/handbook/README.md +275 -0
  39. data/docs/handbook/appendix-elixir.md +370 -0
  40. data/docs/handbook/appendix-go.md +399 -0
  41. data/docs/handbook/appendix-java-csharp.md +470 -0
  42. data/docs/handbook/appendix-liskov.md +580 -0
  43. data/docs/handbook/appendix-mypy.md +370 -0
  44. data/docs/handbook/appendix-phpstan.md +338 -0
  45. data/docs/handbook/appendix-protocols-and-structural-typing.md +292 -0
  46. data/docs/handbook/appendix-rust.md +446 -0
  47. data/docs/handbook/appendix-steep.md +336 -0
  48. data/docs/handbook/appendix-type-theory.md +1662 -0
  49. data/docs/handbook/appendix-typeprof.md +416 -0
  50. data/docs/handbook/appendix-typescript.md +332 -0
  51. data/docs/install.md +189 -0
  52. data/docs/llms.txt +72 -0
  53. data/docs/manual/01-installation.md +342 -0
  54. data/docs/manual/02-cli-reference.md +557 -0
  55. data/docs/manual/03-configuration.md +152 -0
  56. data/docs/manual/04-diagnostics.md +206 -0
  57. data/docs/manual/05-inspecting-types.md +109 -0
  58. data/docs/manual/06-baseline.md +104 -0
  59. data/docs/manual/07-plugins.md +92 -0
  60. data/docs/manual/08-skills.md +143 -0
  61. data/docs/manual/09-editor-integration.md +245 -0
  62. data/docs/manual/10-mcp-server.md +532 -0
  63. data/docs/manual/11-ci.md +274 -0
  64. data/docs/manual/12-caching.md +116 -0
  65. data/docs/manual/13-troubleshooting.md +120 -0
  66. data/docs/manual/14-rails-quickstart.md +332 -0
  67. data/docs/manual/15-type-protection-coverage.md +204 -0
  68. data/docs/manual/16-rbs-extended-annotations.md +190 -0
  69. data/docs/manual/17-driving-improvement.md +160 -0
  70. data/docs/manual/README.md +87 -0
  71. data/docs/manual/ci-templates/README.md +58 -0
  72. data/docs/manual/plugins/README.md +86 -0
  73. data/docs/manual/plugins/rigor-actioncable.md +78 -0
  74. data/docs/manual/plugins/rigor-actionmailer.md +74 -0
  75. data/docs/manual/plugins/rigor-actionpack.md +80 -0
  76. data/docs/manual/plugins/rigor-activejob.md +58 -0
  77. data/docs/manual/plugins/rigor-activerecord.md +102 -0
  78. data/docs/manual/plugins/rigor-activestorage.md +74 -0
  79. data/docs/manual/plugins/rigor-activesupport-core-ext.md +86 -0
  80. data/docs/manual/plugins/rigor-devise.md +70 -0
  81. data/docs/manual/plugins/rigor-dry-schema.md +56 -0
  82. data/docs/manual/plugins/rigor-dry-struct.md +60 -0
  83. data/docs/manual/plugins/rigor-dry-types.md +59 -0
  84. data/docs/manual/plugins/rigor-dry-validation.md +62 -0
  85. data/docs/manual/plugins/rigor-factorybot.md +76 -0
  86. data/docs/manual/plugins/rigor-graphql.md +89 -0
  87. data/docs/manual/plugins/rigor-hanami.md +83 -0
  88. data/docs/manual/plugins/rigor-mangrove.md +73 -0
  89. data/docs/manual/plugins/rigor-minitest.md +86 -0
  90. data/docs/manual/plugins/rigor-pundit.md +72 -0
  91. data/docs/manual/plugins/rigor-rails-i18n.md +92 -0
  92. data/docs/manual/plugins/rigor-rails-routes.md +94 -0
  93. data/docs/manual/plugins/rigor-rails.md +44 -0
  94. data/docs/manual/plugins/rigor-rbs-inline.md +83 -0
  95. data/docs/manual/plugins/rigor-rspec-rails.md +72 -0
  96. data/docs/manual/plugins/rigor-rspec.md +86 -0
  97. data/docs/manual/plugins/rigor-shoulda-matchers.md +78 -0
  98. data/docs/manual/plugins/rigor-sidekiq.md +78 -0
  99. data/docs/manual/plugins/rigor-sinatra.md +61 -0
  100. data/docs/manual/plugins/rigor-sorbet.md +63 -0
  101. data/docs/manual/plugins/rigor-statesman.md +75 -0
  102. data/docs/manual/plugins/rigor-typescript-utility-types.md +71 -0
  103. data/exe/rigor +1 -1
  104. data/lib/rigor/analysis/incremental_session.rb +4 -2
  105. data/lib/rigor/analysis/run_stats.rb +13 -1
  106. data/lib/rigor/analysis/runner.rb +54 -12
  107. data/lib/rigor/cli/check_command.rb +26 -3
  108. data/lib/rigor/cli/coverage_command.rb +67 -92
  109. data/lib/rigor/cli/coverage_mutation.rb +149 -0
  110. data/lib/rigor/cli/docs_command.rb +248 -0
  111. data/lib/rigor/cli/fused_protection_renderer.rb +67 -0
  112. data/lib/rigor/cli/fused_protection_report.rb +76 -0
  113. data/lib/rigor/cli/skill_command.rb +103 -41
  114. data/lib/rigor/cli/skill_describe.rb +346 -0
  115. data/lib/rigor/cli.rb +25 -3
  116. data/lib/rigor/config_audit.rb +152 -0
  117. data/lib/rigor/configuration.rb +12 -0
  118. data/lib/rigor/environment/rbs_loader.rb +27 -0
  119. data/lib/rigor/environment.rb +49 -1
  120. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +140 -38
  121. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +37 -6
  122. data/lib/rigor/inference/scope_indexer.rb +87 -89
  123. data/lib/rigor/inference/statement_evaluator.rb +27 -0
  124. data/lib/rigor/plugin/isolation.rb +5 -5
  125. data/lib/rigor/plugin/loader.rb +4 -2
  126. data/lib/rigor/protection/diagnostic_oracle.rb +51 -0
  127. data/lib/rigor/protection/mutation_scanner.rb +98 -38
  128. data/lib/rigor/protection/mutator.rb +21 -0
  129. data/lib/rigor/protection/test_suite_oracle.rb +68 -0
  130. data/lib/rigor/signature_path_audit.rb +92 -0
  131. data/lib/rigor/version.rb +1 -1
  132. data/skills/rigor-ask/SKILL.md +172 -0
  133. data/skills/rigor-doctor/SKILL.md +87 -0
  134. data/skills/rigor-editor-setup/SKILL.md +114 -0
  135. data/skills/rigor-mcp-setup/SKILL.md +117 -0
  136. data/skills/rigor-monkeypatch-resolve/SKILL.md +79 -0
  137. data/skills/rigor-next-steps/SKILL.md +113 -0
  138. data/skills/rigor-plugin-tune/SKILL.md +79 -0
  139. data/skills/rigor-protection-uplift/SKILL.md +133 -0
  140. data/skills/rigor-rbs-setup/SKILL.md +128 -0
  141. data/skills/rigor-upgrade/SKILL.md +79 -0
  142. metadata +120 -1
@@ -0,0 +1,338 @@
1
+ # Appendix — Coming from PHPStan
2
+
3
+ PHPStan is the closest spiritual peer Rigor has in another
4
+ language. Both tools share the same priorities: stay silent on
5
+ code the analyzer cannot characterise, lean on inference rather
6
+ than mandatory annotations, and surface a small catalogue of
7
+ high-confidence diagnostics. Many of Rigor's design choices —
8
+ config-file shape, baseline diffing, severity profiles, the
9
+ `assert` family of directives — were directly informed by
10
+ PHPStan.
11
+
12
+ If you have used PHPStan, the mental model carries over almost
13
+ unchanged. This appendix maps the vocabulary.
14
+
15
+ ## The five-second pitch
16
+
17
+ | Question | PHPStan | Rigor |
18
+ | --- | --- | --- |
19
+ | Where do annotations live? | PHPDoc `/** ... */` blocks | `.rbs` files alongside `.rb` |
20
+ | Default behaviour | Inference, fall back silent | Inference, fall back silent |
21
+ | "Levels" | 0 – 10 (numeric) | `lenient` / `balanced` / `strict` (named) |
22
+ | Per-rule control | `ignoreErrors:` regex, level demotion | `disable:`, `severity_overrides:` |
23
+ | Baseline | `phpstan-baseline.neon` | `.rigor-baseline.yml` (managed) / `rigor.baseline.json` (ad-hoc) |
24
+ | Stub format | PHP stub files | RBS files |
25
+ | Custom narrowing | Type-Specifying Extensions | Plugins (Chapter 9) |
26
+ | Custom return shape | Dynamic Return Type Extensions | Plugin `dynamic_return` |
27
+
28
+ The two tools agree on most of the foundational decisions. The
29
+ biggest differences are surface (Ruby's syntax and runtime
30
+ shape), not philosophy.
31
+
32
+ ## Type vocabulary mapping
33
+
34
+ PHPStan and Rigor have an overlapping refinement vocabulary; this
35
+ is the closest match of any peer.
36
+
37
+ | PHPStan PHPDoc | Rigor representation | Notes |
38
+ | --- | --- | --- |
39
+ | `string` | `String` | |
40
+ | `int` | `Integer` | |
41
+ | `float` | `Float` | |
42
+ | `bool` | `bool` (`Constant<true> \| Constant<false>`) | |
43
+ | `null` | `Constant<nil>` | Ruby has only `nil`. |
44
+ | `mixed` | `Top` | The "anything" carrier. |
45
+ | `never` | `Bot` | Empty type. |
46
+ | `void` | `void` | Same. |
47
+ | `array<T>` / `T[]` | `Array[T]` | |
48
+ | `array<K, V>` | `Hash[K, V]` | Ruby splits by container kind. |
49
+ | `array{name: string, age: int}` | `HashShape{name: String, age: Integer}` | Same per-key model. |
50
+ | `array{0: int, 1: string}` (list shape) | `Tuple[Integer, String]` | Same per-position model. |
51
+ | `non-empty-string` | `non-empty-string` | **Identical name and meaning.** |
52
+ | `non-falsy-string` | `non-empty-string` | Rigor does not split out the falsy-but-nonempty case. |
53
+ | `numeric-string` | `numeric-string` | Identical. |
54
+ | `lowercase-string` | `lowercase-string` | Identical. |
55
+ | `class-string` | `Singleton[T]` | Equivalent shape. |
56
+ | `int<1, 9>` | `int<1, 9>` | **Identical syntax.** |
57
+ | `positive-int` | `positive-int` | Identical. |
58
+ | `negative-int` | `negative-int` | Identical. |
59
+ | `non-zero-int` | `non-zero-int` | Identical. |
60
+ | `non-empty-array<T>` | `non-empty-array[T]` | Identical. |
61
+ | `non-empty-list<T>` | (no separate carrier — `non-empty-array[T]` covers it) | Ruby has no list/dict split. |
62
+ | `T \| U` | `T \| U` | |
63
+ | `T & U` | `Intersection[T, U]` | |
64
+ | `literal-string` | `literal-string` | **Identical concept.** Provably built from source-code literals. |
65
+ | `'hello'` (literal type) | `Constant<"hello">` | |
66
+ | `42` (literal type) | `Constant<42>` | |
67
+
68
+ This table is the densest in the appendix because the overlap
69
+ is so close. If you are reading PHPStan's "PHPDoc Types" page
70
+ in another tab, almost every advanced refinement transfers.
71
+
72
+ ## The `@phpstan-assert` family
73
+
74
+ PHPStan's assertion-narrowing PHPDoc tags map directly onto
75
+ Rigor's `RBS::Extended` directive grammar. Chapter 7 covers
76
+ the table in depth; here it is again for reference:
77
+
78
+ | PHPStan PHPDoc | Rigor RBS::Extended | Effect |
79
+ | --- | --- | --- |
80
+ | `@phpstan-assert T $x` | `%a{rigor:v1:assert x is T}` | After return, caller's `x` is `T`. |
81
+ | `@phpstan-assert-if-true T $x` | `%a{rigor:v1:predicate-if-true x is T}` | If method returns truthy, caller's `x` is `T`. |
82
+ | `@phpstan-assert-if-false T $x` | `%a{rigor:v1:predicate-if-false x is T}` | If method returns falsey, caller's `x` is `T`. |
83
+ | `@phpstan-assert !T $x` | `%a{rigor:v1:assert x is ~T}` | After return, caller's `x` is **not** `T`. |
84
+ | `@phpstan-assert =T $x` (assert-and-narrow) | (covered by `assert:`) | Same effect. |
85
+ | `@phpstan-self-out T` | `%a{rigor:v1:assert self is T}` | `self` narrows in caller scope. |
86
+ | `@phpstan-impure` | (no analogue) | Rigor does not yet model purity for fold-through-method-call. |
87
+
88
+ Every directive Rigor's grammar ships has a PHPStan PHPDoc
89
+ analogue. If you have a PHPStan-shaped mental model for "what
90
+ narrows what after this method returns," it transfers
91
+ unchanged.
92
+
93
+ ## Type-Specifying Extensions ↔ Plugins
94
+
95
+ When the assertion is recognised by **call shape** rather than
96
+ by signature — PHPStan's `TypeSpecifyingExtension` interface,
97
+ where you write a class that the framework instantiates and
98
+ asks "given this call, what narrowings does it produce?" —
99
+ Rigor's analogue is a plugin's `type_specifier` / `dynamic_return` and
100
+ `#diagnostics_for_file` hooks plus the engine's
101
+ `post_return_facts` substrate.
102
+
103
+ | PHPStan extension type | Rigor analogue |
104
+ | --- | --- |
105
+ | `MethodTypeSpecifyingExtension` | Plugin's `Fact(target_kind: :parameter)` returned from `type_specifier` |
106
+ | `StaticMethodTypeSpecifyingExtension` | Same, with `Fact(target_kind: :receiver-class)` |
107
+ | `FunctionTypeSpecifyingExtension` | Same, with `Fact(target_kind: :argument)` |
108
+ | `DynamicMethodReturnTypeExtension` | Plugin's `dynamic_return(methods:) { |call_node, scope, ...| ... }` |
109
+ | `DynamicStaticMethodReturnTypeExtension` | Same, varying by receiver-class branch in plugin code |
110
+ | `DynamicFunctionReturnTypeExtension` | Same, for module-level methods |
111
+
112
+ The plugin contract pinned at
113
+ [`docs/internal-spec/plugin.md`](../internal-spec/plugin.md)
114
+ gives every shape PHPStan's extension API covers, with
115
+ analogous lifecycle (manifest declaration, per-call dispatch,
116
+ fact emission). Chapter 9 has the high-level orientation; the
117
+ internal spec is the binding contract.
118
+
119
+ The `rigor-sorbet` adapter in Chapter 10 is itself a worked
120
+ example of a "Type-Specifying Extension at scale" — every
121
+ `T.must`, `T.cast`, `T.bind`, `T.assert_type!` call is
122
+ recognised by call shape, not by sig.
123
+
124
+ ## Configuration
125
+
126
+ PHPStan's `phpstan.neon` and Rigor's `.rigor.yml` /
127
+ `.rigor.dist.yml` use the same shape: a single config file at
128
+ the project root, autoloaded if present, with `paths:`,
129
+ severity controls, and includes.
130
+
131
+ | PHPStan | Rigor |
132
+ | --- | --- |
133
+ | `phpstan.neon` | `.rigor.yml` |
134
+ | `phpstan.neon.dist` | `.rigor.dist.yml` |
135
+ | `paths:` | `paths:` |
136
+ | `level:` | `severity_profile:` |
137
+ | `excludePaths:` | (no analogue today — paths are explicitly listed) |
138
+ | `ignoreErrors:` (regex / pattern) | `disable:` (rule identifier or wildcard) |
139
+ | `parameters: ignoreErrors:` per-path | `# rigor:disable-file <rule>` at the file head |
140
+ | `includes:` | `includes:` |
141
+ | `phpstan-baseline.neon` | `.rigor-baseline.yml` (managed) — or `rigor.baseline.json` (ad-hoc) |
142
+ | `phpstan analyse --generate-baseline` | `rigor baseline generate` (managed) — or `rigor check --format=json > rigor.baseline.json` (ad-hoc) |
143
+ | `phpstan analyse` | `rigor check` |
144
+ | `phpstan analyse --baseline` | `rigor check` with `baseline: .rigor-baseline.yml` configured (managed) — or `rigor diff rigor.baseline.json` (ad-hoc) |
145
+ | Path resolution: relative to declaring file | Path resolution: relative to declaring file (same rule). |
146
+
147
+ Rigor has two baseline mechanisms: a **managed** baseline
148
+ (`rigor baseline generate` → `.rigor-baseline.yml`, read on
149
+ the next `rigor check` via the `baseline:` config key — the
150
+ closest match to PHPStan's `--baseline`), and a **lightweight**
151
+ ad-hoc snapshot (`rigor diff` over a `--format=json` dump).
152
+ Chapter 8 has the walkthrough.
153
+
154
+ The `includes:` semantics also match PHPStan's: declaration
155
+ order, later overrides earlier, the current file's keys win
156
+ over included files. Rigor's `.rigor.yml` does NOT auto-merge
157
+ with `.rigor.dist.yml` — the override must list the dist file
158
+ explicitly under `includes:`. PHPStan has the same behaviour
159
+ when you have both `phpstan.neon` and `phpstan.neon.dist` in
160
+ play.
161
+
162
+ ## Stubs ↔ RBS
163
+
164
+ PHPStan reads PHP stub files (`.stub`) for libraries that ship
165
+ no PHPDoc. Rigor reads `.rbs` files for the same purpose. The
166
+ dispatch is similar: both tools layer "stub-declared
167
+ contract beats inferred-from-body," and both use the stub
168
+ files as the canonical place to attach refinements via
169
+ PHPDoc / `RBS::Extended` annotations.
170
+
171
+ | PHPStan | Rigor |
172
+ | --- | --- |
173
+ | `*.stub` files | `.rbs` files in `sig/` (project) and `rbs_collection.lock.yaml` (third-party) |
174
+ | PHPDoc on stubs | `RBS::Extended` `%a{rigor:v1:...}` annotations |
175
+ | `#[Override]` / `#[\Deprecated]` attributes | RBS `attr_*` and `def` declarations |
176
+ | `phpstan/extension-installer` | Bundler + `Gemfile` for plugin gems |
177
+
178
+ A practical pattern that works in both worlds: keep the stub /
179
+ RBS file authoritative for the public contract, then layer
180
+ project-specific tightenings under
181
+ `@phpstan-*` / `RBS::Extended` directives that ship alongside
182
+ the stub.
183
+
184
+ ## Severity profiles vs PHPStan levels
185
+
186
+ PHPStan's levels are a numeric ladder (0 = "shapes only," 10 =
187
+ "strictest"). Rigor's profiles are named (`lenient`,
188
+ `balanced`, `strict`).
189
+
190
+ | PHPStan level | Rigor profile (rough) | Notes |
191
+ | --- | --- | --- |
192
+ | 0 – 2 | `lenient` | Most rules → `:warning`; uncertain rules drop to `:info`. |
193
+ | 3 – 6 | `balanced` (default) | Most rules → `:error`. |
194
+ | 7 – 10 | `strict` | Everything → `:error`, including `:warning` rules in `balanced`. |
195
+
196
+ The mapping is approximate (the rule sets are not 1:1), but
197
+ the practical advice is the same: start with the default,
198
+ tighten over time. Chapter 8's "helpful workflow" matches the
199
+ PHPStan onboarding pattern.
200
+
201
+ ## "No annotations needed" — yes, but with stubs
202
+
203
+ PHPStan and Rigor share the philosophy that **inference does
204
+ the heavy lifting**. You do not annotate every variable; you
205
+ annotate the boundary (function signatures, library stubs)
206
+ and inference propagates inward.
207
+
208
+ PHPStan's catch is that PHPDoc lives in the same file as the
209
+ PHP source. Rigor's catch is that RBS lives in `sig/`, a
210
+ parallel tree. The trade-offs are well-known:
211
+
212
+ - **Same-file PHPDoc** keeps the docs adjacent to the code
213
+ they describe — easier to update, harder to forget.
214
+ - **Parallel `.rbs`** keeps the runtime source clean for
215
+ developers who do not care about types — no PHPDoc clutter
216
+ on production methods.
217
+
218
+ Rigor leans toward the parallel-file model for cultural reasons
219
+ (Ruby's tradition of compact source), but `RBS::Inline`
220
+ provides an in-file alternative for projects that want
221
+ PHPDoc-style adjacency. See ADR-1 for the rationale.
222
+
223
+ ## What PHPStan has and Rigor does not
224
+
225
+ - **Generics with bounded constraints across the stub library.**
226
+ PHPStan's generics ecosystem is more mature; RBS generics
227
+ exist but the standard library's coverage is patchier.
228
+ - **`@phpstan-impure` and pure-by-default modelling.** Rigor
229
+ catalogues per-method purity inside its built-in
230
+ `data/builtins/ruby_core/` YAML, but does not yet expose a
231
+ user-facing way to declare a method pure for fold-through.
232
+ - **Custom rules.** PHPStan's `Rule` interface lets you write a
233
+ rule in PHP that fires on AST patterns; Rigor's plugin
234
+ surface covers diagnostics emission via
235
+ `#diagnostics_for_file`, but the rule shape is less polished
236
+ than PHPStan's framework.
237
+ - **`treatPhpDocTypesAsCertain`.** PHPStan's "trust PHPDoc"
238
+ knob has no Rigor equivalent — Rigor always trusts RBS
239
+ declarations as authoritative.
240
+
241
+ ## What Rigor has and PHPStan does not
242
+
243
+ - **Constant folding through method calls.** PHPStan does some
244
+ constant propagation; Rigor folds aggressively through
245
+ catalogued built-ins (`Numeric`, `String`, `Symbol`, `Array`,
246
+ `Hash`).
247
+ - **First-class flow-sensitive narrowing on Ruby's predicate
248
+ methods.** `s.empty?` / `n.zero?` / `n.positive?` etc. are
249
+ recognised by name and narrow accordingly. PHPStan has the
250
+ same idea via Type-Specifying Extensions, but Rigor ships
251
+ the catalogue out of the box.
252
+ - **`literal-string` carrier.** Both tools have the concept,
253
+ but Rigor's carrier composes through interpolation —
254
+ `"#{a}#{b}"` is `literal-string` if both `a` and `b` are.
255
+ PHPStan has `literal-string` for "literal at this position"
256
+ but the propagation rules are different.
257
+ - **Sorbet-input adapter.** If your project happens to be
258
+ partially-Sorbet (you migrated some files to RBS but kept
259
+ the rest), Rigor reads both sources concurrently. PHPStan
260
+ has nothing analogous — there is no parallel "Sorbet of
261
+ PHP."
262
+
263
+ ## A migration vignette
264
+
265
+ You are porting a PHPStan-tightened library to Ruby. The
266
+ original PHP:
267
+
268
+ ```php
269
+ class Slug {
270
+ /**
271
+ * @phpstan-param non-empty-string $name
272
+ * @phpstan-return non-empty-lowercase-string
273
+ */
274
+ public function normalise(string $name): string {
275
+ return strtolower(preg_replace('/\s+/', '-', $name));
276
+ }
277
+
278
+ /**
279
+ * @phpstan-assert non-empty-string $value
280
+ */
281
+ public function assertNotEmpty(string $value): void {
282
+ if ($value === '') throw new InvalidArgumentException();
283
+ }
284
+ }
285
+ ```
286
+
287
+ The Rigor port — Ruby source unchanged from idiomatic, RBS at
288
+ the boundary:
289
+
290
+ ```ruby
291
+ # lib/slug.rb
292
+ class Slug
293
+ def normalise(name)
294
+ name.downcase.gsub(/\s+/, "-")
295
+ end
296
+
297
+ def assert_not_empty(value)
298
+ raise ArgumentError if value.empty?
299
+ end
300
+ end
301
+ ```
302
+
303
+ ```rbs
304
+ # sig/slug.rbs
305
+ class Slug
306
+ %a{rigor:v1:param: name is non-empty-string}
307
+ %a{rigor:v1:return: non-empty-lowercase-string}
308
+ def normalise: (String name) -> String
309
+
310
+ %a{rigor:v1:assert value is non-empty-string}
311
+ def assert_not_empty: (String value) -> void
312
+ end
313
+ ```
314
+
315
+ The directive grammar is structurally a translation: every
316
+ PHPStan `@phpstan-*` becomes a `%a{rigor:v1:...}` annotation on
317
+ the matching `def` line in the `.rbs` file.
318
+
319
+ ## What's next
320
+
321
+ You probably do not need to read the rest of this appendix
322
+ section sequentially. Three useful pointers:
323
+
324
+ - [Chapter 7 — RBS and `RBS::Extended`](07-rbs-and-extended.md)
325
+ has the full directive grammar, including the PHPStan-mapping
326
+ table that this page summarises.
327
+ - [Chapter 8 — Understanding errors](08-understanding-errors.md)
328
+ covers the rule catalogue, severity profiles, baseline
329
+ diffing — every PHPStan onboarding analogue.
330
+ - [Chapter 9 — Plugins](09-plugins.md) for the
331
+ Type-Specifying / Dynamic-Return analogues.
332
+
333
+ If you want to compare against another tool, the sibling
334
+ appendix pages cover [TypeScript](appendix-typescript.md),
335
+ [mypy](appendix-mypy.md), [Steep](appendix-steep.md),
336
+ [TypeProf](appendix-typeprof.md),
337
+ [Java / C#](appendix-java-csharp.md), [Rust](appendix-rust.md),
338
+ [Go](appendix-go.md), and [Elixir](appendix-elixir.md).
@@ -0,0 +1,292 @@
1
+ # Appendix — Protocols, interfaces, and structural typing
2
+
3
+ If you arrived from Python, "protocol" means one specific thing:
4
+ `typing.Protocol`, PEP 544's **structural typing** — a class
5
+ satisfies a protocol by *having the right methods*, no inheritance
6
+ required. It is "static duck typing," and it is one of the first
7
+ things a Python typist reaches for.
8
+
9
+ That instinct is right, but the *word* is a trap in Rigor. Rigor's
10
+ structural-typing feature is not called "protocol" — it is the RBS
11
+ **`interface`**. Meanwhile "protocol" *does* appear in Rigor, naming
12
+ a **different** feature: a framework's path-scoped *behavioural
13
+ contract* ([ADR-28](../adr/28-path-scoped-protocol-contracts.md)).
14
+ This appendix untangles the two so you reach for the right one.
15
+
16
+ > **The one-line version.** Rigor's `interface` is **structural** —
17
+ > like Go's `interface` and Python's `Protocol`. A class fits by
18
+ > *having the methods*; there is **no `implements`** clause (Ruby has
19
+ > none). It is *not* the Java / PHP nominal `interface`, where a class
20
+ > declares conformance by name. Because the bare word "interface"
21
+ > reads as the Java / PHP kind to many Ruby developers (Ruby has no
22
+ > `interface` keyword, so the intuition is imported), Rigor's docs
23
+ > qualify it on first use — **"structural interface"** or **"RBS
24
+ > interface"** — and so should you when writing about it.
25
+
26
+ ## Two things called "protocol"
27
+
28
+ | You mean… | The Rigor word | What it is | Lives where |
29
+ | --- | --- | --- | --- |
30
+ | "static duck typing" — a class fits if it has the methods (Python `Protocol`) | **interface** | A structural *type* in the type lattice. | RBS `interface _Foo`, read from `sig/` |
31
+ | "every action under `app/actions/` must define `#handle`" — a framework convention enforced by tooling | **protocol contract** | A *behavioural contract* bound to classes by file path, declared by a plugin. | A plugin's `protocol_contracts:` manifest field (ADR-28) |
32
+
33
+ The two are different axes, not two flavours of one
34
+ thing, and the rest of this page is mostly about keeping them
35
+ apart. The one-line discriminator:
36
+
37
+ - An **interface** is a *type* you name in a signature. The check
38
+ happens *where the interface is mentioned* (`def f: (_Closable)
39
+ -> void` checks `f`'s argument).
40
+ - A **protocol contract** is *never mentioned by the conforming
41
+ class or any signature*. A plugin says "the classes under this
42
+ directory carry this contract," and the engine provisions and
43
+ checks them implicitly.
44
+
45
+ ## Structural typing: RBS interfaces
46
+
47
+ This is the direct analogue of Python's `Protocol`. RBS has had
48
+ structural `interface _Foo` since its first release; Rigor reads
49
+ them from `sig/` and checks conformance structurally.
50
+
51
+ ```python
52
+ # Python (PEP 544)
53
+ class SupportsClose(Protocol):
54
+ def close(self) -> None: ...
55
+ ```
56
+
57
+ ```rbs
58
+ # RBS — the same idea
59
+ interface _SupportsClose
60
+ def close: () -> void
61
+ end
62
+ ```
63
+
64
+ A class that defines `close` with a compatible signature satisfies
65
+ the interface **with no `include`, no superclass, and no runtime
66
+ marker** — the match is structural. From the normative spec
67
+ ([`structural-interfaces-and-object-shapes.md`](../type-specification/structural-interfaces-and-object-shapes.md)):
68
+
69
+ > An RBS interface type … is a *named structural contract*. A
70
+ > nominal type or object shape is assignable to an interface when
71
+ > Rigor can prove that it provides all required members with
72
+ > compatible types.
73
+
74
+ Where does Rigor apply structural matching? Deliberately at the
75
+ **boundaries**, not class-to-class by default:
76
+
77
+ - assigning or passing a value where an RBS interface is expected;
78
+ - checking whether an inferred object shape satisfies an interface;
79
+ - a direct method send against a known shape;
80
+ - plugin-provided dynamic reflection adding members to a shape.
81
+
82
+ Ordinary `Foo`/`Bar` class compatibility stays **nominal** — Ruby's
83
+ own `is_a?` / `kind_of?` depend on the class hierarchy, and RBS uses
84
+ class names as declarations about Ruby constants, so Rigor does
85
+ *not* go fully TypeScript-style-structural for classes. Structural
86
+ typing is a tool you opt into by naming an interface, exactly as in
87
+ Python you opt in by annotating against a `Protocol`. (The
88
+ [mypy / Pyright appendix](appendix-mypy.md#protocols--rbs-interfaces)
89
+ covers the same mapping from the Python side.)
90
+
91
+ ## Object shapes and capability roles
92
+
93
+ RBS interfaces are the *named* structural types. Rigor also infers
94
+ **anonymous** structural types — *object shapes* — from what a value
95
+ demonstrably responds to, and ships a curated catalogue of
96
+ **capability roles** for the common IO-like contracts:
97
+
98
+ - `_Reader`, `_Writer` — the read/write halves of a stream;
99
+ - `_RewindableStream`, `_ClosableStream` — `rewind` / `close`
100
+ capabilities;
101
+ - `_Callable` — responds to `call`.
102
+
103
+ These keep `IO` and `StringIO` as separate *nominal* types while
104
+ letting each satisfy the smaller structural *roles* a method
105
+ actually needs — the structural-typing payoff (write against the
106
+ capability, not the concrete class) without giving up nominal
107
+ identity where Ruby's runtime relies on it.
108
+
109
+ If you came looking for "Rigor's Protocol," **this section is it**:
110
+ RBS interfaces + object shapes + capability roles are Rigor's
111
+ structural-typing surface. None of it is spelled "protocol."
112
+
113
+ ## The word vs the semantics, across languages
114
+
115
+ Why does Rigor spell its structural type `interface` and reserve
116
+ `protocol` for something else? Because the *word* "protocol" and the
117
+ *semantics* "structural typing" have drifted apart across languages,
118
+ and Rigor picks the spelling that least surprises a Ruby reader (RBS
119
+ already says `interface`).
120
+
121
+ The term itself is old. **Smalltalk** (1970s) called the set of
122
+ messages an object understands its *protocol* — grouped into "message
123
+ protocols" in the class browser. It was never a static-checking
124
+ construct; it named "what you can send this object," the direct
125
+ ancestor of Ruby's duck typing. Every later use inherits that core
126
+ idea — "a set of methods a conforming object provides" — and then
127
+ adds its own rules about *how conformance is established*.
128
+
129
+ Two such rules matter, and they are independent of the spelling:
130
+
131
+ - **Structural / implicit** — you conform by *having the methods*. No
132
+ declaration. (Python `Protocol`, Go `interface`, **RBS `interface`**,
133
+ Smalltalk's original sense.)
134
+ - **Nominal / explicit** — you conform by *declaring that you do*.
135
+ (Java / PHP `interface ... implements`, Swift / Objective-C
136
+ `protocol` with explicit adoption.)
137
+
138
+ The spelling does **not** track the rule; the two crossed long ago:
139
+
140
+ | Language | Spelling | Conformance | Same as Rigor? |
141
+ | --- | --- | --- | --- |
142
+ | Smalltalk | "protocol" | (dynamic; duck typing) | ancestor of the idea |
143
+ | **Rigor / RBS** | **`interface`** | **structural / implicit** | — |
144
+ | Python (PEP 544) | `Protocol` | structural / implicit | ✅ same model, different word |
145
+ | Go | `interface` | structural / implicit | ✅ same model, same word |
146
+ | Java / PHP | `interface` | nominal / explicit `implements` | ❌ same word, opposite model |
147
+ | Swift / Objective-C | `protocol` | nominal / explicit adoption | ❌ different word *and* model |
148
+
149
+ So a reader's intuition depends entirely on where they came from:
150
+
151
+ - **From Swift / Objective-C:** "protocol" means a type you *declare*
152
+ conformance to (`struct Resource: Closable`). Rigor's `interface`
153
+ needs no such declaration — just define `close`. (Objective-C's
154
+ `respondsToSelector:` and informal protocols are the runtime
155
+ duck-typing escape hatch; Swift allows *retroactive* conformance via
156
+ extensions, but adoption is still explicit.) And the one Rigor
157
+ surface that reuses the *word* — the **protocol contract** below — is
158
+ not Swift's protocol either: it binds classes by file path, not by an
159
+ adoption clause.
160
+ - **From Java / PHP:** "interface" means a contract a class must
161
+ `implement` by name. Rigor reuses the *word* but not the rule: a
162
+ Ruby class satisfies an RBS `interface` structurally, never by an
163
+ `implements` clause (Ruby has none).
164
+ - **From Python or Go:** you are already home. RBS `interface` is your
165
+ `Protocol` / `interface` — structural, implicit, checked where it is
166
+ named.
167
+
168
+ And the Smalltalk sense — "a named set of messages a conforming type
169
+ must provide" — is exactly what Rigor revives under the name **protocol
170
+ contract** in the next section. Fittingly, that is the one Rigor *does*
171
+ spell "protocol."
172
+
173
+ ## Protocol contracts (ADR-28)
174
+
175
+ Now the other axis. A Rack-shaped web framework expects a
176
+ controller action to take a request and return a response; a job
177
+ framework expects `#perform`; a serializer expects `#call`. The
178
+ convention is real, but it lives in the framework's prose — *no
179
+ class declaration records it*, and nothing checks it, so a
180
+ violation is a runtime surprise.
181
+
182
+ An RBS interface cannot express this. RBS (like Python) has **no
183
+ "every class under this directory implements interface I" form** —
184
+ an interface only bites where a signature names it, and these
185
+ controllers name nothing. That gap is what ADR-28's **protocol
186
+ contracts** fill.
187
+
188
+ A plugin that knows the framework declares a contract in its
189
+ manifest (`param_types` is an array of positional
190
+ `{ index:, type_name: }` provisions):
191
+
192
+ ```ruby
193
+ # inside a framework plugin's manifest — an illustrative serializer contract
194
+ protocol_contracts: [
195
+ Rigor::Plugin::ProtocolContract.new(
196
+ path_glob: "app/serializers/**/*.rb", # which files
197
+ method_name: :call, # the method every class must define
198
+ param_types: [{ index: 0, type_name: "ActiveRecord::Base" }],
199
+ return_type_name: "String",
200
+ severity: :error
201
+ )
202
+ ]
203
+ ```
204
+
205
+ The engine then does **provide-and-check**:
206
+
207
+ - **Provide** (engine-side). When binding a `def call(record)` in a
208
+ matching file, the contract's `param_types` *replace* the usual
209
+ `Dynamic[top]` the un-annotated parameter would get. The body is
210
+ then analysed as if `record` carried its real type — so a misuse
211
+ inside the body (`record.no_such_column`) surfaces as an ordinary
212
+ `call.undefined-method`, and the inferred return type is precise.
213
+ - **Check** (plugin-side). The plugin confirms every class in a
214
+ matching file defines the method (else `missing-protocol-method`)
215
+ and that its inferred return type conforms to `return_type_name`
216
+ (else `protocol-return-mismatch`).
217
+
218
+ The provision half is the load-bearing one: without it, `request`
219
+ is `Dynamic[top]`, which answers every method, so any return built
220
+ from it is also `Dynamic[top]` and the return check is vacuous.
221
+
222
+ Two things to note as an *application* developer (not a plugin
223
+ author):
224
+
225
+ - You **never write** `protocol_contracts:` — the framework's
226
+ plugin does. You write a plain `def handle(request)`, and it gets
227
+ checked for free.
228
+ - The `missing-protocol-method` / `protocol-return-mismatch`
229
+ diagnostics are **plugin diagnostics**, emitted under the
230
+ plugin's `plugin.<id>.` provenance — not core Rigor rules. The
231
+ worked references are [`examples/rigor-web/`](../../examples/rigor-web/)
232
+ (the minimal tutorial) and [`plugins/rigor-hanami/`](../../plugins/rigor-hanami/)
233
+ (production Hanami 2 actions).
234
+
235
+ ## Interface vs protocol contract
236
+
237
+ | | RBS `interface` (structural type) | ADR-28 protocol contract |
238
+ | --- | --- | --- |
239
+ | **What it is** | A type in the lattice | A tooling-enforced convention; *not* a type |
240
+ | **Cross-language analogue** | Python `Protocol`, Go `interface` | the Smalltalk "required message set" sense — but bound by **file path**, a mechanism no mainstream `protocol`/`interface` has |
241
+ | **How a class opts in** | Structurally — just have the methods | Implicitly — be defined under the path glob |
242
+ | **Where it's referenced** | Named in a signature (`(_Closable) -> void`) | Named *nowhere*; bound by file path |
243
+ | **Where the check fires** | At the use site that names the interface | At the contracted `def` (provide) + per class (check) |
244
+ | **Who declares it** | Whoever writes the `.rbs` | A framework plugin's manifest |
245
+ | **Provides parameter types?** | No (it's a type, used where named) | **Yes** — into an un-annotated `def` |
246
+ | **Diagnostics** | Core type errors at the use site | `missing-protocol-method`, `protocol-return-mismatch` (plugin) |
247
+
248
+ The naming overlap is historical (see [the cross-language
249
+ detour](#the-word-vs-the-semantics-across-languages) above): the
250
+ Smalltalk "set of required messages" sense survives in the *protocol
251
+ contract*, while Python's `typing.Protocol` reused the word for the
252
+ *structural-type* idea that Ruby/RBS spell `interface`. So in Rigor,
253
+ "protocol" never means the structural type.
254
+
255
+ ## Which one do I want?
256
+
257
+ - You want to **write a method that accepts anything with a
258
+ `#close`** → that is a structural type. Declare an RBS
259
+ **`interface _Closable`** and annotate against it (or rely on
260
+ inferred object shapes for the anonymous case). See
261
+ [Chapter 7 — RBS and RBS::Extended](07-rbs-and-extended.md).
262
+ - You want to **enforce that every class in a directory implements
263
+ a framework method with given parameter/return types** → that is
264
+ a **protocol contract**, and it is a *plugin-authoring* feature.
265
+ See [ADR-28](../adr/28-path-scoped-protocol-contracts.md) and the
266
+ [`examples/`](../../examples/README.md) walkthroughs.
267
+ - You are an **application developer** using such a framework → you
268
+ do neither explicitly. Write idiomatic Ruby; the framework's
269
+ plugin supplies the contract, and Rigor checks your actions
270
+ against it. When you see `missing-protocol-method`, you forgot
271
+ the method the framework requires; when you see
272
+ `protocol-return-mismatch`, your action returns the wrong shape.
273
+
274
+ ## What's next
275
+
276
+ - [Chapter 7 — RBS and RBS::Extended](07-rbs-and-extended.md) — how
277
+ to write the `.rbs` an interface lives in.
278
+ - [Chapter 9 — Plugins](09-plugins.md) and the
279
+ [examples/ landing page](../../examples/README.md) — where
280
+ protocol contracts are authored.
281
+ - [`docs/type-specification/structural-interfaces-and-object-shapes.md`](../type-specification/structural-interfaces-and-object-shapes.md)
282
+ — the normative spec for interfaces, object shapes, and
283
+ capability roles.
284
+ - [ADR-28](../adr/28-path-scoped-protocol-contracts.md) — the
285
+ protocol-contract design decision and its rejected alternatives
286
+ (including *why* an RBS interface bound by directory was not the
287
+ route).
288
+ - Coming from another checker? The
289
+ [mypy / Pyright appendix](appendix-mypy.md#protocols--rbs-interfaces)
290
+ maps `Protocol` ↔ RBS `interface` from the Python side, and the
291
+ [type-theory appendix](appendix-type-theory.md) places nominal vs
292
+ structural typing in the broader landscape.