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
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ffcad04b990dc4220690a6906bb96306056a02792017fcf41200b8865c040846
4
- data.tar.gz: b62aa8767389da9ddd808df2a74d09447aee4d5a9c0deb0d7d4292da5ff7669b
3
+ metadata.gz: cc7f03a32a104c1c3ed00374974fc9af408c84e1c4d19dbb9ad8dc863f0ea0c4
4
+ data.tar.gz: 6c7f1fd0b81526c436731ac6c4710c0937bd00043570b6db5e9d59d6973f7872
5
5
  SHA512:
6
- metadata.gz: f55c7fb8b035ba5364d97163f6ae66e1212d544d5fa2821110a230818be1f64bd2d803ff88739af394d57c39a3bb6ed77737451a68f1cfb047276e23c6ede98a
7
- data.tar.gz: 85aafda9ac9c46b73757c5690484d97d77b1dea26cc499252d39a24aefbd9f58fcf0dfda759a11e7dea553b4d8fac3ae83db9a165e1e926ff62a8fdecad78967
6
+ metadata.gz: 492d3d04d9bce6672ad46885d2eda1234d4f4a30f41b3168244f794754f37fa1a3d0edfc88e425fc2b0582dd09b5f45afee312189c2099c8101140db798eec0a
7
+ data.tar.gz: 8fd06bd7e9af340ba29019c84bd913934c77b094987e5c52be131351bc60c52e1a8a28d777d4a417ac96480a42cf110236346a1a4f62aa5d54f4a5f50d0c8e5b
data/README.md CHANGED
@@ -231,7 +231,7 @@ rigor docs --list # list every bundled page
231
231
 
232
232
  ## Status
233
233
 
234
- Current release: **`v0.3.4`** (2026-08-21) — on the
234
+ Current release: **`v0.3.5`** (2026-08-25) — on the
235
235
  `0.3.x` line, continuing the evaluation era opened by `v0.2.0`, the first
236
236
  publicly-announced (general / evaluation) release. The line publishes
237
237
  an enumerated [compatibility surface](docs/compatibility.md) as a
@@ -531,7 +531,19 @@ classes:
531
531
  # ---------------------------------------------------------------------------------------------
532
532
  # Network.
533
533
  # ---------------------------------------------------------------------------------------------
534
- Socket: { posture: net, why: "the BSD socket surface" }
534
+ Socket:
535
+ posture: net
536
+ why: "the BSD socket surface; the rows below read the machine's own identity and send nothing"
537
+ singleton_methods:
538
+ # `io.net` on these was a wrong label, not a conservative one, and it travelled: Redmine builds
539
+ # its Message-IDs from `Socket.gethostname`, so 215 of 4,234 rows — every model `save`, every
540
+ # `Mailer#*`, and `Redmine::IMAP.check`, whose actual IMAP connection is invisible — read as
541
+ # network traffic on the strength of a hostname lookup (#458). `io` is the honest bound: the
542
+ # call leaves the process to ask the kernel, and nothing goes over a wire.
543
+ gethostname: { effects: [io], why: "reads this machine's own name; no packet leaves the process" }
544
+ gethostbyname: { effects: [io.net], why: "resolves a name — the resolver, and possibly DNS" }
545
+ getifaddrs: { effects: [io], why: "reads this machine's own interfaces; local configuration, not resolution" }
546
+ ip_address_list: { effects: [io], why: "reads this machine's own addresses; local configuration, not resolution" }
535
547
  BasicSocket: { posture: net, why: "the shared socket surface" }
536
548
  TCPSocket: { posture: net, why: "a TCP stream" }
537
549
  TCPServer: { posture: net, why: "a listening TCP socket" }
@@ -572,6 +584,11 @@ classes:
572
584
  start: { effects: [io.net.http], why: "opens the connection; the block joins by containment" }
573
585
  new: { effects: [], why: "allocates the client; nothing is sent until start / request" }
574
586
  "Net::SMTP": { posture: net, why: "an SMTP session; `email.send` is the plugin's semantic layer, not the transport's" }
