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,347 @@
1
+ # Coexisting with Sorbet
2
+
3
+ If your project already uses [Sorbet](https://sorbet.org/),
4
+ the [`rigor-sorbet`](../../plugins/rigor-sorbet/) plugin
5
+ lets Rigor read your existing `sig` blocks, RBI files, and
6
+ `T.let` / `T.cast` / `T.must` / `T.unsafe` assertions as type
7
+ sources. You do not have to rewrite anything in RBS to start
8
+ running `rigor check` alongside `srb tc`.
9
+
10
+ This chapter is for users arriving from a Sorbet-using
11
+ project. If you have never used Sorbet, you can skip it; the
12
+ core handbook material in chapters 1–9 covers Rigor's native
13
+ RBS-based path.
14
+
15
+ ## What gets translated
16
+
17
+ Given a method preceded by a `sig` block:
18
+
19
+ ```ruby
20
+ class Slug
21
+ extend T::Sig
22
+
23
+ sig { params(name: String).returns(String) }
24
+ def normalise(name)
25
+ name.downcase.gsub(/\s+/, "-")
26
+ end
27
+
28
+ sig { returns(Integer) }
29
+ def self.default_length
30
+ 32
31
+ end
32
+ end
33
+ ```
34
+
35
+ Rigor lifts the parsed sig at every call site, so chained
36
+ calls resolve through the analyzer's normal dispatch:
37
+
38
+ ```ruby
39
+ slug = Slug.new
40
+ slug.normalise("Alice").upcase # ✓ String#upcase resolves
41
+ Slug.default_length.even? # ✓ Integer#even? resolves
42
+ ```
43
+
44
+ No `.rbs` file required. The plugin walks every Ruby file
45
+ under `paths:` (and every `.rbi` file under `sorbet/rbi/` —
46
+ see "RBI files" below), pairs each `sig { ... }` block with
47
+ the `def` immediately following it, and contributes the
48
+ return type at the matching call sites.
49
+
50
+ ## The Sorbet type vocabulary
51
+
52
+ The plugin translates the dense middle of Sorbet's type DSL.
53
+ Most everyday sigs land precisely; rare or
54
+ class-introspection-heavy forms degrade to `Dynamic[top]`.
55
+
56
+ | Sorbet form | Rigor representation |
57
+ | ------------------------ | ---------------------------------------- |
58
+ | `Integer` etc. | `Nominal["Integer"]` |
59
+ | `::Foo::Bar` | `Nominal["Foo::Bar"]` |
60
+ | `T.untyped` | `Dynamic[top]` |
61
+ | `T.anything` | `Top` |
62
+ | `T.noreturn` | `Bot` |
63
+ | `T.nilable(X)` | `Union[X, Constant<nil>]` |
64
+ | `T.any(A, B, ...)` | `Union[A, B, ...]` |
65
+ | `T.all(A, B, ...)` | `Intersection[A, B, ...]` |
66
+ | `T::Boolean` | `Union[Constant<true>, Constant<false>]` |
67
+ | `T::Array[E]` | `Nominal["Array", [E]]` |
68
+ | `T::Hash[K, V]` | `Nominal["Hash", [K, V]]` |
69
+ | `T::Set[E]` | `Nominal["Set", [E]]` |
70
+ | `T::Range[E]` | `Nominal["Range", [E]]` |
71
+ | `T::Enumerable[E]` | `Nominal["Enumerable", [E]]` |
72
+ | `T::Class[T]` | `Singleton[T-class-name]` (lossy) |
73
+ | `T.class_of(C)` | `Singleton[C]` |
74
+ | `[A, B]` (tuple in sig) | `Tuple[A, B]` |
75
+ | `{a: A, b: B}` | `HashShape{a: A, b: B}` (closed) |
76
+
77
+ Anything outside this table — `T.proc`, `T.attached_class`,
78
+ `T.self_type`, `T.type_parameter`, `T::Struct` / `T::Enum`
79
+ subclasses — silently degrades to `Dynamic[top]` for now.
80
+
81
+ ## Inline type assertions
82
+
83
+ Sorbet's `T.let` / `T.cast` / `T.must` / `T.unsafe`
84
+ expressions are recognised at every call site, not only inside
85
+ `sig` blocks:
86
+
87
+ ```ruby
88
+ counter = T.let(0, Integer) # widens Constant<0> to Integer
89
+ counter.even? # ✓ Integer#even? resolves
90
+
91
+ T.cast(some_value, String).upcase # ✓ String#upcase resolves
92
+
93
+ maybe = T.let(nil, T.nilable(Integer))
94
+ T.must(maybe).bit_length # ✓ nil stripped → Integer
95
+ # then Integer#bit_length resolves
96
+
97
+ T.unsafe(opaque).any_method_at_all # ✓ silenced — return is Dynamic[top]
98
+ ```
99
+
100
+ `T.must_because(expr, "explanation")` is recognised as an
101
+ alias of `T.must` — the static behaviour is identical (strip
102
+ `nil`); the second-argument string is informational only.
103
+
104
+ `T.reveal_type(expr)` returns `expr` unchanged at runtime AND
105
+ surfaces the inferred static type as a
106
+ `plugin.sorbet.reveal-type` `:info` diagnostic at the call
107
+ site, so chained calls keep working while you eyeball what
108
+ the analyzer sees:
109
+
110
+ ```ruby
111
+ n = T.let(3, Integer)
112
+ T.reveal_type(n).even? # info: T.reveal_type inferred type: Integer
113
+ # ✓ Integer#even? still resolves
114
+ ```
115
+
116
+ `T.assert_type!(expr, T)` is `T.cast` plus a static subtype
117
+ check. The call returns the asserted type so chained calls
118
+ resolve through it; if the inferred type is provably
119
+ incompatible (`Inference::Acceptance.accepts(...)` returns
120
+ `:no`), the plugin emits `plugin.sorbet.assert-type-mismatch`
121
+ as `:error`. Gradual consistency rules apply — `Dynamic[top]`
122
+ inferred types and `:maybe`-compatible shapes are silenced
123
+ because the runtime check covers them.
124
+
125
+ ```ruby
126
+ T.assert_type!("hello", Integer) # error: provably incompatible
127
+ T.assert_type!(some_obj, String) # silent: trust the user
128
+ ```
129
+
130
+ `T.bind(self, T)` narrows `self` to `T` for the rest of the
131
+ current scope (typically a block body):
132
+
133
+ ```ruby
134
+ arr.each do |x|
135
+ T.bind(self, MyHelper)
136
+ do_something(x) # ✓ self is now MyHelper for the rest of this block
137
+ end
138
+ ```
139
+
140
+ The narrowing is implemented via the engine's plugin-side
141
+ `post_return_facts` wiring — the same substrate any future
142
+ PHPStan-style Type-Specifying Extension plugin would use to
143
+ narrow argument variables after a custom assertion call.
144
+
145
+ `T.bind` rejects non-`self` first arguments silently (matches
146
+ Sorbet's contract — bind is self-only).
147
+
148
+ ## RBI files
149
+
150
+ The plugin walks `sorbet/rbi/**/*.rbi` recursively by default
151
+ and treats each `.rbi` as Ruby source. The standard Tapioca
152
+ subdirectories (`gems/`, `annotations/`, `dsl/`, `shims/`)
153
+ all participate as a side effect of recursing into the parent
154
+ root. Override the location via `config.rbi_paths:` in
155
+ `.rigor.yml`, or set it to `[]` to opt out:
156
+
157
+ ```yaml
158
+ plugins:
159
+ - gem: rigor-sorbet
160
+ config:
161
+ rbi_paths: [] # disable RBI loading
162
+ # rbi_paths: ["sorbet/rbi", "vendor/rbi"] # add a vendored tree
163
+ ```
164
+
165
+ Project sigs (`.rb` files under `paths:`) and RBI sigs
166
+ (`.rbi` files under `rbi_paths:`) feed the same per-run
167
+ catalog, so a method declared in either source resolves the
168
+ same way at the call site.
169
+
170
+ ## Sorbet `# typed:` sigils
171
+
172
+ The plugin reads Sorbet's `# typed:` magic comment from the
173
+ top of each file. Behaviour depends on the `enforce_sigil`
174
+ config knob (default `true`):
175
+
176
+ | Sigil | `enforce_sigil: true` (default) | `enforce_sigil: false` |
177
+ | ------------------- | ------------------------------------------- | ---------------------- |
178
+ | `# typed: ignore` | Skipped entirely; no sigs / parse errors recorded. | Same. |
179
+ | no sigil / `false` | Walked for parse-error diagnostics, but sigs are NOT recorded. | Sigs recorded. |
180
+ | `# typed: true`+ | Sigs recorded. | Sigs recorded. |
181
+
182
+ The default mirrors Sorbet's own contract: types aren't
183
+ enforced at `# typed: false`, so Rigor doesn't surface
184
+ narrowing from those files either. Set `enforce_sigil: false`
185
+ in the plugin config to opt into the pre-gate behaviour
186
+ (every parseable file's sigs land in the catalog regardless
187
+ of sigil).
188
+
189
+ **Assertion recognisers** (`T.let`, `T.cast`, `T.must`,
190
+ `T.must_because`, `T.unsafe`, `T.reveal_type`,
191
+ `T.assert_type!`, `T.bind`) are NOT gated by
192
+ `enforce_sigil`. The user wrote those calls deliberately, so
193
+ they fire regardless of the file's sigil.
194
+
195
+ Sorbet-strict's "every method must have a sig" requirement
196
+ and strong-mode's `T.untyped` rejection are intentionally NOT
197
+ mirrored. Those checks live with `srb tc`. Rigor's own
198
+ `severity_profile` setting in `.rigor.yml` covers the
199
+ analogous filtering.
200
+
201
+ ## Tapioca DSL — the mixin pattern
202
+
203
+ Tapioca's standard DSL RBI shape declares sigs on a generated
204
+ module that is `include`d / `extend`ed into the host class:
205
+
206
+ ```rbi
207
+ class Post
208
+ include GeneratedAttributeMethods
209
+ module GeneratedAttributeMethods
210
+ sig { returns(::String) }
211
+ def body; end
212
+ end
213
+ end
214
+ ```
215
+
216
+ The plugin records the sig under the module's qualified name
217
+ during the walk and lifts it to the host class at lookup
218
+ time. So `post.body` correctly resolves through
219
+ `Post::GeneratedAttributeMethods#body` — no manual
220
+ flattening required, and the same trick works for
221
+ hand-written shims under `sorbet/rbi/shims/` and community
222
+ annotations under `rbi-central`.
223
+
224
+ `extend M` correctly lifts `M`'s instance methods to the
225
+ extending class's singleton side, matching Ruby's runtime
226
+ behaviour:
227
+
228
+ ```rbi
229
+ class Post
230
+ extend GeneratedClassMethods
231
+ module GeneratedClassMethods
232
+ sig { params(id: Integer).returns(Post) }
233
+ def find(id); end
234
+ end
235
+ end
236
+ ```
237
+
238
+ `Post.find(42)` resolves through the extended module's
239
+ instance side.
240
+
241
+ ## `T.absurd` exhaustiveness
242
+
243
+ `T.absurd(x)` is Sorbet's idiom for case/when exhaustiveness:
244
+ "if I got here, the type system has lost the plot." The
245
+ plugin treats every `T.absurd` call as `Bot` (the empty
246
+ type — no possible value) AND raising, so the engine's
247
+ existing flow analysis treats code after the call as
248
+ unreachable:
249
+
250
+ ```ruby
251
+ case x
252
+ when A then handle_a(x)
253
+ when B then handle_b(x)
254
+ else
255
+ T.absurd(x) # asserts the else branch is unreachable
256
+ end
257
+ ```
258
+
259
+ When the discriminant is fully exhausted, the `T.absurd`
260
+ call sits in dead code and contributes nothing. When a case
261
+ branch is missing, the discriminant's type at the `T.absurd`
262
+ call still has admissible inhabitants, and the plugin
263
+ surfaces `plugin.sorbet.absurd-reachable` as a warning:
264
+
265
+ ```text
266
+ demo.rb:42:5: warning: `T.absurd` is reachable: the discriminant did not
267
+ narrow to `T.noreturn`. Either add the missing case
268
+ branch above the `else`, or remove the `T.absurd(...)` call.
269
+ [plugin.sorbet.absurd-reachable]
270
+ ```
271
+
272
+ The detection's accuracy follows Rigor's flow-sensitive
273
+ narrowing — `is_a?` / `kind_of?` / `nil?` work precisely;
274
+ narrowing over symbol enums is less precise as of v0.1.3,
275
+ so fully-exhausted symbol cases may emit false-positive
276
+ warnings until the engine's case narrowing improves.
277
+
278
+ ## Tier ordering — what wins on conflict
279
+
280
+ When a method has both a Sorbet `sig` and an RBS sig, RBS
281
+ wins. Sorbet sigs sit at Rigor's plugin tier:
282
+
283
+ 1. **Precision tiers** — constant fold, shape dispatch,
284
+ block fold, etc.
285
+ 2. **Plugin contributions** — including `rigor-sorbet`'s
286
+ sig and assertion translations.
287
+ 3. **RBS-backed dispatch** — project `sig/`,
288
+ `RBS::Inline`, bundled stdlib.
289
+ 4. **Dependency-source inference** (ADR-10's opt-in walker).
290
+ 5. **User-class fallback** (`Object` / `Class` ancestors).
291
+
292
+ The contribution merger (a v0.1.0 substrate documented in
293
+ [`docs/internal-spec/flow-contribution-merger.md`](../internal-spec/flow-contribution-merger.md))
294
+ keeps RBS authoritative on conflict — the Sorbet sig is
295
+ allowed to refine but not contradict it. Users who want
296
+ their Sorbet sig to override should remove the conflicting
297
+ RBS, not the other way around. The reverse direction
298
+ (Sorbet wins) would let third-party-DSL annotations
299
+ override authored RBS, which inverts the trust model.
300
+
301
+ ## Migration patterns
302
+
303
+ The plugin is designed for **gradual coexistence**, not a
304
+ forced migration. Three common shapes:
305
+
306
+ 1. **Run both static checkers side by side.** `srb tc`
307
+ keeps producing its diagnostics; `rigor check`
308
+ produces its own. They overlap on shape errors and
309
+ complement each other on what each finds — Sorbet
310
+ covers `T.let` / `T.cast` / RBI more deeply; Rigor
311
+ covers literal-string narrowing, refinement carriers,
312
+ plugin DSLs, and dependency-source inference.
313
+ 2. **Sorbet for sigs, Rigor for narrowing.** Authoritative
314
+ sigs stay in `sig { ... }` blocks (or the
315
+ sorbet-runtime-friendly RBI tree); Rigor reads them as
316
+ input and adds its own narrowing on top.
317
+ 3. **Sorbet → RBS over time.** New code lands as RBS;
318
+ existing Sorbet sigs stay until the surrounding
319
+ subsystem changes. The plugin keeps running while the
320
+ Sorbet surface shrinks.
321
+
322
+ ## What the plugin doesn't replace
323
+
324
+ Rigor's `rigor-sorbet` adapter is **input-side only**. It
325
+ reads Sorbet's syntax and translates the vocabulary; it does
326
+ not run Sorbet's type checker, doesn't ship
327
+ `sorbet-runtime`, and doesn't enforce Sorbet's runtime
328
+ guarantees. If you remove `sorbet` and `sorbet-runtime` from
329
+ your `Gemfile`, the plugin keeps reading the sigs (the
330
+ adapter's mini-interpreter doesn't load Sorbet) but `T.let` /
331
+ `T.cast` / `T.must` / `T.unsafe` calls will raise
332
+ `NameError` at runtime unless you keep at least the runtime
333
+ gem (or stub the four singleton methods on a top-level `T`
334
+ constant — the plugin's demo does this for its own
335
+ unit tests).
336
+
337
+ ## Where to go next
338
+
339
+ - The full feature matrix and architectural surface live in
340
+ [`plugins/rigor-sorbet/README.md`](../../plugins/rigor-sorbet/README.md).
341
+ - The design rationale + slice plan is at
342
+ [`docs/adr/11-sorbet-input-adapter.md`](../adr/11-sorbet-input-adapter.md).
343
+ - The cross-checker triage report at
344
+ [`docs/notes/20260503-steep-cross-check-triage.md`](../notes/20260503-steep-cross-check-triage.md)
345
+ shows how Rigor's analyzer routinely surfaces sig drift
346
+ that other static checkers miss — useful when comparing
347
+ what each tool finds in practice.
@@ -0,0 +1,312 @@
1
+ # Generating RBS with rigor sig-gen
2
+
3
+ When `rigor check` is happy with your code but `sig/` is still
4
+ mostly empty, the analyzer is doing useful inference that
5
+ never reaches anyone but itself. `rigor sig-gen` is the
6
+ companion command that emits the inferred signatures as RBS
7
+ so the rest of the toolchain — Steep cross-checks, IDE
8
+ tooltips, downstream consumers reading your gem's `sig/` —
9
+ sees what Rigor sees.
10
+
11
+ This chapter is a walkthrough of the command's UX, the
12
+ classification model, the three output modes, and the
13
+ `--params` policy trade-off that comes straight out of
14
+ [ADR-5](../adr/5-robustness-principle.md)'s asymmetric
15
+ "strict on returns, lenient on parameters" rule.
16
+
17
+ ## When to reach for it
18
+
19
+ - You inherited a Ruby project with zero RBS coverage and
20
+ want a starting point that is more honest than `rbs
21
+ prototype rb`'s syntactic skeleton.
22
+ - You added a method, `rigor check` recognises it, and now
23
+ you want the corresponding sig file updated without
24
+ retyping the signature by hand.
25
+ - Your existing RBS declares `() -> Numeric` but Rigor
26
+ proves `() -> Integer`. You want the tighter spelling
27
+ applied to `sig/` (after review).
28
+
29
+ What it is **not**: a replacement for hand-authored RBS
30
+ that captures intent the source code does not. If a public
31
+ method should accept `_ToStr` because the contract is
32
+ "anything that responds to `to_s`" but the current callers
33
+ only happen to pass `String`, `sig-gen` will not invent
34
+ `_ToStr` for you — the [`--params` policy](#the---params-policy-and-adr-5)
35
+ section below and ADR-5 explain why.
36
+
37
+ ## A first run
38
+
39
+ Given a `lib/calc.rb`:
40
+
41
+ ```ruby
42
+ class Calc
43
+ def add(a, b)
44
+ "sum"
45
+ end
46
+
47
+ def greet(name)
48
+ "hi"
49
+ end
50
+ end
51
+ ```
52
+
53
+ and an empty `sig/`, `rigor sig-gen` prints RBS skeletons:
54
+
55
+ ```
56
+ $ rigor sig-gen
57
+ # lib/calc.rb
58
+ class Calc
59
+ # [new]
60
+ def add: (untyped, untyped) -> String
61
+ # [new]
62
+ def greet: (untyped) -> String
63
+ end
64
+ ```
65
+
66
+ By default the command writes nothing — it prints the
67
+ proposal so you can review it. Pass `--write` to apply the
68
+ proposal to `sig/`.
69
+
70
+ ## The three output modes
71
+
72
+ | Mode | Behaviour |
73
+ | --- | --- |
74
+ | `--print` (default) | Print RBS to stdout, grouped by source file + class declaration. |
75
+ | `--diff` | Show a unified-style diff comparing the existing-declared spelling (if any) against the inferred spelling. Read-only. |
76
+ | `--write` | Apply the proposal to `sig/<path>.rbs`. Creates files, inserts new methods into existing class declarations, appends new class blocks to files that don't declare them yet. |
77
+
78
+ `--write` is the only mode that touches the filesystem. It
79
+ operates **only** inside `configuration.signature_paths`
80
+ (default `sig/`); anything outside that tree is reported as
81
+ `skipped_outside_sig_root` without being written to.
82
+
83
+ ## The classification model
84
+
85
+ Every method `rigor sig-gen` considers lands in one of five
86
+ states:
87
+
88
+ | Classification | Meaning |
89
+ | --- | --- |
90
+ | `new-file` | No RBS file declares the receiver class at all. |
91
+ | `new-method` | RBS file declares the class but not this method. |
92
+ | `tighter-return` | RBS file declares the method, but the inferred return is a strict subtype of the declared return. |
93
+ | `equivalent` | The inferred return is not a strict subtype of the declared one — identical, wider, or unrelated — so there is nothing to tighten. Silently skipped. |
94
+ | `skipped` | Disqualified for one of the reasons below. |
95
+
96
+ The three `sig.skipped.*` reasons are:
97
+
98
+ - `sig.skipped.complex-shape` — the method has optional, rest,
99
+ keyword, block, or forwarding parameters. The MVP's
100
+ body-typing path only handles required positional
101
+ parameters; complex shapes need a future slice.
102
+ - `sig.skipped.untyped-return` — the method body's last
103
+ expression types as `Dynamic[top]`. Emitting `untyped` as
104
+ a tightening would be noise rather than help.
105
+ - `sig.skipped.user-authored` — `--overwrite` was not set
106
+ and the method's existing RBS declaration would have to
107
+ be replaced.
108
+
109
+ The three `sig.generated.*` identifiers
110
+ (`sig.generated.new-file` / `new-method` / `tighter-return`)
111
+ are emitted as JSON fields under `--format=json` so CI
112
+ gating consumers can route them.
113
+
114
+ ## What method shapes the generator covers
115
+
116
+ Slice-by-slice (each shipped via a CHANGELOG entry — this
117
+ list is the current state):
118
+
119
+ - **Plain instance `def foo`** with required positional
120
+ parameters. Both new-method and tighter-return paths
121
+ apply.
122
+ - **Singleton-side `def self.foo`** and
123
+ `class << self; def foo; end`. Rendered as
124
+ `def self.foo: ...`; matched against
125
+ `Reflection.singleton_method_definition` for existing
126
+ RBS.
127
+ - **`attr_reader` / `attr_writer` / `attr_accessor`** with
128
+ literal Symbol arguments. The return type is the
129
+ accumulated ivar type from `Scope#class_ivars_for`. The
130
+ generator emits the long-form `def name: () -> T`
131
+ spelling so the writer's merge path applies unchanged;
132
+ existing short-form `attr_reader name: T` declarations
133
+ are recognised as user-authored and never produce a
134
+ duplicate `def` insertion.
135
+
136
+ Method shapes the generator does **not** cover yet (and
137
+ silently skips):
138
+
139
+ - Optional / rest / keyword / block / forwarding parameters.
140
+ - `define_method(:name) { ... }`.
141
+ - Methods whose body types as `Dynamic[top]` (the body
142
+ inference cannot prove a useful return type).
143
+
144
+ These are tracked as ADR-14 follow-ups.
145
+
146
+ ## The `--params` policy and ADR-5
147
+
148
+ The `--params=POLICY` flag controls how parameter positions
149
+ are spelled in the emitted RBS. There are three policies;
150
+ two are wired today, one is reserved.
151
+
152
+ | Policy | Behaviour |
153
+ | --- | --- |
154
+ | `untyped` (default) | Every parameter is spelled `untyped`. No inference-derived parameter contract is imposed on future callers. The user retains complete authorship over parameter typing. |
155
+ | `observed` | Collect argument types from every call site under `--observe=PATH...` (defaults to `spec/` when present), union per parameter position, erase to RBS, emit the union. |
156
+ | `observed-strict` | Reserved. Will additionally widen to capability roles (`_ToStr`, `_ToS`, …) once the role catalog ships. Currently rejected with a usage error. |
157
+
158
+ The default deliberately favours `untyped` because of
159
+ [ADR-5](../adr/5-robustness-principle.md)'s clause 2: a
160
+ method's parameter contract should be the **most permissive**
161
+ shape the body's logic justifies, not the most specific
162
+ shape the current callers happen to use. Locking in
163
+ `observed` would silently freeze "what the existing specs
164
+ happen to pass" as the contract, which is the precision /
165
+ adoption trade-off the chapter introduction hinted at.
166
+
167
+ `--params=observed` is the deliberate opt-in: you are
168
+ saying *"the union of what my callers pass today IS the
169
+ parameter contract I want."* That is a correctness-
170
+ preserving widening — every existing caller still passes —
171
+ but it does narrow the contract relative to `untyped`.
172
+
173
+ ## RSpec-aware observations
174
+
175
+ When you point `--observe` at a `spec/` directory, the
176
+ generator recognises three RSpec-shaped binding patterns
177
+ and uses them to type receivers that would otherwise
178
+ degrade to `Dynamic[top]`:
179
+
180
+ ```ruby
181
+ RSpec.describe Calc do
182
+ subject { Calc.new } # binds :subject → Nominal[Calc]
183
+ let(:other) { Calc.new } # binds :other → Nominal[Calc]
184
+
185
+ it "..." do
186
+ subject.greet("Alice") # observed: Calc#greet receives String
187
+ other.greet("Bob") # observed: same
188
+ described_class.new.add(1, 2) # observed: Calc#add receives Integer, Integer
189
+ end
190
+ end
191
+ ```
192
+
193
+ The recogniser handles `RSpec.describe Foo`, bare
194
+ `describe Foo` (no `RSpec.` receiver), `subject { … }`,
195
+ `subject(:name) { … }`, `let(:name) { … }`, `let!(:name)`,
196
+ and `described_class.new(...)`. Same-name `let` bindings
197
+ across nested scopes are last-wins; the recogniser does not
198
+ re-implement RSpec's full scope rules — the typical
199
+ one-spec-file shape is the target.
200
+
201
+ The recogniser is part of the generator itself; you do not
202
+ need to install `rigor-rspec` to benefit from it. If you
203
+ already use `rigor-rspec` for diagnostics, the two run side
204
+ by side without coordination.
205
+
206
+ ## Safety: what `--write` will and will not do
207
+
208
+ - **Will** create new `*.rbs` files mirroring `lib/<path>.rb`'s
209
+ layout (basename of `configuration.paths.first` stripped,
210
+ placed under `configuration.signature_paths.first`).
211
+ - **Will** insert new method declarations just before a
212
+ class declaration's closing `end` keyword, preserving
213
+ every other byte of the file verbatim.
214
+ - **Will** append a new `class Foo … end` block when the
215
+ target file does not declare the class yet.
216
+ - **Will not** touch files outside the configured signature
217
+ tree.
218
+ - **Will not** replace an existing method declaration
219
+ unless `--overwrite` is set AND the candidate is a
220
+ `tighter-return`. Without `--overwrite`, existing
221
+ declarations are user-authored and the new method is
222
+ silently skipped.
223
+ - **Will not** touch `attr_reader` / `attr_writer` /
224
+ `attr_accessor` declarations in existing RBS — those are
225
+ always treated as user-authored.
226
+
227
+ The recommended workflow is `--diff` first, review, then
228
+ `--write` (or `--write --overwrite` if you decided that
229
+ the tightening is intentional).
230
+
231
+ ### When tightening is *probably* incomplete inference
232
+
233
+ The strict-subtype check is a *necessary* condition for
234
+ emitting a tighter-return — it's not a sufficient signal
235
+ that the existing RBS is wrong. Slice 1's body-typing path
236
+ only inspects the implicit-return expression, so a method
237
+ like:
238
+
239
+ ```ruby
240
+ def find(key)
241
+ return nil unless @table.key?(key)
242
+ @table[key]
243
+ end
244
+ ```
245
+
246
+ types as the return of `@table[key]` alone. If the existing
247
+ RBS declares `(K) -> V | nil`, the inferred `V` looks
248
+ strictly tighter — but it's tighter for the wrong reason
249
+ (the `nil` branch is unreachable in the body-typer's eyes,
250
+ not in the runtime's). Applying it would silently delete
251
+ the `nil` arm.
252
+
253
+ **Heuristic**: when a tightening DROPS union members that
254
+ the existing RBS declares — `T | nil → T`, `false | true →
255
+ true`, `Float | Integer → Float`, `Array[T] → [T]` — treat
256
+ it as a contradiction signal, not a precision win, and
257
+ leave the existing RBS alone. The generator does not yet
258
+ classify these automatically; the `--diff` review step is
259
+ where the human gate sits.
260
+
261
+ For `rigor`'s own `sig/` tree this is the load-bearing
262
+ policy: every tighter-return that contradicts an existing
263
+ declaration is suspected incomplete inference until proven
264
+ otherwise.
265
+
266
+ ## Putting it together
267
+
268
+ A typical iteration on a new file:
269
+
270
+ ```sh
271
+ # 1. See what Rigor would propose.
272
+ rigor sig-gen lib/calc.rb
273
+
274
+ # 2. Run with the observed-params policy to use spec/ as
275
+ # a parameter-type signal.
276
+ rigor sig-gen --params=observed lib/calc.rb
277
+
278
+ # 3. Compare against the current sig/ tree.
279
+ rigor sig-gen --params=observed --diff lib/calc.rb
280
+
281
+ # 4. Apply.
282
+ rigor sig-gen --params=observed --write lib/calc.rb
283
+
284
+ # 5. Re-run rigor check to confirm no regressions.
285
+ rigor check
286
+ ```
287
+
288
+ The five steps map to the five ADR-14 slices the command
289
+ is built from. If any step shows results you didn't expect,
290
+ the diagnostic the analyzer would emit for the same code is
291
+ the source of truth — `sig-gen` is a downstream consumer of
292
+ inference, not a separate analysis.
293
+
294
+ ## Limits today
295
+
296
+ - Methods with optional / rest / keyword / block /
297
+ forwarding parameters silently skip
298
+ (`sig.skipped.complex-shape`).
299
+ - `define_method` and `Data.define`-specific emission are
300
+ deferred follow-ups (`Data.define`-derived readers come
301
+ through if a method body exists).
302
+ - The strict-subtype check uses gradual-mode acceptance
303
+ today; the `:strict` mode reserved on
304
+ `Inference::Acceptance` arrives in a follow-up.
305
+ - Round-trip through `RBS::Writer` is not used (it drops
306
+ comments by upstream design); the generator's
307
+ byte-range insertion preserves untouched declarations
308
+ verbatim but cannot preserve comments interleaved
309
+ *inside* a touched declaration's range.
310
+
311
+ These are the ADR-14 deferred items; the design rationale
312
+ is in [`docs/adr/14-rbs-sig-generation.md`](../adr/14-rbs-sig-generation.md).