rigortype 0.2.1 → 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 (105) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +41 -14
  3. data/docs/handbook/01-getting-started.md +311 -0
  4. data/docs/handbook/02-everyday-types.md +337 -0
  5. data/docs/handbook/03-narrowing.md +359 -0
  6. data/docs/handbook/04-tuples-and-shapes.md +321 -0
  7. data/docs/handbook/05-methods-and-blocks.md +339 -0
  8. data/docs/handbook/06-classes.md +305 -0
  9. data/docs/handbook/07-rbs-and-extended.md +427 -0
  10. data/docs/handbook/08-understanding-errors.md +373 -0
  11. data/docs/handbook/09-plugins.md +241 -0
  12. data/docs/handbook/10-sorbet.md +347 -0
  13. data/docs/handbook/11-sig-gen.md +312 -0
  14. data/docs/handbook/12-lightweight-hkt.md +333 -0
  15. data/docs/handbook/README.md +275 -0
  16. data/docs/handbook/appendix-elixir.md +370 -0
  17. data/docs/handbook/appendix-go.md +399 -0
  18. data/docs/handbook/appendix-java-csharp.md +470 -0
  19. data/docs/handbook/appendix-liskov.md +580 -0
  20. data/docs/handbook/appendix-mypy.md +370 -0
  21. data/docs/handbook/appendix-phpstan.md +338 -0
  22. data/docs/handbook/appendix-protocols-and-structural-typing.md +292 -0
  23. data/docs/handbook/appendix-rust.md +446 -0
  24. data/docs/handbook/appendix-steep.md +336 -0
  25. data/docs/handbook/appendix-type-theory.md +1662 -0
  26. data/docs/handbook/appendix-typeprof.md +416 -0
  27. data/docs/handbook/appendix-typescript.md +332 -0
  28. data/docs/install.md +189 -0
  29. data/docs/llms.txt +72 -0
  30. data/docs/manual/01-installation.md +342 -0
  31. data/docs/manual/02-cli-reference.md +557 -0
  32. data/docs/manual/03-configuration.md +152 -0
  33. data/docs/manual/04-diagnostics.md +206 -0
  34. data/docs/manual/05-inspecting-types.md +109 -0
  35. data/docs/manual/06-baseline.md +104 -0
  36. data/docs/manual/07-plugins.md +92 -0
  37. data/docs/manual/08-skills.md +143 -0
  38. data/docs/manual/09-editor-integration.md +245 -0
  39. data/docs/manual/10-mcp-server.md +532 -0
  40. data/docs/manual/11-ci.md +274 -0
  41. data/docs/manual/12-caching.md +116 -0
  42. data/docs/manual/13-troubleshooting.md +120 -0
  43. data/docs/manual/14-rails-quickstart.md +332 -0
  44. data/docs/manual/15-type-protection-coverage.md +204 -0
  45. data/docs/manual/16-rbs-extended-annotations.md +190 -0
  46. data/docs/manual/17-driving-improvement.md +160 -0
  47. data/docs/manual/README.md +87 -0
  48. data/docs/manual/ci-templates/README.md +58 -0
  49. data/docs/manual/plugins/README.md +86 -0
  50. data/docs/manual/plugins/rigor-actioncable.md +78 -0
  51. data/docs/manual/plugins/rigor-actionmailer.md +74 -0
  52. data/docs/manual/plugins/rigor-actionpack.md +80 -0
  53. data/docs/manual/plugins/rigor-activejob.md +58 -0
  54. data/docs/manual/plugins/rigor-activerecord.md +102 -0
  55. data/docs/manual/plugins/rigor-activestorage.md +74 -0
  56. data/docs/manual/plugins/rigor-activesupport-core-ext.md +86 -0
  57. data/docs/manual/plugins/rigor-devise.md +70 -0
  58. data/docs/manual/plugins/rigor-dry-schema.md +56 -0
  59. data/docs/manual/plugins/rigor-dry-struct.md +60 -0
  60. data/docs/manual/plugins/rigor-dry-types.md +59 -0
  61. data/docs/manual/plugins/rigor-dry-validation.md +62 -0
  62. data/docs/manual/plugins/rigor-factorybot.md +76 -0
  63. data/docs/manual/plugins/rigor-graphql.md +89 -0
  64. data/docs/manual/plugins/rigor-hanami.md +83 -0
  65. data/docs/manual/plugins/rigor-mangrove.md +73 -0
  66. data/docs/manual/plugins/rigor-minitest.md +86 -0
  67. data/docs/manual/plugins/rigor-pundit.md +72 -0
  68. data/docs/manual/plugins/rigor-rails-i18n.md +92 -0
  69. data/docs/manual/plugins/rigor-rails-routes.md +94 -0
  70. data/docs/manual/plugins/rigor-rails.md +44 -0
  71. data/docs/manual/plugins/rigor-rbs-inline.md +83 -0
  72. data/docs/manual/plugins/rigor-rspec-rails.md +72 -0
  73. data/docs/manual/plugins/rigor-rspec.md +86 -0
  74. data/docs/manual/plugins/rigor-shoulda-matchers.md +78 -0
  75. data/docs/manual/plugins/rigor-sidekiq.md +78 -0
  76. data/docs/manual/plugins/rigor-sinatra.md +61 -0
  77. data/docs/manual/plugins/rigor-sorbet.md +63 -0
  78. data/docs/manual/plugins/rigor-statesman.md +75 -0
  79. data/docs/manual/plugins/rigor-typescript-utility-types.md +71 -0
  80. data/exe/rigor +1 -1
  81. data/lib/rigor/analysis/incremental_session.rb +4 -2
  82. data/lib/rigor/analysis/run_stats.rb +13 -1
  83. data/lib/rigor/analysis/runner.rb +54 -12
  84. data/lib/rigor/cli/check_command.rb +1 -1
  85. data/lib/rigor/cli/docs_command.rb +248 -0
  86. data/lib/rigor/cli/skill_command.rb +103 -41
  87. data/lib/rigor/cli/skill_describe.rb +346 -0
  88. data/lib/rigor/cli.rb +25 -3
  89. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +124 -32
  90. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +37 -6
  91. data/lib/rigor/inference/scope_indexer.rb +87 -89
  92. data/lib/rigor/plugin/isolation.rb +5 -5
  93. data/lib/rigor/plugin/loader.rb +4 -2
  94. data/lib/rigor/version.rb +1 -1
  95. data/skills/rigor-ask/SKILL.md +172 -0
  96. data/skills/rigor-doctor/SKILL.md +87 -0
  97. data/skills/rigor-editor-setup/SKILL.md +114 -0
  98. data/skills/rigor-mcp-setup/SKILL.md +117 -0
  99. data/skills/rigor-monkeypatch-resolve/SKILL.md +79 -0
  100. data/skills/rigor-next-steps/SKILL.md +113 -0
  101. data/skills/rigor-plugin-tune/SKILL.md +79 -0
  102. data/skills/rigor-protection-uplift/SKILL.md +133 -0
  103. data/skills/rigor-rbs-setup/SKILL.md +128 -0
  104. data/skills/rigor-upgrade/SKILL.md +79 -0
  105. metadata +90 -1