587
+ # rbs ships `net-http` / `net-smtp` and not these, so a project's `Net::IMAP` types as `Dynamic` and
588
+ # only the constant the author wrote names the class. #463 is what lets a posture answer there; before
589
+ # it, `Net::FTP` had a row that could never fire and these two were not worth writing.
590
+ "Net::IMAP": { posture: net, why: "an IMAP session" }
591
+ "Net::POP3": { posture: net, why: "a POP3 session" }
575
592
  "Net::FTP": { posture: net, why: "an FTP session" }
576
593
 
577
594
  URI:
@@ -11,6 +11,25 @@
11
11
  # still reads.
12
12
  vocabulary: 1
13
13
 
14
+ # What each root means, in one line. Read by `rigor effects --list-labels` (#429), which is the only
15
+ # way an installed Rigor can tell a reader what to write in `effects.envelopes:`, `effects.tolerated:`,
16
+ # `effects.attribution:` or an `%a{rigor:v1:effect …}` annotation — four surfaces that all require
17
+ # typing a label. `docs/manual/19-effect-labels.md`'s vocabulary table is the same wording for the same
18
+ # reason; a root without a line here simply prints without one, which is what a plugin-opened root does.
19
+ descriptions:
20
+ io: talks to something outside the process — a file, a socket, a database, a terminal, a subprocess
21
+ mutate: changes state in place, in the frame's own objects, the receiver, another object, or the class
22
+ nondet: reads something that differs between two otherwise identical runs
23
+ global: process-wide state — ENV, $stdout, a class variable
24
+ exit: can end the process
25
+ ffi: calls out through a foreign-function interface
26
+ telemetry: reports on the program rather than doing its work — logs, metrics, traces
27
+ email: sends mail, whatever the transport underneath is
28
+ job: hands work to a queue for another process to run
29
+ cache: reads or writes a cache, whatever backs it
30
+ failure: registered so a policy written for Steins, the sibling PHP analyzer, parses here; Rigor never
31
+ produces one, so a bound naming it is satisfied vacuously
32
+
14
33
  labels:
15
34
  # Steins v1, verbatim (rigortype/steins `docs/type-specification/effects.md`). Shared vocabulary:
16
35
  # a policy naming these labels must read the same against a PHP service and a Rails app.
@@ -53,9 +72,18 @@ labels:
53
72
  - io.db.write
54
73
  - io.db.transaction
55
74
 
56
- # Application-meaning roots, small and shared. These are the labels a policy actually names
57
- # ("presenters do not enqueue jobs") and the ones `tolerated:` grips, so they must spell the same
58
- # in Steins and Rigor.
75
+ # Steins carries these (its ADR-0042) and Rigor does not produce them: they name a failure arm's
76
+ # value provenance rather than an effect. Registered for the same reason `io.output.buffer` is
77
+ # so a policy written against Steins parses here — and never inferred (ADR-103 WD16).
78
+ - failure
79
+ - failure.environment
80
+ - failure.input
81
+ - failure.resource
82
+
83
+ # Application-meaning roots. These are the labels a policy actually names ("presenters do not
84
+ # enqueue jobs") and the ones `tolerated:` grips, so a project must not need a plugin before it
85
+ # can write one. Rigor-owned and proposed to Steins, which holds ecosystem labels outside its
86
+ # builtin set and supplies them through a plugin manifest (ADR-103 WD16).
59
87
  - telemetry
60
88
  - email.send
61
89
  - job.enqueue
@@ -169,7 +169,10 @@ Report what each method *does* — its effect labels — rather
169
169
  than what it returns, and manage the committed **effect
170
170
  snapshot** that gates drift. Opt-in and observational: nothing
171
171
  here emits a diagnostic or changes `rigor check`'s output, and
172
- only `rigor effects check` ever exits non-zero.
172
+ only `rigor effects check` ever exits non-zero. The workflow
173
+ around these verbs — the label vocabulary, how to read the
174
+ report at scale, the review loop and the CI step — is
175
+ [Effect labels](19-effect-labels.md).
173
176
 
