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.
Files changed (56) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +1 -1
  3. data/data/effects/core.yml +18 -1
  4. data/data/effects/registry.yml +31 -3
  5. data/docs/manual/02-cli-reference.md +60 -13
  6. data/docs/manual/03-configuration.md +20 -3
  7. data/docs/manual/04-diagnostics.md +3 -2
  8. data/docs/manual/11-ci.md +37 -0
  9. data/docs/manual/12-caching.md +39 -0
  10. data/docs/manual/16-rbs-extended-annotations.md +15 -2
  11. data/docs/manual/19-effect-labels.md +671 -0
  12. data/docs/manual/README.md +5 -0
  13. data/docs/manual/ci-templates/README.md +9 -0
  14. data/lib/rigor/analysis/rule_catalog.rb +10 -3
  15. data/lib/rigor/analysis/run_cache_probe.rb +69 -1
  16. data/lib/rigor/analysis/runner/declaration_position.rb +8 -24
  17. data/lib/rigor/analysis/runner/effect_annotation_residual_pass.rb +28 -28
  18. data/lib/rigor/analysis/runner/effect_envelope_pass.rb +1 -1
  19. data/lib/rigor/analysis/runner/pool_coordinator.rb +25 -0
  20. data/lib/rigor/analysis/runner/run_snapshots.rb +7 -4
  21. data/lib/rigor/analysis/runner.rb +12 -3
  22. data/lib/rigor/analysis/worker_session.rb +3 -1
  23. data/lib/rigor/cli/effects_command.rb +123 -9
  24. data/lib/rigor/cli/effects_diff_renderer.rb +5 -1
  25. data/lib/rigor/cli/effects_renderer.rb +41 -3
  26. data/lib/rigor/cli/effects_report.rb +116 -6
  27. data/lib/rigor/cli/effects_snapshot_command.rb +23 -4
  28. data/lib/rigor/cli.rb +12 -1
  29. data/lib/rigor/configuration.rb +37 -25
  30. data/lib/rigor/configuration_error.rb +20 -0
  31. data/lib/rigor/effects/collector.rb +38 -1
  32. data/lib/rigor/effects/entry_points.rb +47 -1
  33. data/lib/rigor/effects/file_collection.rb +18 -4
  34. data/lib/rigor/effects/framework_units.rb +68 -13
  35. data/lib/rigor/effects/inline_anchor.rb +134 -0
  36. data/lib/rigor/effects/plugin_facts.rb +62 -13
  37. data/lib/rigor/effects/propagator.rb +79 -19
  38. data/lib/rigor/effects/registry.rb +10 -3
  39. data/lib/rigor/effects/scanner.rb +21 -9
  40. data/lib/rigor/effects/signature_sources.rb +16 -0
  41. data/lib/rigor/effects/snapshot.rb +21 -5
  42. data/lib/rigor/effects/taint_cause.rb +1 -0
  43. data/lib/rigor/effects/unit_scan.rb +87 -12
  44. data/lib/rigor/plugin/base.rb +4 -0
  45. data/lib/rigor/plugin/box.rb +18 -2
  46. data/lib/rigor/plugin/effect_ancestry.rb +80 -0
  47. data/lib/rigor/plugin/manifest.rb +34 -10
  48. data/lib/rigor/plugin/registry.rb +10 -3
  49. data/lib/rigor/rbs_extended.rb +22 -2
  50. data/lib/rigor/version.rb +1 -1
  51. data/plugins/rigor-activesupport-core-ext/sig/active_support/core_ext.rbs +17 -2
  52. data/plugins/rigor-devise/lib/rigor/plugin/devise.rb +27 -0
  53. data/plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline.rb +56 -1
  54. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/effects.rb +75 -0
  55. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq.rb +13 -1
  56. 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.
@@ -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