rigortype 0.3.4 → 0.3.5
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 +1 -1
- data/data/effects/core.yml +18 -1
- data/data/effects/registry.yml +31 -3
- data/docs/manual/02-cli-reference.md +60 -13
- data/docs/manual/03-configuration.md +20 -3
- data/docs/manual/04-diagnostics.md +3 -2
- data/docs/manual/11-ci.md +37 -0
- data/docs/manual/12-caching.md +39 -0
- data/docs/manual/16-rbs-extended-annotations.md +15 -2
- data/docs/manual/19-effect-labels.md +671 -0
- data/docs/manual/README.md +5 -0
- data/docs/manual/ci-templates/README.md +9 -0
- data/lib/rigor/analysis/rule_catalog.rb +10 -3
- data/lib/rigor/analysis/run_cache_probe.rb +69 -1
- data/lib/rigor/analysis/runner/declaration_position.rb +8 -24
- data/lib/rigor/analysis/runner/effect_annotation_residual_pass.rb +28 -28
- data/lib/rigor/analysis/runner/effect_envelope_pass.rb +1 -1
- data/lib/rigor/analysis/runner/pool_coordinator.rb +25 -0
- data/lib/rigor/analysis/runner/run_snapshots.rb +7 -4
- data/lib/rigor/analysis/runner.rb +12 -3
- data/lib/rigor/analysis/worker_session.rb +3 -1
- data/lib/rigor/cli/effects_command.rb +123 -9
- data/lib/rigor/cli/effects_diff_renderer.rb +5 -1
- data/lib/rigor/cli/effects_renderer.rb +41 -3
- data/lib/rigor/cli/effects_report.rb +116 -6
- data/lib/rigor/cli/effects_snapshot_command.rb +23 -4
- data/lib/rigor/cli.rb +12 -1
- data/lib/rigor/configuration.rb +37 -25
- data/lib/rigor/configuration_error.rb +20 -0
- data/lib/rigor/effects/collector.rb +38 -1
- data/lib/rigor/effects/entry_points.rb +47 -1
- data/lib/rigor/effects/file_collection.rb +18 -4
- data/lib/rigor/effects/framework_units.rb +68 -13
- data/lib/rigor/effects/inline_anchor.rb +134 -0
- data/lib/rigor/effects/plugin_facts.rb +62 -13
- data/lib/rigor/effects/propagator.rb +79 -19
- data/lib/rigor/effects/registry.rb +10 -3
- data/lib/rigor/effects/scanner.rb +21 -9
- data/lib/rigor/effects/signature_sources.rb +16 -0
- data/lib/rigor/effects/snapshot.rb +21 -5
- data/lib/rigor/effects/taint_cause.rb +1 -0
- data/lib/rigor/effects/unit_scan.rb +87 -12
- data/lib/rigor/plugin/base.rb +4 -0
- data/lib/rigor/plugin/box.rb +18 -2
- data/lib/rigor/plugin/effect_ancestry.rb +80 -0
- data/lib/rigor/plugin/manifest.rb +34 -10
- data/lib/rigor/plugin/registry.rb +10 -3
- data/lib/rigor/rbs_extended.rb +22 -2
- data/lib/rigor/version.rb +1 -1
- data/plugins/rigor-activesupport-core-ext/sig/active_support/core_ext.rbs +17 -2
- data/plugins/rigor-devise/lib/rigor/plugin/devise.rb +27 -0
- data/plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline.rb +56 -1
- data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/effects.rb +75 -0
- data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq.rb +13 -1
- metadata +6 -1
|
@@ -0,0 +1,671 @@
|
|
|
1
|
+
# Effect labels — what your code *does*
|
|
2
|
+
|
|
3
|
+
Rigor's usual question is what a method **returns**. Effect labels answer the
|
|
4
|
+
other one: what it **does**. `Reports::Nightly#perform` returns a boolean; that
|
|
5
|
+
it opens a socket, reads the clock and writes to the database is a different
|
|
6
|
+
fact about it, and usually the one a reviewer wants when a pull request touches
|
|
7
|
+
a job.
|
|
8
|
+
|
|
9
|
+
The command reference — every flag, every subcommand — is in
|
|
10
|
+
[CLI reference](02-cli-reference.md#rigor-effects), and every configuration key
|
|
11
|
+
is in [Configuration § Effect labels](03-configuration.md#effect-labels). This
|
|
12
|
+
chapter is the workflow around them: what a label is, what the output really
|
|
13
|
+
looks like on a real application, and the order to adopt the three surfaces in.
|
|
14
|
+
|
|
15
|
+
**Almost none of this tells you that you were wrong.** The report and the
|
|
16
|
+
snapshot are observations: they say what your code does, not that doing it was a
|
|
17
|
+
mistake — which is why effect labels ship as their own command rather than as
|
|
18
|
+
`rigor check` findings. `rigor effects check` does fail a build, but on drift
|
|
19
|
+
from a record you committed, never on a judgment Rigor made. The one place Rigor
|
|
20
|
+
judges is the last section of this chapter: the bounds you declare yourself.
|
|
21
|
+
|
|
22
|
+
## Three surfaces, one collection pass
|
|
23
|
+
|
|
24
|
+
Rigor collects effects once per run and then shows them three ways. Readers
|
|
25
|
+
confuse them constantly, so it is worth pinning down before anything else:
|
|
26
|
+
|
|
27
|
+
| Surface | Command | Answers | Can it fail? |
|
|
28
|
+
| --- | --- | --- | --- |
|
|
29
|
+
| **The report** | `rigor effects` | "What does every method do, right now?" | Never — always exits 0 |
|
|
30
|
+
| **The snapshot** | `rigor effects update` / `check` / `diff` / `explain` | "What changed since we last agreed?" | `check` exits 1 on **any** drift |
|
|
31
|
+
| **Declared bounds** | `effects.envelopes:` in `.rigor.yml`, `%a{pure}` / `%a{rigor:v1:effect …}` in signatures | "Did this method's own code exceed what we said it may do?" | A `rigor check` diagnostic, on **proven** labels only |
|
|
32
|
+
|
|
33
|
+
The report is for reading. The snapshot is for reviewing — it is to effects what
|
|
34
|
+
`db/schema.rb` is to your schema: a generated file you commit, whose *diff* is
|
|
35
|
+
the artefact. The declared bounds are where you assert a contract about code
|
|
36
|
+
Rigor read, and the only place effects behave like the rest of Rigor.
|
|
37
|
+
|
|
38
|
+
The two failing surfaces answer different questions, and the difference decides
|
|
39
|
+
which one your policy belongs in:
|
|
40
|
+
|
|
41
|
+
| If your policy is… | Write it as | Because |
|
|
42
|
+
| --- | --- | --- |
|
|
43
|
+
| "this method does nothing but compute" | a declared bound | `mutate.*`, `io.fs.*`, `nondet.*`, `global.*` and `exit` are proven from your own code, so a bound on them fires |
|
|
44
|
+
| "this layer must not touch the database / the cache / the mailer / the queue" | a committed snapshot + `rigor effects check` | those labels come from a plugin modelling a framework Rigor did not read, so no bound can fire on them — see [What a bound can and cannot see](#what-a-bound-can-and-cannot-see) |
|
|
45
|
+
|
|
46
|
+
Adopt them in that order. Each one is useful without the next.
|
|
47
|
+
|
|
48
|
+
## The label vocabulary
|
|
49
|
+
|
|
50
|
+
A label is a dot-path of lowercase segments: `io`, `io.net.http`, `nondet.time`.
|
|
51
|
+
A bound that names a label admits everything **under** it and nothing above it —
|
|
52
|
+
`io` covers `io.db.read`, `io.db.read` does not cover `io.db`. Matching is over
|
|
53
|
+
whole segments, so `io` never covers `iota`.
|
|
54
|
+
|
|
55
|
+
This is the whole shipped vocabulary:
|
|
56
|
+
|
|
57
|
+
| Root | Labels | Means |
|
|
58
|
+
| --- | --- | --- |
|
|
59
|
+
| `io` | `io`, `io.db`, `io.db.read`, `io.db.write`, `io.db.transaction`, `io.fs`, `io.fs.read`, `io.fs.write`, `io.net`, `io.net.http`, `io.input`, `io.output`, `io.output.stdout`, `io.output.stderr`, `io.output.buffer`, `io.output.header`, `io.ipc`, `io.process`, `io.signal` | Talks to something outside the process — a file, a socket, a database, a terminal, a subprocess |
|
|
60
|
+
| `mutate` | `mutate`, `mutate.local`, `mutate.self`, `mutate.instance`, `mutate.static` | Changes state in place: an object the frame allocated (`local`), the receiver's own state (`self`), some other object (`instance`), or class-level state (`static`) |
|
|
61
|
+
| `nondet` | `nondet`, `nondet.random`, `nondet.time` | Reads something that differs between two identical runs |
|
|
62
|
+
| `global` | `global.read`, `global.write` | Process-wide state — `ENV`, `$stdout`, a class variable |
|
|
63
|
+
| `exit` | `exit` | Can end the process (`exit`, `abort`) |
|
|
64
|
+
| `ffi` | `ffi` | Calls out through a foreign-function interface |
|
|
65
|
+
| meaning | `telemetry`, `email.send`, `job.enqueue`, `cache.read`, `cache.write` | Application-level meaning rather than transport — what a policy actually names |
|
|
66
|
+
| `failure` | `failure`, `failure.environment`, `failure.input`, `failure.resource` | Registered so a policy written for [Steins](https://github.com/rigortype/steins), the sibling PHP analyzer, parses here. Rigor never produces one; a bound naming one is satisfied vacuously |
|
|
67
|
+
|
|
68
|
+
Two things to internalise now, because everything below depends on them:
|
|
69
|
+
|
|
70
|
+
- **`mutate.local` is free.** Mutating an object the method itself allocated and
|
|
71
|
+
never let out is not an effect anybody bounds, and every envelope tolerates
|
|
72
|
+
it. A method whose only label is `mutate.local` is a pure method.
|
|
73
|
+
- **Plugins add roots.** `rails.i18n.translate`, `rails.session.write`,
|
|
74
|
+
`rails.flash.write` and the rest of `rails.*` come from the Rails plugins, not
|
|
75
|
+
from the shipped file. Your own project can open any root it likes with
|
|
76
|
+
[`effects.labels:`](03-configuration.md#effect-labels).
|
|
77
|
+
|
|
78
|
+
Your installed Rigor can print all of this, including whatever your plugins and
|
|
79
|
+
your own `effects.labels:` opened:
|
|
80
|
+
|
|
81
|
+
```sh
|
|
82
|
+
rigor effects --list-labels
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
It analyses nothing, so it is instant, and it is the answer to "what may I write
|
|
86
|
+
in `effects.envelopes:`" — a question four configuration keys and two annotation
|
|
87
|
+
forms all ask of you.
|
|
88
|
+
|
|
89
|
+
The full grammar, the subsumption rules and the registry's evolution policy are
|
|
90
|
+
normative in the effect-labels specification, which the gem does not ship:
|
|
91
|
+
<https://rigor.typedduck.fail/type-specification/effect-labels/>.
|
|
92
|
+
|
|
93
|
+
## Turning it on
|
|
94
|
+
|
|
95
|
+
One line in `.rigor.yml`:
|
|
96
|
+
|
|
97
|
+
```yaml
|
|
98
|
+
effects: {}
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
The *presence* of the block is the switch; the empty hash means "every sub-key
|
|
102
|
+
at its default". Nothing else turns collection on — in particular an
|
|
103
|
+
`%a{pure}` annotation in your signatures does not, because one line in one file
|
|
104
|
+
must not make every run of the project more expensive. A project with
|
|
105
|
+
annotations and no block gets told so, once per run:
|
|
106
|
+
|
|
107
|
+
```
|
|
108
|
+
sig/slug.rbs:2:1: info: Effect annotations (`%a{pure}` / `%a{rigor:v1:effect …}`) are present in your project's signatures, but `.rigor.yml` carries no `effects:` block, so effect collection never runs and nothing checks them — they are documentation, not a contract. Add `effects: {}` to have Rigor prove what your methods do and check these bounds against it (ADR-103); an annotation alone never turns collection on, because that would make one line in one signature file more expensive for every run of the project.
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
From **v0.4.0** collection is the default: a config with no `effects:` key at
|
|
112
|
+
all behaves as `effects: {}`, and `effects: false` is how you opt out. On the
|
|
113
|
+
`0.3.x` line you can preview that with
|
|
114
|
+
`bleeding_edge: [effects-on-by-default]`.
|
|
115
|
+
|
|
116
|
+
One exception to all of this: `rigor effects` — the bare report — runs under an
|
|
117
|
+
implicit empty block whether or not you configured one, so you can look before
|
|
118
|
+
you commit to anything.
|
|
119
|
+
|
|
120
|
+
## The report
|
|
121
|
+
|
|
122
|
+
```sh
|
|
123
|
+
rigor effects
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
Set your expectations before you run it. On [Redmine](https://www.redmine.org/)
|
|
127
|
+
— 347 analysed files across `app` and `lib`, six Rails plugins, no signatures of
|
|
128
|
+
its own — the report is:
|
|
129
|
+
|
|
130
|
+
| | count | share |
|
|
131
|
+
| --- | --- | --- |
|
|
132
|
+
| units analysed | 4,683 | |
|
|
133
|
+
| rows printed by default | 2,730 | 58% of units |
|
|
134
|
+
| rows omitted, because they say nothing at all | 1,953 | 42% |
|
|
135
|
+
| lines on stdout | 2,733 | (the rows, plus a three-line footer) |
|
|
136
|
+
| printed rows ending in ` …?` | 2,468 | **90% of rows** |
|
|
137
|
+
| lines under `--full --why` | 34,680 | |
|
|
138
|
+
|
|
139
|
+
**Ninety percent hedged is the normal, healthy state of a Rails application**,
|
|
140
|
+
not a sign that something is broken. Rails resolves an enormous amount at
|
|
141
|
+
runtime, and Rigor reports what it proved rather than what it feared. If you
|
|
142
|
+
were expecting an exhaustive answer for every method, recalibrate here rather
|
|
143
|
+
than at the end of the chapter.
|
|
144
|
+
|
|
145
|
+
The report closes with a footer, and it counts the two lanes apart on purpose:
|
|
146
|
+
|
|
147
|
+
```
|
|
148
|
+
──
|
|
149
|
+
2730 of 4683 units printed; 1953 omitted (--full)
|
|
150
|
+
2183 carry a proven label · 1555 carry a declared (≤) one · 262 are exhaustive
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
Those two numbers have different powers, and the rest of this chapter is about
|
|
154
|
+
the difference. A **proven** label can fail a build. A **declared** one cannot —
|
|
155
|
+
it is a claim by a plugin about a framework Rigor never read, and the snapshot
|
|
156
|
+
is what enforces it.
|
|
157
|
+
|
|
158
|
+
### Reading a row
|
|
159
|
+
|
|
160
|
+
```
|
|
161
|
+
IssuesController#create: [global.read, io, mutate.instance, mutate.local, mutate.self, mutate.static] ≤ [email.send, job.enqueue, mutate, rails.flash.write, rails.i18n.translate, rails.response.write] …? (21 reasons, --why)
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
Four fields, and they answer four different questions:
|
|
165
|
+
|
|
166
|
+
1. **The key** — `Owner#instance_method` or `Owner.singleton_method`. Rows are
|
|
167
|
+
sorted by it.
|
|
168
|
+
2. **The proven list** — what Rigor *established*, following your call graph
|
|
169
|
+
transitively. `IssuesController#create` reads a class variable somewhere
|
|
170
|
+
below it, writes an ivar, writes class-level state, and mutates objects of
|
|
171
|
+
its own. This is the only lane a diagnostic will ever read.
|
|
172
|
+
3. **The `≤` declared lane** — what a source Rigor trusts, but did not read,
|
|
173
|
+
*claims*. It is a claim, never a proof, and it is printed apart from the
|
|
174
|
+
proven labels for exactly that reason. On a framework application this is
|
|
175
|
+
overwhelmingly your **plugins** speaking: `rails.flash.write` and
|
|
176
|
+
`rails.i18n.translate` above are Action Pack's and rails-i18n's rows, not
|
|
177
|
+
anything you wrote. Your own
|
|
178
|
+
[`effects.attribution:`](03-configuration.md#effect-labels) table lands in the
|
|
179
|
+
same lane, and on most projects contributes far less than the plugins do.
|
|
180
|
+
Declared labels follow call edges just as proven ones do, so a controller two
|
|
181
|
+
hops above an attributed gem call carries the claim rather than a shrug. A
|
|
182
|
+
row you wrote yourself leaves a `plugin-attribution (Owner.method)` reason
|
|
183
|
+
line naming the call; a row a bundled plugin contributed does not, because a
|
|
184
|
+
trusted row discharges its own taint.
|
|
185
|
+
4. **` …?`** — "these effects, and **possibly more**". Some call could not be
|
|
186
|
+
resolved, and the count says how many kinds. `--why` expands them under the
|
|
187
|
+
row, along with the plugin row behind each declared label:
|
|
188
|
+
|
|
189
|
+
```
|
|
190
|
+
IssuesController#create: … …?
|
|
191
|
+
dynamic-receiver (inferred_return_untyped)
|
|
192
|
+
template-not-analysed (ActionController::Base#render)
|
|
193
|
+
plugin:ActionController::Base#redirect_to → [mutate.self, rails.response.write]
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
They are collapsed by default because on Redmine they are 30,000 of the
|
|
197
|
+
34,680 lines a full report prints, and they answer a question you ask about
|
|
198
|
+
one row after having read many.
|
|
199
|
+
|
|
200
|
+
Two kinds of row are **omitted** by default:
|
|
201
|
+
|
|
202
|
+
- a method that proves nothing beyond `mutate.local` and claims nothing — the
|
|
203
|
+
reading of `%a{pure}`;
|
|
204
|
+
- a method with no label in either lane, which exists only to record that
|
|
205
|
+
something below it was unresolved. That is 1,953 rows on Redmine, and the
|
|
206
|
+
footer counts them.
|
|
207
|
+
|
|
208
|
+
`--full` prints both. But the first group is worth asking for by name:
|
|
209
|
+
|
|
210
|
+
```sh
|
|
211
|
+
rigor effects --pure
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
436 methods on Redmine, and they are your `%a{pure}` candidates — the on-ramp to
|
|
215
|
+
the last section of this chapter.
|
|
216
|
+
|
|
217
|
+
### Asking it a question
|
|
218
|
+
|
|
219
|
+
**By label.** The question this chapter opens with, in one command:
|
|
220
|
+
|
|
221
|
+
```sh
|
|
222
|
+
$ rigor effects --label io.net
|
|
223
|
+
Redmine::IMAP.check: [exit, global.read, io, io.fs.read, io.fs.write, io.net, …] ≤ [email.send, job.enqueue, …] …? (121 reasons, --why)
|
|
224
|
+
Redmine::POP3.check: …
|
|
225
|
+
WebhookEndpointValidator#validate_each: …
|
|
226
|
+
──
|
|
227
|
+
5 of 4683 units printed; 4678 not selected
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
`--label` matches a label and everything under it — `--label io` selects the
|
|
231
|
+
`io.net` rows above and the `io.fs.read` ones too — and it looks in **both**
|
|
232
|
+
lanes, because "what talks to the network" is a question about your code rather
|
|
233
|
+
than about which lane happens to know it. The row's own rendering keeps the two
|
|
234
|
+
apart.
|
|
235
|
+
|
|
236
|
+
**By path.**
|
|
237
|
+
|
|
238
|
+
```
|
|
239
|
+
$ rigor effects app/controllers/issues_controller.rb
|
|
240
|
+
rigor: showing 20 of 4683 units, selected by app/controllers/issues_controller.rb;
|
|
241
|
+
a path narrows the printing and not the analysis, so every label is the one
|
|
242
|
+
the whole-project run reports
|
|
243
|
+
IssuesController#create: [global.read, io, mutate.instance, mutate.local, mutate.self, mutate.static] ≤ [email.send, job.enqueue, …] …?
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
It is a **view**, not a scope. Rigor still analyses your configured `paths:`, so
|
|
247
|
+
the twenty rows it prints are the same twenty lines the whole-project run
|
|
248
|
+
prints. That costs a full analysis — the note on stderr is there so you know
|
|
249
|
+
what you paid for and what you got — and it is the only honest way to do it: an
|
|
250
|
+
effect summary is transitive, so analysing less would not filter this report, it
|
|
251
|
+
would lower every answer in it. A path that names no method says so rather than
|
|
252
|
+
printing an empty report.
|
|
253
|
+
|
|
254
|
+
**By count.** `--limit N` prints the first N rows and the footer says how many
|
|
255
|
+
it cut.
|
|
256
|
+
|
|
257
|
+
And `grep`, `sort` and `--format=json` piped into `jq` all still work — the text
|
|
258
|
+
is stable and sorted, the JSON payload carries the same totals the footer
|
|
259
|
+
prints, and the path note goes to stderr so a redirect gets the report alone.
|
|
260
|
+
|
|
261
|
+
## Direct and transitive
|
|
262
|
+
|
|
263
|
+
The same method key appears in two places with two different answers, and this
|
|
264
|
+
is the single idea in the feature most likely to trip you up. From one
|
|
265
|
+
`rigor effects update` on Redmine:
|
|
266
|
+
|
|
267
|
+
```yaml
|
|
268
|
+
methods:
|
|
269
|
+
"IssuesController#create":
|
|
270
|
+
effects: []
|
|
271
|
+
declared: ["mutate", "mutate.self", "rails.flash.write", "rails.response.write"]
|
|
272
|
+
exhaustive: false
|
|
273
|
+
|
|
274
|
+
reach:
|
|
275
|
+
"IssuesController#create":
|
|
276
|
+
effects: ["global.read", "mutate.local", "mutate.self", "mutate.static"]
|
|
277
|
+
declared: ["mutate", "rails.flash.write", "rails.i18n.translate", "rails.response.write"]
|
|
278
|
+
exhaustive: false
|
|
279
|
+
```
|
|
280
|
+
|
|
281
|
+
- `methods:` is the **direct** summary — what this method's own body does,
|
|
282
|
+
including block literals and catalogued calls, but *not* what the project
|
|
283
|
+
methods it calls do. `effects: []` says the controller action's own lines
|
|
284
|
+
perform nothing; everything happens below it.
|
|
285
|
+
- `reach:` is the **transitive** footprint, and is identical to what
|
|
286
|
+
`rigor effects` printed for that method.
|
|
287
|
+
|
|
288
|
+
The split is deliberate. A direct entry moves only when its own lines change,
|
|
289
|
+
so its diff is attributable to the pull request that caused it; a transitive
|
|
290
|
+
entry is the blast radius, and is where a leaf change is supposed to fan out.
|
|
291
|
+
`rigor effects` shows you the transitive number because that is the one you want
|
|
292
|
+
when *reading*; the snapshot records both because they fail differently when
|
|
293
|
+
reviewing.
|
|
294
|
+
|
|
295
|
+
## The snapshot
|
|
296
|
+
|
|
297
|
+
```sh
|
|
298
|
+
rigor effects update
|
|
299
|
+
```
|
|
300
|
+
|
|
301
|
+
```
|
|
302
|
+
rigor: wrote .rigor-effects.yml (1529 method(s), 0 reach entries)
|
|
303
|
+
rigor: note — `effects.snapshot.reach:` is empty, so the snapshot records `methods:` only (presets registered in this project: rails, rails-channels, rails-controllers, rails-jobs, rails-mailers).
|
|
304
|
+
```
|
|
305
|
+
|
|
306
|
+
The note lists the preset names *your* plugins registered, so the fix is the
|
|
307
|
+
line it already printed: put one of them in `effects.snapshot.reach:`. A project
|
|
308
|
+
whose plugins register none is told that instead.
|
|
309
|
+
|
|
310
|
+
Commit the file. Its header pins the Rigor version, the vocabulary version and a
|
|
311
|
+
digest of your `effects:` block, so an upgrade or a policy edit shows up as a
|
|
312
|
+
*regeneration event* rather than as a silent reinterpretation:
|
|
313
|
+
|
|
314
|
+
```yaml
|
|
315
|
+
# .rigor-effects.yml — generated by `rigor effects update`. Commit it; review its diff.
|
|
316
|
+
schema: 1
|
|
317
|
+
rigor: "0.3.4"
|
|
318
|
+
vocabulary: 1
|
|
319
|
+
config_digest: "44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a"
|
|
320
|
+
methods:
|
|
321
|
+
"Change#init_path":
|
|
322
|
+
effects: ["mutate.self"]
|
|
323
|
+
```
|
|
324
|
+
|
|
325
|
+
Two notes on reading the file:
|
|
326
|
+
|
|
327
|
+
- **Do not read the `unresolved:` arrays.** They are inference-quality metadata,
|
|
328
|
+
they are what makes half the file's bytes, and they churn on Rigor upgrades
|
|
329
|
+
and on unrelated edits. They exist so `check` can print
|
|
330
|
+
`exhaustive → not`. The lines a reviewer reads are `effects:` and `declared:`.
|
|
331
|
+
- Trivial and synthesised entries are left out, as in the report. `--full`
|
|
332
|
+
records everything, and produces a much larger and much noisier file.
|
|
333
|
+
|
|
334
|
+
### `reach:` and entry-point presets
|
|
335
|
+
|
|
336
|
+
**It starts empty, and that is deliberate.** Rigor could guess — on a Rails
|
|
337
|
+
application with `rigor-railties` loaded, `reach: [rails]` is almost certainly
|
|
338
|
+
what you want — but a snapshot is a record you agree to and then review the diff
|
|
339
|
+
of, and one whose contents changed because your plugin list changed would be a
|
|
340
|
+
worse artefact than one you configured. So `rigor effects update` writes the
|
|
341
|
+
direct half, names the presets your plugins actually registered, and leaves the
|
|
342
|
+
same hint in the file:
|
|
343
|
+
|
|
344
|
+
```
|
|
345
|
+
# `reach:` is empty. It records the TRANSITIVE footprint at your entry points —
|
|
346
|
+
# what a controller action or a job causes, rather than what its own body does …
|
|
347
|
+
```
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
Out of the box the snapshot records **no `reach:` table at all** — that is what
|
|
351
|
+
the `0 reach entries` note above is telling you. The framing question the
|
|
352
|
+
feature is usually sold on ("which controllers reach the network, which jobs
|
|
353
|
+
write") needs you to name the entry points:
|
|
354
|
+
|
|
355
|
+
```yaml
|
|
356
|
+
effects:
|
|
357
|
+
snapshot:
|
|
358
|
+
reach: [rails-controllers]
|
|
359
|
+
```
|
|
360
|
+
|
|
361
|
+
Each entry is either a project-relative glob (`**` is the only way across a
|
|
362
|
+
directory boundary) or the name of a **preset** a plugin registered. On Redmine
|
|
363
|
+
that stanza takes the snapshot from 0 to 482 reach entries.
|
|
364
|
+
|
|
365
|
+
A preset name is registered by the plugin that models the framework, so which
|
|
366
|
+
names exist depends on which plugins you listed:
|
|
367
|
+
|
|
368
|
+
| Preset | Registered by | Covers |
|
|
369
|
+
| --- | --- | --- |
|
|
370
|
+
| `rails` | `rigor-railties` | `app/controllers/**`, `app/jobs/**`, `app/mailers/**`, `app/channels/**` |
|
|
371
|
+
| `rails-controllers` | `rigor-actionpack` | `app/controllers/**/*.rb` |
|
|
372
|
+
| `rails-jobs` | `rigor-activejob` | `app/jobs/**/*.rb` |
|
|
373
|
+
| `rails-mailers` | `rigor-actionmailer` | `app/mailers/**/*.rb` |
|
|
374
|
+
| `rails-channels` | `rigor-actioncable` | `app/channels/**/*.rb` |
|
|
375
|
+
|
|
376
|
+
`reach: [rails]` is the one you want on a Rails app — but it needs
|
|
377
|
+
`rigor-railties` in `plugins:`, and naming a preset nothing registered is an
|
|
378
|
+
error when the snapshot is built rather than when the config loads (the plugins
|
|
379
|
+
that register presets load *from* that config). If you get that error, the plugin
|
|
380
|
+
list is what to fix.
|
|
381
|
+
|
|
382
|
+
## The review loop
|
|
383
|
+
|
|
384
|
+
This is the part that pays. Someone adds an audit-log write to a model:
|
|
385
|
+
|
|
386
|
+
```rb
|
|
387
|
+
def init_path
|
|
388
|
+
self.path ||= ""
|
|
389
|
+
File.open(Rails.root.join("log", "change_audit.log"), "a") do |f|
|
|
390
|
+
f.puts("#{Time.now.utc.iso8601} #{changeset_id} #{path}")
|
|
391
|
+
end
|
|
392
|
+
end
|
|
393
|
+
```
|
|
394
|
+
|
|
395
|
+
CI runs `rigor effects check` and fails, exit 1:
|
|
396
|
+
|
|
397
|
+
```
|
|
398
|
+
Effect drift against .rigor-effects.yml:
|
|
399
|
+
|
|
400
|
+
methods:
|
|
401
|
+
Change#init_path + io.fs.write
|
|
402
|
+
Change#init_path + nondet.time
|
|
403
|
+
Change#init_path exhaustive → not
|
|
404
|
+
|
|
405
|
+
Run `rigor effects update` and commit the result if this change is intended.
|
|
406
|
+
```
|
|
407
|
+
|
|
408
|
+
`+ label` and `- label` are the proven lane; `≤+` / `≤-` the declared one;
|
|
409
|
+
`materialised` means a declared label became proven; `exhaustive → not` means
|
|
410
|
+
someone introduced a call Rigor cannot follow; `+symbol` / `-symbol` are methods
|
|
411
|
+
that appeared or vanished, and a rename is one of each.
|
|
412
|
+
|
|
413
|
+
Before regenerating, ask why:
|
|
414
|
+
|
|
415
|
+
```sh
|
|
416
|
+
rigor effects explain
|
|
417
|
+
```
|
|
418
|
+
|
|
419
|
+
```
|
|
420
|
+
methods:
|
|
421
|
+
Change#init_path [io.fs.write] ← catalogue:File#puts
|
|
422
|
+
Change#init_path [nondet.time] ← catalogue:Time.now
|
|
423
|
+
```
|
|
424
|
+
|
|
425
|
+
`--symbol KEY` explains one unit instead of the changed ones, and prints its
|
|
426
|
+
reach path as well:
|
|
427
|
+
|
|
428
|
+
```
|
|
429
|
+
$ rigor effects explain --symbol "Change#init_path"
|
|
430
|
+
reach:
|
|
431
|
+
Change#init_path → File#puts [io.fs.write]
|
|
432
|
+
Change#init_path → receiver-mutation [mutate.self]
|
|
433
|
+
Change#init_path → Time.now [nondet.time]
|
|
434
|
+
methods:
|
|
435
|
+
Change#init_path [io.fs.write] ← catalogue:File#puts
|
|
436
|
+
Change#init_path [mutate.self] ← construct:receiver-mutation
|
|
437
|
+
Change#init_path [nondet.time] ← catalogue:Time.now
|
|
438
|
+
```
|
|
439
|
+
|
|
440
|
+
Then `rigor effects update` and commit the regenerated file alongside the code
|
|
441
|
+
change. **Intent is expressed by committing the snapshot**, not by annotating
|
|
442
|
+
the code: the reviewer reads a three-line diff next to the change that caused it
|
|
443
|
+
and either nods or pushes back. A bundle update that moves effects with no code
|
|
444
|
+
diff of its own goes through exactly the same gate, and is the case most worth
|
|
445
|
+
seeing.
|
|
446
|
+
|
|
447
|
+
`rigor effects diff` prints the identical comparison and always exits 0 — use it
|
|
448
|
+
locally, or in a reporting job you do not want to gate.
|
|
449
|
+
|
|
450
|
+
## In CI
|
|
451
|
+
|
|
452
|
+
Add one step to [the Rigor job](11-ci.md). It is a separate step from
|
|
453
|
+
`rigor check` because it answers a separate question and fails for separate
|
|
454
|
+
reasons:
|
|
455
|
+
|
|
456
|
+
```yaml
|
|
457
|
+
# .github/workflows/rigor.yml
|
|
458
|
+
- run: gem install rigortype
|
|
459
|
+
- run: rigor check
|
|
460
|
+
- run: rigor effects check
|
|
461
|
+
```
|
|
462
|
+
|
|
463
|
+
Exit codes, all verified unpiped:
|
|
464
|
+
|
|
465
|
+
| Situation | `effects check` |
|
|
466
|
+
| --- | --- |
|
|
467
|
+
| Snapshot matches | `0` |
|
|
468
|
+
| Any drift under `gate: symmetric` (the default) | `1` |
|
|
469
|
+
| Growth only, under `gate: additions` | `1` on additions, `0` on removals |
|
|
470
|
+
| Difference confined to `effects.tolerated:` labels | `0`, printed under a `tolerated:` heading — `--strict-tolerated` makes it `1` |
|
|
471
|
+
| `.rigor-effects.yml` missing from the checkout | `1`, under a `snapshot:` heading telling you to run `rigor effects update` |
|
|
472
|
+
|
|
473
|
+
That last row is the one that saves you: someone who edits code and forgets to
|
|
474
|
+
regenerate the snapshot, and someone who never committed one, both fail the same
|
|
475
|
+
way.
|
|
476
|
+
|
|
477
|
+
**Reviewing against the base branch.** `--baseline` compares against a file
|
|
478
|
+
other than the configured one, which is how a bot reports "what this branch
|
|
479
|
+
changes" without regard to how stale `master` is:
|
|
480
|
+
|
|
481
|
+
```sh
|
|
482
|
+
rigor effects diff --baseline <(git show origin/main:.rigor-effects.yml)
|
|
483
|
+
```
|
|
484
|
+
|
|
485
|
+
`diff` never gates, so this is safe as a comment-posting step; swap in `check`
|
|
486
|
+
if you want it to fail. `--format=json` is accepted by every subcommand if you
|
|
487
|
+
would rather post structured output.
|
|
488
|
+
|
|
489
|
+
**`--no-tolerated-effects`** re-judges as if `effects.tolerated:` were empty. It
|
|
490
|
+
is the audit switch for your own policy — worth a scheduled non-gating job, so
|
|
491
|
+
that what you decided to stop looking at is still visible once a week. The run
|
|
492
|
+
itself is identical either way, so it never costs a re-analysis.
|
|
493
|
+
|
|
494
|
+
## Declaring what a method may do
|
|
495
|
+
|
|
496
|
+
Everything so far observes. This section asserts — and is the only part of the
|
|
497
|
+
feature that produces a `rigor check` diagnostic. Do it last, once the report
|
|
498
|
+
has told you what your code actually does.
|
|
499
|
+
|
|
500
|
+
### Envelopes by convention
|
|
501
|
+
|
|
502
|
+
The cheapest bound needs no signatures at all. One stanza bounds a whole
|
|
503
|
+
architectural layer:
|
|
504
|
+
|
|
505
|
+
```yaml
|
|
506
|
+
effects:
|
|
507
|
+
envelopes:
|
|
508
|
+
- match: "app/helpers/**/*.rb" # a helper builds strings
|
|
509
|
+
effect: []
|
|
510
|
+
```
|
|
511
|
+
|
|
512
|
+
Each stanza names exactly one of `match:` (a path glob over the files a class is
|
|
513
|
+
defined in) or `namespace:` (a constant glob), plus `effect:` — the labels those
|
|
514
|
+
classes may perform, `[]` for pure. Nearest wins: a per-method annotation beats
|
|
515
|
+
a class-level one, which beats a stanza; among stanzas the first match wins.
|
|
516
|
+
|
|
517
|
+
A method that exceeds its bound gets one diagnostic per (method, label) pair, at
|
|
518
|
+
its `def`, naming the route:
|
|
519
|
+
|
|
520
|
+
```
|
|
521
|
+
app/helpers/application_helper.rb:59:1: warning: Method ApplicationHelper#link_to_principal performs io.fs.read (Dir.glob via IconsHelper#principal_icon → IconsHelper#sprite_icon → IconsHelper#sprite_source → Redmine::Themes::Helper#current_theme → Redmine::Themes.theme → Redmine::Themes.themes → Redmine::Themes.scan_themes), but is declared effect: [] at .rigor.yml effects.envelopes[0], so io.fs.read exceeds the envelope.
|
|
522
|
+
```
|
|
523
|
+
|
|
524
|
+
**Budget for a big first number.** That one stanza, on Redmine, is **343
|
|
525
|
+
warnings across 18 files** — 91 `mutate.self`, 86 `mutate.static`, 83
|
|
526
|
+
`io.fs.read`, 38 `mutate.instance`, 16 `global.read`, 13 `io.output.stderr`,
|
|
527
|
+
13 `exit`, 3 `nondet.time`.
|
|
528
|
+
|
|
529
|
+
That is not a failure of the stanza — it is the layer telling you what it really
|
|
530
|
+
does, and a helper reaching a theme scanner eight hops down is a genuine
|
|
531
|
+
finding. But 343 is not a work list. Work down in this order:
|
|
532
|
+
|
|
533
|
+
1. **Read before you bound.** `rigor effects app/helpers` (or the whole report,
|
|
534
|
+
filtered) shows you the same facts without any diagnostics. Write the stanza
|
|
535
|
+
you mean rather than the aspirational one.
|
|
536
|
+
2. **Discharge whole categories with `tolerated:`.** Adding
|
|
537
|
+
`tolerated: [mutate.self]` takes the 343 to **252** — every warning whose
|
|
538
|
+
origin was a receiver mutation, and nothing else.
|
|
539
|
+
3. **Narrow the stanza**, or carve out the deliberate exception with a tighter
|
|
540
|
+
envelope on the one method. `except:` does not exist and is not needed:
|
|
541
|
+
nearest wins.
|
|
542
|
+
|
|
543
|
+
Discharge works **per origin**, not per label. `Logger#info` carries `io` and
|
|
544
|
+
`telemetry` together, so `tolerated: [telemetry]` frees the `io` that came with
|
|
545
|
+
the logging and leaves an `io.fs.read` from a `File.read` two lines down exactly
|
|
546
|
+
where it was. An added label is discharged only when every origin that
|
|
547
|
+
introduced it is discharged.
|
|
548
|
+
|
|
549
|
+
### Annotations on one method
|
|
550
|
+
|
|
551
|
+
Where a stanza is too coarse, bound one method. Two annotations do it —
|
|
552
|
+
`%a{pure}`, rbs' own purity annotation, read as "nothing at all", and
|
|
553
|
+
`%a{rigor:v1:effect <labels>}`, a comma-separated list of bare labels the method
|
|
554
|
+
may not exceed. Both attach to a method or to a `class` / `module`, where they
|
|
555
|
+
distribute to that class's own methods. Full syntax is in
|
|
556
|
+
[RBS::Extended annotations](16-rbs-extended-annotations.md) § *Effect
|
|
557
|
+
envelopes*; two things that surprise people belong here instead:
|
|
558
|
+
|
|
559
|
+
- **Hanging an envelope on one method in `.rbs` costs you its whole signature.**
|
|
560
|
+
RBS has no way to annotate a method without declaring it, so you end up
|
|
561
|
+
writing `def init_path: () -> untyped` purely to carry the annotation. The
|
|
562
|
+
rbs-inline form (`# @rbs %a{pure}` above the `def`, in the `.rb` file) does
|
|
563
|
+
not have that problem, and is the better lane when the bound is all you want.
|
|
564
|
+
- **A declared label never makes a diagnostic fire.** A method whose report row
|
|
565
|
+
reads `[] ≤ [global.read, rails.i18n.translate]` passes `%a{pure}` in silence,
|
|
566
|
+
because the `≤` lane is a claim and a claim must never manufacture a finding.
|
|
567
|
+
Correct, and thoroughly counter-intuitive at the point of use — if you
|
|
568
|
+
annotate a method and nothing happens, check which lane its labels are in.
|
|
569
|
+
|
|
570
|
+
A misspelled label makes the **whole** annotation read as unbounded, so a typo
|
|
571
|
+
can never manufacture a finding either. Where the spelling is evidently meant to
|
|
572
|
+
be a label, Rigor says so:
|
|
573
|
+
|
|
574
|
+
```
|
|
575
|
+
sig/slug.rbs:2:1: info: Effect envelope on Slug#load names io.bd.read, which is not a known effect label (did you mean io.db.read?); the annotation now bounds nothing.
|
|
576
|
+
```
|
|
577
|
+
|
|
578
|
+
### `attribution:` for gems nobody has written a plugin for
|
|
579
|
+
|
|
580
|
+
```yaml
|
|
581
|
+
effects:
|
|
582
|
+
attribution:
|
|
583
|
+
"Acme::Http.get": [io.net.http]
|
|
584
|
+
"Acme::Metrics.count": [telemetry]
|
|
585
|
+
```
|
|
586
|
+
|
|
587
|
+
Keys are method keys — `Owner#instance_method` or `Owner.singleton_method`, and
|
|
588
|
+
anything else is a load error. The labels land in the **declared** lane, never
|
|
589
|
+
the proven one, so an attribution can never make a diagnostic fire; the call
|
|
590
|
+
still counts as unresolved, because you told Rigor what that code does and Rigor
|
|
591
|
+
did not read it.
|
|
592
|
+
|
|
593
|
+
Check whether a plugin already covers the gem before writing rows by hand — on a
|
|
594
|
+
framework application the plugins supply most of the declared lane, and a table
|
|
595
|
+
that duplicates them is a table you have to maintain.
|
|
596
|
+
|
|
597
|
+
### What a bound can and cannot see
|
|
598
|
+
|
|
599
|
+
A bound is checked against the method's **proven** labels — the ones Rigor got by
|
|
600
|
+
reading code. It is never checked against the `≤` lane, and on a Rails
|
|
601
|
+
application that distinction decides almost everything.
|
|
602
|
+
|
|
603
|
+
Take a serializer under a `effect: []` stanza that ends up calling
|
|
604
|
+
`UserRole.create!`:
|
|
605
|
+
|
|
606
|
+
```
|
|
607
|
+
app/serializers/rest/v1/instance_serializer.rb:89:1: warning: Method
|
|
608
|
+
REST::V1::InstanceSerializer#invites_enabled performs mutate.self
|
|
609
|
+
(receiver-mutation via UserRole.everyone → UserRole.create! → UserRole#set_position),
|
|
610
|
+
but is declared effect: [] at .rigor.yml effects.envelopes[0], so mutate.self
|
|
611
|
+
exceeds the envelope.
|
|
612
|
+
```
|
|
613
|
+
|
|
614
|
+
Rigor walked *through* the database write and reported the ivar assignment beyond
|
|
615
|
+
it. The row for that method says `≤ [io.db.read, io.db.write]`, so the write is
|
|
616
|
+
not hidden — it is in the lane a bound may not read, because `io.db.write` there
|
|
617
|
+
is what `rigor-activerecord` says `create!` does, not something Rigor saw. A
|
|
618
|
+
claim about code the analyzer never read must not be able to fail your build; if
|
|
619
|
+
it could, every plugin upgrade would be a build risk.
|
|
620
|
+
|
|
621
|
+
So on a Rails application:
|
|
622
|
+
|
|
623
|
+
- **`mutate.*`, `io.fs.*`, `io.net`, `nondet.*`, `global.*`, `exit`** are proven
|
|
624
|
+
from your own code. A bound on these fires.
|
|
625
|
+
- **`io.db.*`, `cache.*`, `telemetry`, `email.send`, `job.enqueue`, every
|
|
626
|
+
`rails.*`**, and anything you write in `effects.attribution:`, are declared. A
|
|
627
|
+
bound naming them is satisfied vacuously.
|
|
628
|
+
|
|
629
|
+
**The enforcement path for the second group is the snapshot.** `rigor effects
|
|
630
|
+
check` diffs both lanes and marks a declared-lane addition with `≤+`:
|
|
631
|
+
|
|
632
|
+
```
|
|
633
|
+
reach:
|
|
634
|
+
IssuesController#index + io.net
|
|
635
|
+
IssuesController#index ≤+ io.db.write
|
|
636
|
+
```
|
|
637
|
+
|
|
638
|
+
That is how you enforce "the issue list must not start writing to the database":
|
|
639
|
+
commit the snapshot, review the diff, and let `rigor effects check` fail the
|
|
640
|
+
build when a `≤+` appears where you did not want one. It is a ratchet on
|
|
641
|
+
observed state rather than a declared policy — which is why `effects.snapshot.gate`
|
|
642
|
+
exists, and why this chapter puts the snapshot before this section.
|
|
643
|
+
|
|
644
|
+
The rule and the evidence behind it are
|
|
645
|
+
[ADR-103](../adr/103-effect-labels.md) § WD17.
|
|
646
|
+
|
|
647
|
+
## The diagnostics
|
|
648
|
+
|
|
649
|
+
Four rules. Three of them need an `effects:` block; the fourth exists to tell
|
|
650
|
+
you that you have not written one. The default text output does not print rule
|
|
651
|
+
IDs — `--format json` carries the `rule` field, and `rigor explain <rule>`
|
|
652
|
+
prints the catalogue entry for any of them.
|
|
653
|
+
|
|
654
|
+
| Rule | Fires when | Severity |
|
|
655
|
+
| --- | --- | --- |
|
|
656
|
+
| [`effect.envelope-exceeded`](04-diagnostics.md#rule-effect-envelope-exceeded) | A method's proven labels are not covered by the envelope declared on it, on its class, or by a stanza | `warning` |
|
|
657
|
+
| [`effect.liskov-widened`](04-diagnostics.md#rule-effect-liskov-widened) | An override escapes the envelope written on the method it overrides — an implementation may be purer than the bound it inherits, never less pure | `warning` |
|
|
658
|
+
| [`effect.unknown-label`](04-diagnostics.md#rule-effect-unknown-label) | A declaration names a label the registry does not know, so the whole tag reads as unbounded | `info` |
|
|
659
|
+
| [`effect.annotations-unchecked`](04-diagnostics.md#rule-effect-annotations-unchecked) | Your signatures carry envelopes but `.rigor.yml` has no `effects:` block | `info` |
|
|
660
|
+
|
|
661
|
+
They take `disable:` and `severity_overrides:` by ID like any other rule
|
|
662
|
+
([Diagnostics](04-diagnostics.md)). `# rigor:disable` comments are not read out
|
|
663
|
+
of `.rbs` or `.rigor.yml`, so for a declaration site use `disable:` or the
|
|
664
|
+
baseline. Setting `effects.check: false` keeps the report and the snapshot while
|
|
665
|
+
silencing the first three.
|
|
666
|
+
|
|
667
|
+
Unproven effects never fire any of them. "Possibly more" is not evidence, and a
|
|
668
|
+
check that fired on a shrug would teach you to route around it. Nor does a
|
|
669
|
+
declared (`≤`) label fire one — see
|
|
670
|
+
[What a bound can and cannot see](#what-a-bound-can-and-cannot-see) for which
|
|
671
|
+
labels that rules out and where to enforce them instead.
|
data/docs/manual/README.md
CHANGED
|
@@ -65,6 +65,11 @@ flag, key, or command that *acts* on it.
|
|
|
65
65
|
`rigor unused` as a campaign on an old codebase: why the list
|
|
66
66
|
starts long, which lever actually shortens it, what to confirm
|
|
67
67
|
before deleting, and how to make the case to a reviewer.
|
|
68
|
+
19. [Effect labels](19-effect-labels.md) — what your code *does*
|
|
69
|
+
beside what it returns: the label vocabulary, the `rigor
|
|
70
|
+
effects` report, the committed `.rigor-effects.yml` snapshot
|
|
71
|
+
and its review loop, the CI gate, and the envelopes and
|
|
72
|
+
`%a{pure}` annotations that bound a layer.
|
|
68
73
|
|
|
69
74
|
### Integration and operations
|
|
70
75
|
|