174
177
  ```sh
175
178
  rigor effects [PATH...] # the report
@@ -183,6 +186,13 @@ configuring anything; such an ad-hoc run shares no cache with
183
186
  `rigor check`, because a run served from that cache would have
184
187
  collected nothing.
185
188
 
189
+ Passing paths narrows the **analysis**, not just the output.
190
+ Labels are transitive over what was analysed, so a method whose
191
+ callees were not analysed reports fewer labels and a wider
192
+ "possibly more" than the same method in a whole-project run —
193
+ honest, but not a cheaper way to get the whole-project answer.
194
+ Filter the output when you want the real footprint.
195
+
186
196
  Each line is one method, sorted by key:
187
197
 
188
198
  ```
@@ -199,10 +209,13 @@ lines say why. A taint is never a finding.
199
209
 
200
210
  A ` ≤ [...]` clause after the labels is the **declared** lane:
201
211
  what a source Rigor trusts but did not verify *claims* the
202
- method does, today the `effects.attribution:` table you wrote
203
- for gem methods it cannot see. It is printed apart from the
204
- proven labels and never folded in among them, because the two
205
- answer different questions. It follows call edges exactly as
212
+ method does. Two sources feed it — the plugins you activated,
213
+ which on a framework application contribute most of it
214
+ (`rails.session.write` and the rest of `rails.*` are Action
215
+ Pack's rows, not yours), and the `effects.attribution:` table
216
+ you wrote for gem methods nothing models. It is printed apart
217
+ from the proven labels and never folded in among them, because
218
+ the two answer different questions. It follows call edges exactly as
206
219
  the proven labels do, so a controller two hops above an
207
220
  attributed gem call carries the claim rather than only a
208
221
  "possibly more"; a declared label the proven list already
@@ -213,10 +226,30 @@ Gateways::Client#fetch: [] ≤ [io.net.http] …?
213
226
  plugin-attribution (Acme::Http.get)
214
227
  ```
215
228
 
216
- A method is omitted when it is exhaustive and proves nothing
217
- beyond `mutate.local` — mutation of objects its own frame
218
- allocated and never let out, which every effect envelope
219
- tolerates. `--full` lists every method instead.
229
+ Two kinds of method are omitted: one that proves nothing
230
+ beyond `mutate.local` and claims nothing — mutation of objects
231
+ its own frame allocated and never let out, which every effect
232
+ envelope tolerates and one with no label in either lane,
233
+ which exists only to record that something below it was
234
+ unresolved. `--full` lists both. `--pure` asks for the first
235
+ group by name, which is the set worth annotating `%a{pure}`.
236
+
237
+ `--label=LABEL` prints only the methods carrying `LABEL` or a
238
+ label under it, in either lane. `--limit=N` caps the rows.
239
+ `--why` expands each row's unresolved reasons and the plugin
240
+ row behind each declared label, which are collapsed to a count
241
+ by default. The report closes with a footer counting the two
242
+ lanes apart, because they have different powers: a proven label
243
+ can fail a build and a declared one cannot
244
+ ([ADR-103](../adr/103-effect-labels.md) § WD17).
245
+
246
+ A `PATH` argument selects which methods are **printed**, never
247
+ which are analysed: Rigor analyses your configured `paths:`
248
+ either way, so a selected row carries exactly the labels the
249
+ whole-project run gives it, and a note on stderr says how many
250
+ of how many you are looking at. A path outside `paths:` is
251
+ analysed as well as them, so pointing the command at a tree
252
+ your configuration does not cover still works.
220
253
 
221
254
  `--format=text|json` selects the output format; the JSON
222
255
  payload additionally carries each method's *direct* summary
@@ -226,9 +259,15 @@ subcommands and does nothing here: the report is an
226
259
  observation, and observations are undischarged.
227
260
 
228
261
  What is collected and how it propagates is
229
- [the effect-summaries internal spec](../internal-spec/effect-summaries.md);
230
- the label vocabulary is
231
- [the effect-labels specification](../type-specification/effect-labels.md).
262
+ [the effect-summaries internal spec](../internal-spec/effect-summaries.md).
263
+ `--list-labels` prints the vocabulary this project can name —
264
+ every shipped label with its root's meaning, plus whatever your
265
+ plugins and your `effects.labels:` opened — and exits without
266
+ analysing anything. The same table is in
267
+ [Effect labels § The label vocabulary](19-effect-labels.md), and
268
+ the grammar is specified normatively in the effect-labels
269
+ specification, which the gem does not ship:
270
+ <https://rigor.typedduck.fail/type-specification/effect-labels/>.
232
271
 
233
272
  ### The effect snapshot
234
273
 
@@ -1069,7 +1108,15 @@ diagnostics about Rigor's own inference cutoffs and memory — see
1069
1108
  | --- | --- |
1070
1109
  | `0` | Success — no error-severity diagnostics. |
1071
1110
  | `1` | Diagnostics found, or a per-command failure (parse error, missing file, new diagnostics on `diff`, effect drift on `effects check`). |
1072
- | `64` | Usage error — unknown command, bad flag, malformed argument. |
1111
+ | `64` | Usage error — unknown command, bad flag, malformed argument, or a value in `.rigor.yml` the loader cannot proceed on. |
1073
1112
 
1074
1113
  `rigor triage` is the exception: it is advisory and always
1075
1114
  exits `0`.
1115
+
1116
+ A configuration mistake prints one `rigor:` line naming the key
1117
+ and nothing else — no backtrace, on every command:
1118
+
1119
+ ```
1120
+ $ rigor effects update
1121
+ rigor: effects.attribution key is not a method key (`Owner#method` / `Owner.method`): "Net::HTTP get"
1122
+ ```
@@ -176,12 +176,15 @@ explicitly with `bundler.bundle_path:`, or supply signatures another way:
176
176
 
177
177
  ### Effect labels
178
178
 
179
+ The key reference is below; the workflow these keys serve — the vocabulary, the report, the committed
180
+ snapshot, the CI gate — is [Effect labels](19-effect-labels.md).
181
+
179
182
  | Key | Type | Default | Meaning |
180
183
  | --- | --- | --- | --- |
181
184
  | `effects` | Hash | absent | **Opt-in to effect labels ([ADR-103](../adr/103-effect-labels.md)).** The *presence* of this block is the switch — `effects: {}` enables collection with every sub-key at its default, and leaving it out keeps `rigor check` byte-identical and free. Nothing else turns collection on: an `%a{pure}` or `%a{rigor:v1:effect …}` annotation in your RBS does not, because an annotation must not silently make every run more expensive — such a project gets one `effect.annotations-unchecked` `:info` per run instead, saying the annotations are inert. `rigor effects` runs under an implicit empty block when the key is absent, so you can try the report before configuring anything. Effect summaries are cached under their own identity (Rigor's effect vocabulary, its built-in catalogue and this block), so `rigor effects` after `rigor check` in the same job is a cache hit plus the propagation, and turning this block on or off does not invalidate your diagnostics cache. The sub-keys are below. `views` is declared in the schema and reserved: accepted and **not yet read**. See [`rigor effects`](02-cli-reference.md#rigor-effects). |
182
185
  | `effects.check` | Boolean | `true` | Whether the envelopes you declared — `%a{pure}` and `%a{rigor:v1:effect …}` in RBS, and the `effects.envelopes:` stanzas below — are checked against what Rigor proved, surfacing `effect.envelope-exceeded` and, for a label the vocabulary does not recognise, `effect.unknown-label`. Set it to `false` to keep the report and the snapshot while silencing both. Never on without an `effects:` block. |
183
186
  | `effects.snapshot.path` | String | `.rigor-effects.yml` | Where `rigor effects update` writes the committed record. |
184
- | `effects.snapshot.reach` | Array | `[]` | Entry points whose **transitive** footprint the snapshot records under `reach:`. Each entry is a project-relative file glob (the `unused --entry-point` semantics — `**` is the only way across a directory boundary) or the name of an entry-point **preset** a plugin registered. On a Rails app, `reach: [rails]` is the one you want — see below. A name nothing registered is an error when the snapshot is built (not when the configuration loads, because the plugins that name presets load *from* that configuration). |
187
+ | `effects.snapshot.reach` | Array | `[]` | Entry points whose **transitive** footprint the snapshot records under `reach:`. The default is empty **deliberately**: Rigor could infer `[rails]` from your plugin list, but a snapshot is a record you agree to and review the diff of, and one whose contents moved because your plugin list moved would be the worse artefact. `rigor effects update` names the presets your plugins registered and leaves the same hint in the written file. Each entry is a project-relative file glob (the `unused --entry-point` semantics — `**` is the only way across a directory boundary) or the name of an entry-point **preset** a plugin registered. On a Rails app, `reach: [rails]` is the one you want — see below. A name nothing registered is an error when the snapshot is built (not when the configuration loads, because the plugins that name presets load *from* that configuration); the error lists the presets your plugins did register, so it names the fix. |
185
188
  | `effects.snapshot.gate` | String | `symmetric` | What `rigor effects check` treats as drift. `symmetric` fails on any difference — a job that *stopped* enqueueing is news too; `additions` is the growth-only ratchet. |
186
189
  | `effects.labels` | Array | `[]` | Effect labels **your project** registers, layered over Rigor's shipped vocabulary. A project may open any root (`acme.cache`) — listing the label here is the vouching act. Once registered, a label is usable in every other key below and stops being reported as unknown. Malformed spellings are a load error. |
187
190
  | `effects.attribution` | Hash | `{}` | What a call into code Rigor cannot see *does*, keyed by method: `{"Net::HTTP.get": [io.net.http], "Logger#info": [telemetry]}`. Keys are method keys — `Owner#instance_method` or `Owner.singleton_method` — and anything else is a load error. The labels land in the caller's **declared** lane and never in the proven one, so an attribution can never make a diagnostic fire; the call still counts as unresolved, because you told Rigor what that code does and Rigor did not read it. Use it for gems nobody has written a plugin for. |
@@ -215,7 +218,8 @@ effects:
215
218
  reach: [rails]
216
219
  ```
217
220
 
218
- `rails` — registered by [`rigor-railties`](plugins/rigor-rails.md) stands for `app/controllers/**`,
221
+ `rails` — registered by `rigor-railties`, which is a distinct plugin from the
222
+ [`rigor-rails`](plugins/rigor-rails.md) convenience grouping — stands for `app/controllers/**`,
219
223
  `app/jobs/**`, `app/mailers/**` and `app/channels/**`: every way the outside world enters the
220
224
  application. The component plugins also register the narrower `rails-controllers`, `rails-jobs`,
221
225
  `rails-mailers` and `rails-channels` if you want one layer's footprint rather than all four. A preset is
@@ -247,13 +251,26 @@ stanza it broke:
247
251
  ```
248
252
  app/presenters/user_presenter.rb:14:1: warning: Method Presenters::User#render performs io.fs.read
249
253
  (File.read), but is declared effect: [] at .rigor.yml effects.envelopes[0], so io.fs.read exceeds
250
- the envelope. [effect.envelope-exceeded]
254
+ the envelope.
251
255
  ```
252
256
 
253
257
  When one method is a deliberate exception, write the narrower envelope on it in RBS — nearest wins,
254
258
  and no `except:` key is needed. When a whole *kind* of effect is acceptable everywhere, name it in
255
259
  `tolerated:` instead of loosening every stanza.
256
260
 
261
+ **One stanza can land hundreds of warnings**, one per (method, label) pair — on a mid-size Rails
262
+ application, `match: "app/helpers/**/*.rb"` with `effect: []` produced 343 across 18 files. That is
263
+ the layer telling you what it does rather than a misconfiguration, but it is not a work list either;
264
+ [Effect labels § Envelopes by convention](19-effect-labels.md) walks the recipe for working it down.
265
+
266
+ A stanza is checked against **proven** labels only, so on a Rails application it cannot fire on
267
+ `io.db.*`, `cache.*`, `telemetry`, `email.send`, `job.enqueue` or any `rails.*`: those come from a
268
+ plugin modelling a framework Rigor did not read, and a claim about unread code must not be able to
269
+ fail your build. The enforcement path for that half is the committed snapshot, whose `rigor effects
270
+ check` marks a declared-lane addition with `≤+` — [Effect labels § What a bound can and cannot
271
+ see](19-effect-labels.md#what-a-bound-can-and-cannot-see), and
272
+ [ADR-103](../adr/103-effect-labels.md) § WD17 for why.
273
+
257
274
  If a layer is not ready for a bound yet, leave `envelopes:` out and start with the committed snapshot
258
275
  ([`rigor effects update`](02-cli-reference.md#the-effect-snapshot)) — it needs no declaration at all,
259
276
  and stanzas are the second step, written once the record has told you what the layer actually does.
@@ -27,7 +27,8 @@ built-in rule ID; `rigor explain` with no argument lists them all.
27
27
  Each built-in rule has a stable per-rule anchor on this page
28
28
  (`#rule-<family>-<name>`, dots written as dashes) — the
29
29
  `documentation_url` field in `--format json` and `rigor explain`'s
30
- `Documentation:` line both point here. The `Evidence` column is
30
+ `Documentation:` line both point here, at this chapter's published
31
+ copy on <https://rigor.typedduck.fail/manual/04-diagnostics/>. The `Evidence` column is
31
32
  Rigor's confidence that a firing is a true positive (see
32
33
  [Evidence tier](#evidence-tier) below). The one exception is
33
34
  `rbs_extended.unsatisfied-conformance`, an `rbs_extended`-family rule
@@ -61,7 +62,7 @@ carries no `documentation_url`.
61
62
  | <a id="rule-effect-envelope-exceeded"></a>`effect.envelope-exceeded` | A method performs an effect its declared envelope does not admit — its proven effect labels (its own body plus everything it calls) are not covered by the `%a{pure}` or `%a{rigor:v1:effect …}` bound written on it or on its class. Opt-in twice over: it needs an `effects:` block in `.rigor.yml` and an envelope you wrote. Positioned at the Ruby `def`. Unproven ("and possibly more") effects never fire, and `mutate.local` is tolerated by every envelope. | high |
62
63
  | <a id="rule-effect-liskov-widened"></a>`effect.liskov-widened` | An override escapes the envelope written on the method it overrides. A `PgRepo` is usable wherever a `Repo` is, so a `%a{rigor:v1:effect io.db}` on `Repo#find` binds `PgRepo#find` too: an implementation may be purer than the bound it inherits, never less pure. Either what the override *does* exceeds the inherited bound, or the envelope the override *declares for itself* is wider than it. Both sides must be authored — nothing fires unless someone wrote an envelope on the ancestor — and only subclassing counts, not `include`. Positioned at the override's `def`. Needs an `effects:` block. | high |
63
64
  | <a id="rule-effect-unknown-label"></a>`effect.unknown-label` | An effect declaration names a label the registry does not know — a typo in an envelope (`%a{rigor:v1:effect io.bd}`), or a member of `effects.tolerated:`. The whole tag then reads as unbounded, so the declaration quietly stops doing anything; this says so. Positioned at the declaration: the `.rbs` line, the `.rb` line for an rbs-inline annotation, or `.rigor.yml` for a config value. `# rigor:disable` comments are not read out of `.rbs` or `.rigor.yml`, so use `disable:` or the baseline there. Only fires where the spelling is evidently meant to be a label (close to a known one, next to a known one, dotted, or retired) — a word nothing resembles stays silent, because you may be opening your own vocabulary. Needs an `effects:` block. | high |
64
- | <a id="rule-effect-annotations-unchecked"></a>`effect.annotations-unchecked` | Your signatures carry `%a{pure}` / `%a{rigor:v1:effect …}` but `.rigor.yml` has no `effects:` block, so nothing checks them. One `:info` per run, positioned at the first annotation. An annotation never turns effect collection on by itself — that would make one line in one file more expensive for every run — so this is how it tells you instead. Add `effects: {}` to opt in, or `disable:` it to keep the annotations documentary. | — |
65
+ | <a id="rule-effect-annotations-unchecked"></a>`effect.annotations-unchecked` | Your signatures carry `%a{pure}` / `%a{rigor:v1:effect …}` but `.rigor.yml` has no `effects:` block, so nothing checks them. One `:info` per run, positioned at the first annotation. An annotation never turns effect collection on by itself — that would make one line in one file more expensive for every run — so this is how it tells you instead. Add `effects: {}` to opt in, or `disable:` it to keep the annotations documentary. Reads both annotation lanes — `sig/*.rbs` and rbs-inline comments — except on a run that analyses no file at all (a warm `--incremental` with nothing changed), where only the `.rbs` lane is visible. | — |
65
66
  | <a id="rule-suppression-unknown-rule"></a>`suppression.unknown-rule` | A `# rigor:disable[-file]` comment names a rule that does not exist (typically a typo), so the suppression silently does nothing. `plugin.`-prefixed tokens are never flagged. | high |
66
67
  | <a id="rule-suppression-empty"></a>`suppression.empty` | A `# rigor:disable[-file]` comment lists no rules, so it suppresses nothing. | high |
67
68
  | <a id="rule-suppression-unknown-marker"></a>`suppression.unknown-marker` | A comment uses a suppression marker Rigor does not recognise — typically the RuboCop reflex `# rigor:disable-next-line <rule>` or `# rigor:enable <rule>`. Rigor's only markers are `# rigor:disable <rules>` (suppresses on its own line) and `# rigor:disable-file <rules>`, so the comment suppresses nothing. | high |
data/docs/manual/11-ci.md CHANGED
@@ -188,6 +188,43 @@ The exit code is unchanged by `--format` — `0` when there are no errors,
188
188
  `1` otherwise — so the job still gates the pipeline. `--format json`
189
189
  remains available for any other tool that wants the raw diagnostic stream.
190
190
 
191
+ ## Gating effect drift
192
+
193
+ If the project uses [effect labels](19-effect-labels.md), add a second
194
+ step beside `rigor check`. It answers a different question — "did this
195
+ branch change what the code *does*?" — and fails for different reasons,
196
+ so keep it as its own step and let the log say which one went red:
197
+
198
+ ```yaml
199
+ - run: gem install rigortype
200
+ - run: rigor check
201
+ - run: rigor effects check
202
+ ```
203
+
204
+ `rigor effects check` exits `0` when the committed
205
+ `.rigor-effects.yml` matches what Rigor just recomputed and `1`
206
+ otherwise — including when the file is missing from the checkout, which
207
+ is what catches the pull request that changed a method and forgot to
208
+ regenerate it. The author's fix is `rigor effects update` plus a commit;
209
+ the regenerated diff is what the reviewer reads.
210
+
211
+ A bot that wants to report *what this branch changes*, regardless of how
212
+ stale the default branch is, compares against the base instead of the
213
+ committed file:
214
+
215
+ ```sh
216
+ rigor effects diff --baseline <(git show origin/main:.rigor-effects.yml)
217
+ ```
218
+
219
+ `diff` prints the identical comparison and never gates, so it is safe as
220
+ a comment-posting step; `check` takes the same `--baseline`.
221
+
222
+ With an `effects:` block configured, the snapshot verbs share their
223
+ cached effect summaries with `rigor check`, so running both in one job
224
+ does not re-read the project twice. Exit codes, `--strict-tolerated`, the
225
+ `--no-tolerated-effects` audit switch and the review loop are covered in
226
+ [Effect labels § In CI](19-effect-labels.md).
227
+
191
228
  ## Pinning Rigor's version
192
229
 
193
230
  The workflow above installs whatever `rigortype` is current at run
@@ -49,6 +49,45 @@ The cache is also schema-versioned: after a Rigor upgrade that
49
49
  changes the cache format, the stale cache is purged on the
50
50
  first writable run.
51
51
 
52
+ ## Effect summaries
53
+
54
+ [Effect labels](19-effect-labels.md) are cached alongside the rest,
55
+ but under their **own** identity: Rigor's effect vocabulary, its
56
+ built-in effect catalogue, and your `effects:` block. Two
57
+ consequences worth knowing:
58
+
59
+ - **Turning `effects:` on or off does not invalidate the entries
60
+ your `rigor check` already relies on.** The two identities are
61
+ separate, so adopting effect labels costs a first collection
62
+ pass and leaves the diagnostics cache alone. Upgrading to a
63
+ Rigor whose catalogue changed does the reverse — it re-reads
64
+ your effects without re-running your check.
65
+ - **The `rigor effects` verbs share those summaries with `rigor
66
+ check`.** In a job that runs both, the second command pays for
67
+ the propagation rather than for a second analysis.
68
+
69
+ The exception is a `rigor effects` run on a project with **no**
70
+ `effects:` block: it collects under an implicit empty block and
71
+ shares no cache with `rigor check`, because a run served from that
72
+ cache would have collected nothing.
73
+
74
+ **Everything the effect system produces survives a cache hit.** A
75
+ warm `rigor check` reports exactly the `effect.envelope-exceeded`
76
+ warnings a cold one does; there is no configuration in which the
77
+ effect checks fire on the first build of the day and go quiet
78
+ afterwards. The judgment itself is never stored — it is recomputed
79
+ each run from the summaries — so editing an envelope, a
80
+ `tolerated:` list or an `%a{pure}` annotation re-judges your code
81
+ on the next run even though no Ruby file changed, and a warning
82
+ can never outlive the stanza that justified it.
83
+
84
+ That costs speed in exactly one case. Declaring an envelope
85
+ anywhere opts the project out of the cache-hit fast path, because
86
+ Rigor has to load the analyzer to re-judge: a warm run takes about
87
+ a second on a mid-sized Rails application instead of about a
88
+ quarter of one. A project with `effects:` on and nothing declared
89
+ anywhere pays nothing.
90
+
52
91
  ## How a file is checked for changes
53
92
 
54
93
  To decide whether a cached entry is still valid, Rigor needs to
@@ -162,7 +162,7 @@ Every directive above describes what a method returns. Two
162
162
  describe what it *does*: `%a{pure}` — rbs' own purity
163
163
  annotation, read as "nothing at all" — and
164
164
  `%a{rigor:v1:effect <labels>}`, a comma-separated list of bare
165
- [effect labels](../type-specification/effect-labels.md) the
165
+ [effect labels](19-effect-labels.md) the
166
166
  method may not exceed. Both attach to a method or to a `class` /
167
167
  `module`, where they distribute to that class's own methods
168
168
  (nearest wins), and both tolerate mutating objects the method
@@ -210,7 +210,20 @@ on:
210
210
  at the declaration.
211
211
  - Without the block, one
212
212
  [`effect.annotations-unchecked`](04-diagnostics.md#rule-effect-annotations-unchecked)
213
- `:info` per run tells you the annotations are inert.
213
+ `:info` per run tells you the annotations are inert — from either
214
+ lane, with one exception: a run that analyses **no file at all**
215
+ (a warm `rigor check --incremental` with nothing changed) has no
216
+ synthesised RBS to read, so it reports a `.rbs` annotation and not
217
+ an inline one. Any run that analyses something reports both.
218
+
219
+ Two practical notes. Annotating one method in a `.rbs` file forces you to
220
+ declare its whole signature — RBS has no way to annotate a method it does
221
+ not declare — while the rbs-inline form above does not, so prefer the
222
+ inline lane when the bound is all you want. And an envelope is checked
223
+ against the **proven** lane only: a method whose labels all sit in the
224
+ declared (`≤`) lane passes `%a{pure}` in silence.
225
+ [Effect labels](19-effect-labels.md) covers both, with the vocabulary
226
+ these annotations draw from.
214
227
 
215
228
  ## Higher-kinded type directives
216
229