rigortype 0.2.1 → 0.2.3
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.
- checksums.yaml +4 -4
- data/README.md +41 -14
- data/docs/handbook/01-getting-started.md +311 -0
- data/docs/handbook/02-everyday-types.md +337 -0
- data/docs/handbook/03-narrowing.md +359 -0
- data/docs/handbook/04-tuples-and-shapes.md +321 -0
- data/docs/handbook/05-methods-and-blocks.md +339 -0
- data/docs/handbook/06-classes.md +305 -0
- data/docs/handbook/07-rbs-and-extended.md +427 -0
- data/docs/handbook/08-understanding-errors.md +373 -0
- data/docs/handbook/09-plugins.md +241 -0
- data/docs/handbook/10-sorbet.md +347 -0
- data/docs/handbook/11-sig-gen.md +312 -0
- data/docs/handbook/12-lightweight-hkt.md +333 -0
- data/docs/handbook/README.md +275 -0
- data/docs/handbook/appendix-elixir.md +370 -0
- data/docs/handbook/appendix-go.md +399 -0
- data/docs/handbook/appendix-java-csharp.md +470 -0
- data/docs/handbook/appendix-liskov.md +580 -0
- data/docs/handbook/appendix-mypy.md +370 -0
- data/docs/handbook/appendix-phpstan.md +338 -0
- data/docs/handbook/appendix-protocols-and-structural-typing.md +292 -0
- data/docs/handbook/appendix-rust.md +446 -0
- data/docs/handbook/appendix-steep.md +336 -0
- data/docs/handbook/appendix-type-theory.md +1662 -0
- data/docs/handbook/appendix-typeprof.md +416 -0
- data/docs/handbook/appendix-typescript.md +332 -0
- data/docs/install.md +189 -0
- data/docs/llms.txt +72 -0
- data/docs/manual/01-installation.md +342 -0
- data/docs/manual/02-cli-reference.md +569 -0
- data/docs/manual/03-configuration.md +152 -0
- data/docs/manual/04-diagnostics.md +206 -0
- data/docs/manual/05-inspecting-types.md +109 -0
- data/docs/manual/06-baseline.md +104 -0
- data/docs/manual/07-plugins.md +92 -0
- data/docs/manual/08-skills.md +143 -0
- data/docs/manual/09-editor-integration.md +245 -0
- data/docs/manual/10-mcp-server.md +539 -0
- data/docs/manual/11-ci.md +274 -0
- data/docs/manual/12-caching.md +116 -0
- data/docs/manual/13-troubleshooting.md +120 -0
- data/docs/manual/14-rails-quickstart.md +332 -0
- data/docs/manual/15-type-protection-coverage.md +204 -0
- data/docs/manual/16-rbs-extended-annotations.md +190 -0
- data/docs/manual/17-driving-improvement.md +160 -0
- data/docs/manual/README.md +87 -0
- data/docs/manual/ci-templates/README.md +58 -0
- data/docs/manual/plugins/README.md +86 -0
- data/docs/manual/plugins/rigor-actioncable.md +78 -0
- data/docs/manual/plugins/rigor-actionmailer.md +74 -0
- data/docs/manual/plugins/rigor-actionpack.md +80 -0
- data/docs/manual/plugins/rigor-activejob.md +58 -0
- data/docs/manual/plugins/rigor-activerecord.md +102 -0
- data/docs/manual/plugins/rigor-activestorage.md +74 -0
- data/docs/manual/plugins/rigor-activesupport-core-ext.md +86 -0
- data/docs/manual/plugins/rigor-devise.md +70 -0
- data/docs/manual/plugins/rigor-dry-schema.md +56 -0
- data/docs/manual/plugins/rigor-dry-struct.md +60 -0
- data/docs/manual/plugins/rigor-dry-types.md +59 -0
- data/docs/manual/plugins/rigor-dry-validation.md +62 -0
- data/docs/manual/plugins/rigor-factorybot.md +76 -0
- data/docs/manual/plugins/rigor-graphql.md +89 -0
- data/docs/manual/plugins/rigor-hanami.md +83 -0
- data/docs/manual/plugins/rigor-mangrove.md +73 -0
- data/docs/manual/plugins/rigor-minitest.md +86 -0
- data/docs/manual/plugins/rigor-pundit.md +72 -0
- data/docs/manual/plugins/rigor-rails-i18n.md +92 -0
- data/docs/manual/plugins/rigor-rails-routes.md +94 -0
- data/docs/manual/plugins/rigor-rails.md +44 -0
- data/docs/manual/plugins/rigor-rbs-inline.md +83 -0
- data/docs/manual/plugins/rigor-rspec-rails.md +72 -0
- data/docs/manual/plugins/rigor-rspec.md +86 -0
- data/docs/manual/plugins/rigor-shoulda-matchers.md +78 -0
- data/docs/manual/plugins/rigor-sidekiq.md +78 -0
- data/docs/manual/plugins/rigor-sinatra.md +61 -0
- data/docs/manual/plugins/rigor-sorbet.md +63 -0
- data/docs/manual/plugins/rigor-statesman.md +75 -0
- data/docs/manual/plugins/rigor-typescript-utility-types.md +71 -0
- data/exe/rigor +1 -1
- data/lib/rigor/analysis/incremental_session.rb +4 -2
- data/lib/rigor/analysis/run_stats.rb +13 -1
- data/lib/rigor/analysis/runner.rb +54 -12
- data/lib/rigor/cli/check_command.rb +1 -1
- data/lib/rigor/cli/docs_command.rb +248 -0
- data/lib/rigor/cli/skill_command.rb +103 -41
- data/lib/rigor/cli/skill_describe.rb +346 -0
- data/lib/rigor/cli/triage_command.rb +8 -2
- data/lib/rigor/cli/triage_renderer.rb +4 -0
- data/lib/rigor/cli.rb +25 -3
- data/lib/rigor/inference/method_dispatcher/constant_folding.rb +124 -32
- data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +37 -6
- data/lib/rigor/inference/scope_indexer.rb +87 -89
- data/lib/rigor/plugin/isolation.rb +5 -5
- data/lib/rigor/plugin/loader.rb +4 -2
- data/lib/rigor/triage/catalogue.rb +16 -1
- data/lib/rigor/triage.rb +30 -7
- data/lib/rigor/version.rb +1 -1
- data/skills/rigor-ask/SKILL.md +172 -0
- data/skills/rigor-doctor/SKILL.md +87 -0
- data/skills/rigor-editor-setup/SKILL.md +114 -0
- data/skills/rigor-mcp-setup/SKILL.md +117 -0
- data/skills/rigor-monkeypatch-resolve/SKILL.md +79 -0
- data/skills/rigor-next-steps/SKILL.md +113 -0
- data/skills/rigor-plugin-tune/SKILL.md +79 -0
- data/skills/rigor-protection-uplift/SKILL.md +133 -0
- data/skills/rigor-rbs-setup/SKILL.md +128 -0
- data/skills/rigor-upgrade/SKILL.md +79 -0
- 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.
|