@@ -0,0 +1,373 @@
1
+ # Understanding errors
2
+
3
+ This chapter is the catalogue of diagnostics Rigor ships, the
4
+ families they belong to, and how to suppress one when it is
5
+ wrong (or move its severity around). It is the page to land on
6
+ when a diagnostic surprises you, in either direction.
7
+
8
+ ## Anatomy of a diagnostic
9
+
10
+ ```text
11
+ lib/user.rb:42:7: error: undefined method `upcas' for "alice" [call.undefined-method]
12
+ ↑ ↑ ↑
13
+ │ │ └─ qualified rule
14
+ │ └─ message
15
+ └─ severity (error / warning / info)
16
+ ```
17
+
18
+ The qualified rule (`call.undefined-method`,
19
+ `flow.always-raises`, `def.return-type-mismatch`, …) is the
20
+ stable identifier for the rule. Use it in:
21
+
22
+ - `# rigor:disable <rule>` end-of-line suppressions in source
23
+ - `# rigor:disable-file <rule>` file-scope suppressions
24
+ - `severity_overrides:` in `.rigor.yml`
25
+ - `disable:` in `.rigor.yml`
26
+
27
+ Wildcards work — `# rigor:disable call` suppresses every
28
+ `call.*` rule on that line.
29
+
30
+ Need to look up what a rule does without leaving the shell?
31
+ `rigor explain <rule>` prints the rule's summary, when it
32
+ fires, when it doesn't, the suppression token, the authored
33
+ severity, and the per-profile severity. `rigor explain` with
34
+ no argument prints the index of every shipped rule.
35
+
36
+ ### Confidence and reference fields
37
+
38
+ Two extra fields ride along on every built-in diagnostic, for
39
+ agents and dashboards consuming `rigor check --format json`
40
+ (and on each rule in `rigor explain --format json`):
41
+
42
+ - **`evidence_tier`** — `high` / `medium` / `low`: Rigor's own
43
+ confidence that the firing is a true positive, derived from
44
+ the rule's gates, not its severity. `high` means a concrete,
45
+ statically-known type with no metaprogramming escape (e.g.
46
+ `call.undefined-method`); `medium` rests on a flow / inference
47
+ proof with a documented false-positive envelope (e.g.
48
+ `flow.always-truthy-condition`); `low` is a resolution- or
49
+ coverage-gap signal that often means missing context rather
50
+ than a bug (e.g. `call.unresolved-toplevel`). The tier never
51
+ feeds severity — that stays the `severity_profile:` decision.
52
+ Informational helpers (`dump.type`) carry no tier.
53
+ - **`documentation_url`** — a stable link to the rule's entry in
54
+ the published diagnostics catalogue.
55
+
56
+ Both are presentation metadata. They never change whether a
57
+ diagnostic fires.
58
+
59
+ ## The rule catalogue
60
+
61
+ Five families, each with one or more rules:
62
+
63
+ ### `call.*` — call-site rules
64
+
65
+ Fire when a method call's shape is wrong.
66
+
67
+ | Rule | Fires when | Default severity |
68
+ | --- | --- | --- |
69
+ | `call.undefined-method` | The receiver class is statically known and the method is not defined on it (RBS or in-source). | error |
70
+ | `call.wrong-arity` | The number of positional arguments does not satisfy any overload's arity. | error |
71
+ | `call.argument-type-mismatch` | An argument's type provably does not satisfy the parameter contract (RBS or `RBS::Extended` `param:`). | error |
72
+ | `call.possible-nil-receiver` | The receiver type is `T \| nil` and the method is not defined on `NilClass`. | error (warning under `lenient`) |
73
+ | `call.unresolved-toplevel` | An implicit-self call at the top level (outside any `def` / `class` / `module`) resolves against no same-file `def`, `pre_eval:` monkey-patch, or `Kernel` / `Object` method — surfacing typos in standalone scripts. | warning under `balanced`, error under `strict`, suppressed under `lenient` |
74
+
75
+ `call.*` rules are the highest-volume diagnostics on
76
+ real-world code. They are also the most refined — every one
77
+ fires only when Rigor can prove the underlying fact.
78
+
79
+ ### `flow.*` — flow-analysis rules
80
+
81
+ Fire when the control flow itself is unsound.
82
+
83
+ | Rule | Fires when | Default severity |
84
+ | --- | --- | --- |
85
+ | `flow.always-raises` | Every reachable evaluation of an expression raises (e.g. `n / 0` where `n: Integer`). | error |
86
+ | `flow.unreachable-branch` | An `if` / `unless` / ternary's predicate is a syntactic literal AND the corresponding dead branch is non-empty. | warning |
87
+ | `flow.always-truthy-condition` | The predicate of an `if` / `unless` / ternary is provably truthy (or falsey) by inferred type, with surgical skips inside loop bodies and on defensive predicate calls. | warning |
88
+ | `flow.unreachable-clause` | A `case <local>; when <Class>` (or bare-class `case`/`in`) clause whose subject narrowing proves it can never match — disjoint from the subject's type, or already exhausted by an earlier clause. | info under `balanced`, warning under `strict`, info under `lenient` |
89
+ | `flow.dead-assignment` | A plain local-variable write whose target name is never read in the same `def` body. | warning |
90
+
91
+ `flow.unreachable-branch`, `flow.always-truthy-condition`, and
92
+ `flow.unreachable-clause` are the **reachability family** — each
93
+ proves a branch or `case` clause is dead. `unreachable-clause`
94
+ is the newest member: it watches `case <local>; when <Class>`
95
+ (and bare-class `case`/`in`) and fires when an earlier clause
96
+ already covered a member's type or the clause is disjoint from
97
+ the subject. It ships at `:info` under `balanced` (one notch
98
+ below its siblings) while its corpus false-positive gate
99
+ finishes; bump it with `severity_overrides:` if you want it
100
+ louder.
101
+
102
+ ### `def.*` — method-definition rules
103
+
104
+ Fire when the body of a method violates its declared
105
+ contract.
106
+
107
+ | Rule | Fires when | Default severity |
108
+ | --- | --- | --- |
109
+ | `def.return-type-mismatch` | The body's last expression's inferred type cannot satisfy the RBS-declared return type. Honors `%a{rigor:v1:return: <refinement>}` overrides. | warning under `balanced` profile, error under `strict` |
110
+ | `def.ivar-write-mismatch` | A later `@var = ...` write's concrete class disagrees with the first write's class in the same class body (NilClass-to-clear is allowlisted). | warning under `balanced` profile, error under `strict` |
111
+ | `def.method-visibility-mismatch` | An explicit-receiver call targets a `Nominal[X]` whose discovered method is `:private` in the surrounding class body. | error |
112
+ | `def.override-visibility-reduced` | An override reduces the visibility it inherits from a project-defined ancestor (public → protected/private, protected → private), breaking a caller that holds the supertype. | warning under `balanced`, error under `strict`, suppressed under `lenient` |
113
+ | `def.override-return-widened` | An override's declared return widens the inherited return (covariance). Fires only on a proven violation when both sides carry an authored RBS signature. | warning under `balanced`, error under `strict`, suppressed under `lenient` |
114
+ | `def.override-param-narrowed` | An override narrows an inherited parameter type (contravariance), comparing matching positional parameters. Requires an authored single-overload RBS signature on both sides. | warning under `balanced`, error under `strict`, suppressed under `lenient` |
115
+
116
+ The three `def.override-*` rules are the Liskov Substitution
117
+ Principle signature rule applied across a project-defined
118
+ class/module hierarchy (superclass chain + included/prepended
119
+ modules, resolved cross-file). They are the conceptual subject of
120
+ [appendix: Liskov substitution](appendix-liskov.md).
121
+
122
+ ### `assert.*` — runtime assertion rules
123
+
124
+ | Rule | Fires when | Default severity |
125
+ | --- | --- | --- |
126
+ | `assert.type-mismatch` | An `assert_type("expected", value)` call's actual inferred type does not match the expected string. | error |
127
+
128
+ ### `dump.*` — debug helpers
129
+
130
+ | Rule | Fires when | Default severity |
131
+ | --- | --- | --- |
132
+ | `dump.type` | `dump_type(value)` was called — emits an info diagnostic naming the inferred type. | info |
133
+
134
+ `dump_type` is your introspection probe during debugging:
135
+ sprinkle it through suspicious code, run `rigor check`, read
136
+ the inferred types from the diagnostic stream.
137
+
138
+ ## Severity profiles
139
+
140
+ Rigor ships three named severity profiles that re-stamp the
141
+ shipped severities:
142
+
143
+ | Profile | Behaviour |
144
+ | --- | --- |
145
+ | `lenient` | Most rules → `warning`; uncertain rules drop to `info`. CI-friendly for legacy code. |
146
+ | `balanced` (default) | Most rules → `error`; `dump.type` → `info`. The shipped behaviour. |
147
+ | `strict` | Everything → `error` including the `:warning` rules under `balanced`. Suitable for new projects with no legacy noise. |
148
+
149
+ Set in `.rigor.yml`:
150
+
151
+ ```yaml
152
+ severity_profile: strict
153
+ ```
154
+
155
+ ## Per-rule overrides
156
+
157
+ Override a single rule's severity:
158
+
159
+ ```yaml
160
+ severity_overrides:
161
+ call.argument-type-mismatch: warning
162
+ def.return-type-mismatch: off
163
+ ```
164
+
165
+ `off` drops the diagnostic from the result entirely — useful
166
+ when you want a profile-wide setting for most rules but
167
+ silence one specifically.
168
+
169
+ Family wildcards work in overrides too:
170
+
171
+ ```yaml
172
+ severity_overrides:
173
+ call: warning # demote every call.* rule
174
+ dump: off # drop every dump.* rule
175
+ ```
176
+
177
+ Per-rule entries beat family-wildcard entries:
178
+
179
+ ```yaml
180
+ severity_overrides:
181
+ call: warning # every call.* → warning
182
+ call.undefined-method: error # except undefined-method, still error
183
+ ```
184
+
185
+ YAML reserves the bareword `off`. If the stripped severity
186
+ seems not to apply, quote it: `"off"`. Same for `on`.
187
+
188
+ ## In-source suppression
189
+
190
+ ```ruby
191
+ "hello".no_such_method # rigor:disable call.undefined-method
192
+ ```
193
+
194
+ The comment must be on the same line as the diagnostic. Use
195
+ the qualified rule, the family wildcard, or `all`:
196
+
197
+ ```ruby
198
+ "hello".no_such_method # rigor:disable call
199
+ "hello".no_such_method # rigor:disable all
200
+ ```
201
+
202
+ For multiline blocks, suppress at every line — Rigor does
203
+ not yet ship a `disable-block` syntax.
204
+
205
+ ### File-scope suppression
206
+
207
+ When you need to silence a rule everywhere in a file —
208
+ typically a generated file, a fixture, or a vendored snippet
209
+ that triggers a known false positive — drop a single
210
+ `# rigor:disable-file` comment anywhere in the file:
211
+
212
+ ```ruby
213
+ # rigor:disable-file call.undefined-method
214
+
215
+ # This whole file is generated; the analyzer's call surface
216
+ # is mismatched with the runtime layer for these stubs.
217
+ ```
218
+
219
+ Convention is to put the comment near the top, but Rigor
220
+ scans every comment in the file so any placement works. The
221
+ same token forms apply: qualified rule, family wildcard, or
222
+ `all`. The line-scope `# rigor:disable` form continues to
223
+ work — the two compose, and any project-wide
224
+ `disable: [...]` in `.rigor.yml` also still applies.
225
+
226
+ ## Project-wide suppression
227
+
228
+ ```yaml
229
+ # .rigor.yml
230
+ disable:
231
+ - call.possible-nil-receiver
232
+ ```
233
+
234
+ Drops the rule project-wide. Heavier hammer than
235
+ `severity_overrides: { call.possible-nil-receiver: off }` —
236
+ both work; the choice is stylistic.
237
+
238
+ ## Baseline diffing for CI
239
+
240
+ When you adopt Rigor on an existing codebase, you usually
241
+ inherit a long tail of legitimate-but-pre-existing diagnostics
242
+ that nobody is going to fix today. The pragmatic move is to
243
+ **snapshot the current state as a baseline** and then have CI
244
+ fail only on *new* diagnostics introduced by a PR:
245
+
246
+ ```sh
247
+ # Once: capture the current diagnostic surface.
248
+ rigor check --format=json > rigor.baseline.json
249
+ git add rigor.baseline.json
250
+ git commit
251
+
252
+ # Per PR: compare against the committed baseline.
253
+ rigor diff rigor.baseline.json
254
+ ```
255
+
256
+ `rigor diff` prints `+ NEW` rows for each diagnostic that
257
+ wasn't in the baseline and `- FIXED` rows for each that has
258
+ been resolved since. The exit code is `1` when any new
259
+ diagnostic appears and `0` otherwise — so adding a new
260
+ violation fails CI, but the legacy diagnostics recorded in
261
+ the baseline don't.
262
+
263
+ When you fix a row in the baseline, regenerate it with the
264
+ same `rigor check --format=json > rigor.baseline.json` so
265
+ the project tightens monotonically over time. The
266
+ `--format=json` form of `rigor diff` itself is also
267
+ available for editor / dashboard integrations.
268
+
269
+ `rigor diff` is the lightweight, ad-hoc form — a JSON file you
270
+ diff by hand in a CI script. Most projects instead adopt the
271
+ **managed baseline**: `rigor baseline generate` writes a
272
+ `.rigor-baseline.yml`, you point at it with the `baseline:`
273
+ config key, and from then on `rigor check` itself exits clean
274
+ on recorded diagnostics and surfaces only new ones — no
275
+ separate diff step. That is the path the
276
+ [`rigor-project-init` skill](../manual/14-rails-quickstart.md)
277
+ sets up for you; see [Baselines](../manual/06-baseline.md) for
278
+ the full workflow ([ADR-22](../adr/22-baseline-and-project-onboarding.md)
279
+ for the design).
280
+
281
+ ## Why a diagnostic might NOT fire when you expected one
282
+
283
+ The most common reasons:
284
+
285
+ 1. **The receiver is `Dynamic[top]`.** Rigor stays silent on
286
+ gradual receivers. Run `rigor type-of <file>:<line>:<col>`
287
+ to confirm what the engine sees.
288
+ 2. **The method exists somewhere in the hierarchy.** Even one
289
+ matching def in any ancestor class / module silences
290
+ `call.undefined-method`.
291
+ 3. **The call is implicit-self inside a method body.** Rigor
292
+ does not flag implicit-self calls — too much noise on
293
+ metaprogramming-heavy code.
294
+ 4. **The literal might be empty / nil at runtime in a way the
295
+ analyzer cannot prove.** `s = ARGV.first; s.upcase`
296
+ silently passes because `s` could legitimately be a
297
+ non-empty string at runtime, and Rigor will not flag what
298
+ it cannot prove. Add an explicit guard or a `param:`
299
+ tightening.
300
+ 5. **The target rule is disabled by configuration.** Check
301
+ your `.rigor.yml` and any `# rigor:disable` comments in
302
+ the offending file.
303
+ 6. **The severity profile dropped it.** Under `lenient`, rules
304
+ that fire as `:warning` may have been further demoted to
305
+ `:info` and filtered out of your CI script.
306
+
307
+ When in doubt, run with `--explain`:
308
+
309
+ ```sh
310
+ rigor check --explain lib
311
+ ```
312
+
313
+ This adds an `:info` diagnostic for every fail-soft fallback
314
+ the engine took — every place it widened to `Dynamic[top]`
315
+ because it could not see further. The output is noisy on
316
+ realistic code but invaluable when "I expected a diagnostic
317
+ here" debugging.
318
+
319
+ ## Why a diagnostic IS firing when you think it should not
320
+
321
+ Almost always one of:
322
+
323
+ 1. **Rigor is right.** The classic case: a method's RBS sig
324
+ says `String?` but the project's runtime invariants
325
+ guarantee non-nil. Either fix the sig (preferred), add a
326
+ `RBS::Extended` `return:` directive, or add a `# rigor:disable`
327
+ on the line.
328
+ 2. **An RBS sig is missing or wrong.** The class lives in a
329
+ gem with no `.rbs`, or the project's own `sig/` is out of
330
+ date with the source. Update or add the sig.
331
+ 3. **A constant is being looked up wrong.** Constant
332
+ resolution can fall back to RBS-core or in-source class
333
+ discovery; if both miss, the call goes through
334
+ `Dynamic[top]` and you see no diagnostic, but a sibling
335
+ call against the wrong class might fire.
336
+ 4. **A diagnostic is genuinely false-positive.** Rare
337
+ (Rigor's design priority is no-false-positives) but
338
+ possible. File an issue with the smallest reproducer you
339
+ can extract.
340
+
341
+ ## A helpful workflow
342
+
343
+ The pragmatic loop on a project that just adopted Rigor:
344
+
345
+ 1. Run `rigor check lib` once to see the baseline.
346
+ 2. Skim every diagnostic. Triage as one of:
347
+ a. **Real bug.** Fix the code.
348
+ b. **Missing / wrong RBS.** Update the sig or add a new
349
+ one.
350
+ c. **Genuine noise.** Add `# rigor:disable <rule>` on the
351
+ line, or `disable:` to `.rigor.yml`.
352
+ 3. Re-run. Repeat until the diagnostic stream is clean.
353
+ 4. Add `rigor check lib` to CI under the
354
+ `balanced` profile (or stricter).
355
+ 5. As the project's invariants get more proven, demote
356
+ `# rigor:disable` lines into `RBS::Extended` directives
357
+ so the analyzer learns the real contract.
358
+
359
+ A clean `rigor check` run is the goal; a green CI badge says
360
+ "every diagnostic that fires is one we accept."
361
+
362
+ ## What's next
363
+
364
+ [Chapter 9 — Plugins](09-plugins.md) is a one-page pointer to
365
+ the `examples/` directory. Plugins extend Rigor for
366
+ project-specific DSLs (units of measure, route helpers,
367
+ deprecations, …). Most projects will never write one; the
368
+ chapter exists so you know the option is there.
369
+ [Chapter 10 — Coexisting with Sorbet](10-sorbet.md) is for
370
+ projects arriving from a Sorbet codebase: the
371
+ [`rigor-sorbet`](../../plugins/rigor-sorbet/) adapter reads
372
+ `sig { ... }` blocks, RBI files, and `T.let` / `T.cast` /
373
+ `T.must` / `T.unsafe` assertions as type sources.
@@ -0,0 +1,241 @@
1
+ # Plugins
2
+
3
+ Plugins exist for one reason: some methods' types depend on
4
+ the **shape of their arguments at runtime** in ways that no
5
+ RBS sig can express. This chapter helps you decide when that
6
+ is worth a plugin, and when it is not.
7
+
8
+ It does **not** teach plugin *authoring*. That lives in
9
+ [`examples/`](../../examples/README.md) — six tutorial
10
+ walkthroughs, each spotlighting one extension surface.
11
+ Ready-to-install gems for real frameworks live in
12
+ [`plugins/`](../../plugins/README.md). Read on to decide
13
+ whether you need a plugin; go to `examples/` once you want to
14
+ write one, or `plugins/` to install an existing one.
15
+
16
+ ## When you reach for a plugin
17
+
18
+ The classic case is a domain-specific evaluator:
19
+
20
+ ```ruby
21
+ Lisp.eval([:+, 1, 2]) # Integer at runtime
22
+ Lisp.eval([:<, 1, 2]) # bool at runtime
23
+ Lisp.eval([:if, true, "a", 0]) # String | Integer at runtime
24
+ ```
25
+
26
+ The return type depends on the literal first symbol of the
27
+ argument array. RBS can only say `untyped` here; Rigor's
28
+ inference can do nothing about it; an `RBS::Extended`
29
+ directive cannot vary by argument shape. **A plugin can.**
30
+
31
+ Other shapes that fit the plugin niche:
32
+
33
+ - **Units-of-measure DSLs** — `100.kilometers / 2.hours`
34
+ produces a `Speed`, but Ruby's runtime sees a method on
35
+ Integer that returns a user class.
36
+ - **Route helpers** — `users_path` returns a String, but
37
+ whether the helper exists at all depends on a YAML file
38
+ the analyzer has to read.
39
+ - **State machines** — `transition_to(:foo)` is fine if
40
+ `:foo` is in a `state_machine do ... end` block declared
41
+ somewhere; otherwise it is a typo.
42
+ - **Custom validators** — `validate(:email, value)` should
43
+ catch a literal that does not match the named pattern at
44
+ lint time.
45
+
46
+ Each of these has a worked example in
47
+ [`examples/`](../../examples/README.md). The
48
+ [`examples/README.md`](../../examples/README.md) page
49
+ compares the six worked examples on architectural axes
50
+ (config schema, file I/O, cache producers,
51
+ engine-collaboration via `Scope#type_of`, cross-plugin facts,
52
+ return-type contributions, …) and recommends a reading order.
53
+
54
+ ## What a plugin can do today
55
+
56
+ > Still here? Most readers should jump to
57
+ > [Should you write one?](#should-you-write-one) first — the
58
+ > answer is usually "no, RBS and `RBS::Extended` get you
59
+ > there." The surfaces below are for when it is "yes."
60
+
61
+ The v0.1.0+ plugin contract — pinned at
62
+ [`docs/internal-spec/plugin.md`](../internal-spec/plugin.md)
63
+ and laid out across a handful of slice specs in the same
64
+ directory — gives a plugin five primary surfaces:
65
+
66
+ 1. **`#diagnostics_for_file(path:, scope:, root:)`** — the
67
+ per-file emission hook. Walk the parsed AST, return an
68
+ array of `Rigor::Analysis::Diagnostic` rows. The runner
69
+ stamps each with `source_family: "plugin.<your-id>"`.
70
+ 2. **`dynamic_return(receivers:, methods:, file_methods:)` /
71
+ `type_specifier(methods:)`** — the per-call-site return-type
72
+ and flow-narrowing contribution surface (ADR-37 Slice 2).
73
+ A `dynamic_return` block names the inferred return type at a
74
+ matching call site; the analyzer's dispatcher merges the
75
+ contribution and uses it as if it were RBS-declared. A
76
+ `type_specifier` block contributes branch-narrowing facts.
77
+ (These replaced the removed `flow_contribution_for` hook —
78
+ ADR-52 WD3; a plugin that still defines it raises at load.)
79
+ 3. **`Plugin::IoBoundary#read_file`** / **`#open_url`** —
80
+ sandboxed file and (since v0.1.2) HTTPS reads under the
81
+ active `TrustPolicy`. Use this when the plugin needs to
82
+ read project files (route tables, schemas, locale files)
83
+ or fetch a stable URL.
84
+ 4. **`Plugin::Base.producer` + `#cache_for`** — plugin-side
85
+ cache producers. Use these for parses / lookups expensive
86
+ enough to want cross-run caching. Auto-invalidates on
87
+ the digest of every file (and content hash of every URL)
88
+ the IoBoundary read while building the result.
89
+ 5. **`Plugin::FactStore` + `#prepare(services)`** — the
90
+ cross-plugin fact-publication surface (v0.1.1 Track 2,
91
+ ADR-9). Plugins publish facts in `prepare`; downstream
92
+ plugins consume them through `services.fact_store` so
93
+ producer-side parsing (e.g., `config/routes.rb`) can be
94
+ reused by every consumer (controller-side validators,
95
+ factory-side validators, …).
96
+
97
+ Several worked examples (`rigor-lisp-eval`, `rigor-pattern`,
98
+ `rigor-units`, `rigor-activerecord`) contribute a narrowed
99
+ return type via `dynamic_return` rather than only emitting
100
+ diagnostics, so chained calls on plugin-typed values resolve
101
+ through the analyzer's normal dispatch rather than the
102
+ RBS-level `untyped` envelope. See the per-plugin README for
103
+ which surface each one demonstrates.
104
+
105
+ ## Macro / DSL expansion substrate (ADR-16)
106
+
107
+ A second authoring path was added on top of the hand-rolled
108
+ walker contract above: the **macro expansion substrate**
109
+ (ADR-16). For metaprogramming-heavy DSLs — Rails-style
110
+ `has_one_attached`, dry-struct's `attribute`, Devise's
111
+ `devise :strategy`, Sinatra's `get '/foo' do ... end` — the
112
+ substrate lets a plugin author **declare** the call shape
113
+ instead of walking the AST by hand. The plugin's body becomes
114
+ a single manifest entry; the substrate handles literal-symbol
115
+ extraction, name interpolation, registry lookup, and per-method
116
+ synthesis.
117
+
118
+ Four tier shapes are recognised. The
119
+ [per-library survey](../notes/20260515-macro-expansion-library-survey.md)
120
+ identifies which libraries fit each tier and which fall
121
+ outside the substrate's scope.
122
+
123
+ | Tier | Shape | Manifest declaration | Worked example |
124
+ | --- | --- | --- | --- |
125
+ | **A — block-as-method** | DSL call's block runs as an instance method on the receiver class (`Sinatra::Base#generate_method`) | `block_as_methods: [Macro::BlockAsMethod.new(receiver_constraint:, method_names:)]` | [`rigor-sinatra`](../../plugins/rigor-sinatra/) |
126
+ | **B — trait-inlining registry** | Class-level call enumerates symbols → bundled registry maps each to a module → substrate explodes the module's RBS methods onto the calling class | `trait_registries: [Macro::TraitRegistry.new(receiver_constraint:, method_name:, modules_by_symbol:, always_included:)]` | [`rigor-devise`](../../plugins/rigor-devise/) |
127
+ | **C — heredoc template** | Class-level call interpolates a literal symbol into a method-name template; substrate emits synthetic readers | `heredoc_templates: [Macro::HeredocTemplate.new(receiver_constraint:, method_name:, symbol_arg_position:, emit:)]` | [`rigor-dry-struct`](../../plugins/rigor-dry-struct/) |
128
+
129
+ The three Tier-A/B/C plugins above are each ~60–110 LoC of
130
+ **purely declarative** Ruby — no walker, no
131
+ `diagnostics_for_file`, no plugin-side state. The substrate's
132
+ pre-pass + dispatcher integration do the work.
133
+
134
+ ### Concern re-targeting
135
+
136
+ `ActiveSupport::Concern.included do ... end` is a *deferred
137
+ class_eval*: any DSL calls inside the block fire on whoever
138
+ includes the concern, not on the concern module itself. The
139
+ substrate's scanner handles this re-targeting automatically.
140
+ For source like:
141
+
142
+ ```ruby
143
+ module Auditable
144
+ extend ActiveSupport::Concern
145
+ included do
146
+ attribute :audited_at, Types::Time
147
+ end
148
+ end
149
+
150
+ class Address < Dry::Struct
151
+ include Auditable
152
+ attribute :city, Types::String
153
+ end
154
+ ```
155
+
156
+ `Address` gets BOTH `city` (direct) AND `audited_at` (re-targeted
157
+ from `Auditable`) as synthetic readers. The same pattern works
158
+ for Tier B traits (Devise modules included via Concerns).
159
+
160
+ ### Floor / ceiling
161
+
162
+ Per ADR-16 § WD13, the **floor** is that synthetic methods emit
163
+ by NAME so cross-file dispatch resolves (no more
164
+ `call.undefined-method`). The common cases also recover precise
165
+ return types: **Tier B** redispatches on the origin module's
166
+ authored RBS (a Devise `valid_password?` resolves to `bool`,
167
+ not `Dynamic[T]`), and **Tier C** resolves a plain class-name
168
+ return to its `Nominal`. What still degrades to `Dynamic[T]` is
169
+ the parameterised / utility-type-shaped Tier C return
170
+ (`Array[String]`, `Pick<T, K>`); routing those through the
171
+ [ADR-13](../adr/13-typenode-resolver-plugin.md) resolver chain
172
+ is the **ceiling**, demand-driven. The substrate never
173
+ *fabricates* precision per ADR-5 robustness.
174
+
175
+ ### Choosing between the substrate and a hand-rolled walker
176
+
177
+ | If the DSL is… | Use the substrate | Use a hand-rolled walker |
178
+ | --- | --- | --- |
179
+ | `class-level call with literal symbol args + framework class_eval'd heredoc` | ✓ Tier C | — |
180
+ | `class-level call with literal symbol args + registry-driven module include` | ✓ Tier B | — |
181
+ | `class-level call with do…end block running as an instance method` | ✓ Tier A | — |
182
+ | `external Ruby files instance_eval'd under a declared self` | ✓ Tier D (contract only as of v0.1.x) | — |
183
+ | `domain DSL whose return type depends on argument shape` | — | `dynamic_return` ([`rigor-lisp-eval`](../../examples/rigor-lisp-eval/)) |
184
+ | `cross-file validation (collect declarations, then validate uses)` | — | Two-pass walker ([`rigor-statesman`](../../plugins/rigor-statesman/)) |
185
+ | `parsing an external project file (routes, schema, locale)` | — | `IoBoundary` + cache producer ([`rigor-routes`](../../examples/rigor-routes/)) |
186
+ | `schema-graph recorder (GraphQL-Ruby-style)` | — | Schema-resolution pass (no plugin authored yet) |
187
+
188
+ The substrate and the hand-rolled walker contract coexist —
189
+ a plugin can mix `manifest`-declared substrate entries with a
190
+ `diagnostics_for_file` walker. The
191
+ [`skills/rigor-plugin-author/SKILL.md`](../../skills/rigor-plugin-author/SKILL.md)
192
+ SKILL captures the decision flow in detail; the survey at
193
+ [`docs/notes/20260515-macro-expansion-library-survey.md`](../notes/20260515-macro-expansion-library-survey.md)
194
+ records which Ruby libraries the substrate covers and which
195
+ fall outside.
196
+
197
+ ## Should you write one?
198
+
199
+ Probably not — most projects benefit from RBS and
200
+ `RBS::Extended` long before they hit the plugin niche.
201
+ Reach for a plugin only when:
202
+
203
+ - A domain DSL's typing depends on argument shape, file
204
+ contents, or cross-method declarations.
205
+ - You are willing to maintain the plugin gem alongside your
206
+ application.
207
+ - The team can read the plugin's source — it is not a black
208
+ box anyone can ignore.
209
+
210
+ If those are true, [`examples/README.md`](../../examples/README.md)
211
+ is your starting point. The
212
+ [`rigor-deprecations`](../../examples/rigor-deprecations/)
213
+ example is the smallest fully-shaped plugin — manifest +
214
+ single per-file walk + a couple of diagnostic emissions —
215
+ and is the recommended template for "I want to author my
216
+ first plugin."
217
+
218
+ ## What's next
219
+
220
+ If your project uses [Sorbet](https://sorbet.org/), the
221
+ [next chapter](10-sorbet.md) covers the `rigor-sorbet`
222
+ adapter — Rigor reads `sig { ... }` blocks, RBI files, and
223
+ `T.let` / `T.cast` / `T.must` / `T.unsafe` assertions as
224
+ type sources, so you do not have to rewrite anything in RBS
225
+ to start running `rigor check` alongside `srb tc`. If you do
226
+ not use Sorbet, chapter 10 is safe to skip.
227
+
228
+ From here:
229
+
230
+ - Cover-to-cover re-reading is rarely useful — most readers
231
+ return to specific chapters as questions arise.
232
+ - The [Handbook index](README.md) has the cross-references
233
+ to deeper material in
234
+ [`docs/type-specification/`](../type-specification/README.md),
235
+ [`docs/internal-spec/`](../internal-spec/README.md), and
236
+ [`docs/adr/`](../adr/).
237
+ - The [`CHANGELOG.md`](../../CHANGELOG.md) is the per-release
238
+ truth for what shipped when.
239
+
240
+ Welcome to the small, growing community of static-Ruby
241
+ believers.