rigortype 0.3.3 → 0.3.4

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 (158) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +1 -1
  3. data/data/builtins/ruby_core/array.yml +11 -24
  4. data/data/builtins/ruby_core/enumerable.yml +4 -8
  5. data/data/builtins/ruby_core/exception.yml +6 -9
  6. data/data/builtins/ruby_core/hash.yml +13 -20
  7. data/data/builtins/ruby_core/io.yml +6 -9
  8. data/data/builtins/ruby_core/numeric.yml +2 -3
  9. data/data/builtins/ruby_core/proc.yml +4 -8
  10. data/data/builtins/ruby_core/re.yml +6 -9
  11. data/data/builtins/ruby_core/set.yml +2 -3
  12. data/data/builtins/ruby_core/string.yml +4 -7
  13. data/data/builtins/ruby_core/struct.yml +0 -6
  14. data/data/builtins/ruby_core/time.yml +3 -6
  15. data/data/effects/core.yml +843 -0
  16. data/data/effects/registry.yml +67 -0
  17. data/docs/handbook/07-rbs-and-extended.md +106 -5
  18. data/docs/manual/02-cli-reference.md +311 -1
  19. data/docs/manual/03-configuration.md +84 -1
  20. data/docs/manual/04-diagnostics.md +6 -2
  21. data/docs/manual/16-rbs-extended-annotations.md +77 -0
  22. data/docs/manual/18-removing-dead-code.md +326 -0
  23. data/docs/manual/README.md +5 -0
  24. data/docs/manual/plugins/rigor-actionmailer.md +5 -0
  25. data/docs/manual/plugins/rigor-activejob.md +47 -0
  26. data/docs/manual/plugins/rigor-factorybot.md +16 -0
  27. data/docs/manual/plugins/rigor-pundit.md +26 -0
  28. data/docs/manual/plugins/rigor-rails-routes.md +41 -2
  29. data/docs/manual/plugins/rigor-rspec-rails.md +5 -0
  30. data/docs/manual/plugins/rigor-rspec.md +8 -0
  31. data/docs/manual/plugins/rigor-sidekiq.md +54 -0
  32. data/lib/rigor/analysis/check_rules/rule_ids.rb +28 -2
  33. data/lib/rigor/analysis/incremental_session.rb +140 -6
  34. data/lib/rigor/analysis/reachability/graph.rb +244 -0
  35. data/lib/rigor/analysis/reachability/plugin_roots.rb +164 -0
  36. data/lib/rigor/analysis/reachability/project_files.rb +58 -0
  37. data/lib/rigor/analysis/reachability/scan.rb +241 -0
  38. data/lib/rigor/analysis/reachability/signature_scan.rb +134 -0
  39. data/lib/rigor/analysis/rule_catalog.rb +158 -0
  40. data/lib/rigor/analysis/run_cache_key.rb +16 -0
  41. data/lib/rigor/analysis/runner/declaration_position.rb +54 -0
  42. data/lib/rigor/analysis/runner/effect_annotation_residual_pass.rb +108 -0
  43. data/lib/rigor/analysis/runner/effect_envelope_pass.rb +358 -0
  44. data/lib/rigor/analysis/runner/envelope_messages.rb +61 -0
  45. data/lib/rigor/analysis/runner/pool_coordinator.rb +68 -10
  46. data/lib/rigor/analysis/runner.rb +420 -10
  47. data/lib/rigor/analysis/worker_session.rb +58 -2
  48. data/lib/rigor/bleeding_edge.rb +22 -0
  49. data/lib/rigor/cache/incremental_snapshot.rb +24 -5
  50. data/lib/rigor/cli/check_command.rb +13 -1
  51. data/lib/rigor/cli/check_runner_factory.rb +4 -1
  52. data/lib/rigor/cli/effects_command.rb +131 -0
  53. data/lib/rigor/cli/effects_diff_renderer.rb +123 -0
  54. data/lib/rigor/cli/effects_explain_renderer.rb +68 -0
  55. data/lib/rigor/cli/effects_renderer.rb +54 -0
  56. data/lib/rigor/cli/effects_report.rb +53 -0
  57. data/lib/rigor/cli/effects_snapshot_command.rb +256 -0
  58. data/lib/rigor/cli/unused_command.rb +288 -0
  59. data/lib/rigor/cli.rb +16 -0
  60. data/lib/rigor/configuration/severity_profile.rb +23 -0
  61. data/lib/rigor/configuration.rb +296 -14
  62. data/lib/rigor/effects/attribution.rb +76 -0
  63. data/lib/rigor/effects/catalog.rb +275 -0
  64. data/lib/rigor/effects/collector.rb +182 -0
  65. data/lib/rigor/effects/config_envelopes.rb +185 -0
  66. data/lib/rigor/effects/discharge.rb +69 -0
  67. data/lib/rigor/effects/effect_table.rb +92 -0
  68. data/lib/rigor/effects/entry_points.rb +93 -0
  69. data/lib/rigor/effects/envelope.rb +86 -0
  70. data/lib/rigor/effects/envelope_check.rb +172 -0
  71. data/lib/rigor/effects/envelope_index.rb +157 -0
  72. data/lib/rigor/effects/file_collection.rb +160 -0
  73. data/lib/rigor/effects/framework_units.rb +167 -0
  74. data/lib/rigor/effects/identity.rb +104 -0
  75. data/lib/rigor/effects/label.rb +77 -0
  76. data/lib/rigor/effects/label_intent.rb +73 -0
  77. data/lib/rigor/effects/label_set.rb +136 -0
  78. data/lib/rigor/effects/liskov_check.rb +167 -0
  79. data/lib/rigor/effects/local_ownership.rb +132 -0
  80. data/lib/rigor/effects/method_key.rb +40 -0
  81. data/lib/rigor/effects/mutation_classifier.rb +92 -0
  82. data/lib/rigor/effects/narrowing.rb +202 -0
  83. data/lib/rigor/effects/origin.rb +66 -0
  84. data/lib/rigor/effects/path_finder.rb +89 -0
  85. data/lib/rigor/effects/plugin_facts.rb +335 -0
  86. data/lib/rigor/effects/propagator.rb +275 -0
  87. data/lib/rigor/effects/registry.rb +193 -0
  88. data/lib/rigor/effects/scanner.rb +290 -0
  89. data/lib/rigor/effects/signature_sources.rb +74 -0
  90. data/lib/rigor/effects/snapshot.rb +380 -0
  91. data/lib/rigor/effects/snapshot_diff.rb +265 -0
  92. data/lib/rigor/effects/summary.rb +154 -0
  93. data/lib/rigor/effects/taint_cause.rb +38 -0
  94. data/lib/rigor/effects/unit_scan.rb +572 -0
  95. data/lib/rigor/effects/unknown_label_check.rb +86 -0
  96. data/lib/rigor/effects/unknown_label_report.rb +59 -0
  97. data/lib/rigor/effects.rb +47 -0
  98. data/lib/rigor/environment/rbs_loader.rb +34 -0
  99. data/lib/rigor/environment.rb +8 -1
  100. data/lib/rigor/flow_contribution/element.rb +1 -0
  101. data/lib/rigor/flow_contribution/merge_result.rb +5 -3
  102. data/lib/rigor/flow_contribution/merger.rb +16 -1
  103. data/lib/rigor/flow_contribution.rb +20 -4
  104. data/lib/rigor/inference/expression_typer.rb +18 -4
  105. data/lib/rigor/inference/pre_eval_constants.rb +189 -0
  106. data/lib/rigor/inference/scope_indexer.rb +15 -3
  107. data/lib/rigor/plugin/base.rb +24 -0
  108. data/lib/rigor/plugin/effect_attribution.rb +208 -0
  109. data/lib/rigor/plugin/effect_edge.rb +101 -0
  110. data/lib/rigor/plugin/effect_entry_points.rb +51 -0
  111. data/lib/rigor/plugin/first_party.rb +57 -0
  112. data/lib/rigor/plugin/isolation.rb +4 -1
  113. data/lib/rigor/plugin/manifest.rb +103 -3
  114. data/lib/rigor/plugin/registry.rb +48 -0
  115. data/lib/rigor/plugin.rb +1 -0
  116. data/lib/rigor/rbs_extended/envelope_scanner.rb +160 -0
  117. data/lib/rigor/rbs_extended.rb +168 -0
  118. data/lib/rigor/reflection.rb +131 -30
  119. data/lib/rigor/version.rb +1 -1
  120. data/lib/rigor.rb +1 -0
  121. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/effects.rb +90 -0
  122. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable.rb +7 -1
  123. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/effects.rb +85 -0
  124. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer.rb +8 -1
  125. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/effects.rb +152 -0
  126. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +9 -1
  127. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/effects.rb +156 -0
  128. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/recurring_scan.rb +113 -0
  129. data/plugins/rigor-activejob/lib/rigor/plugin/activejob.rb +68 -3
  130. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/effects.rb +186 -0
  131. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +12 -1
  132. data/plugins/rigor-activerecord/sig/active_record/relation.rbs +145 -0
  133. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/effects.rb +77 -0
  134. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage.rb +6 -1
  135. data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext/effects.rb +143 -0
  136. data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext.rb +9 -1
  137. data/plugins/rigor-activesupport-core-ext/sig/active_support/core_ext.rbs +385 -28
  138. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot.rb +30 -2
  139. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/authorization_scan.rb +151 -0
  140. data/plugins/rigor-pundit/lib/rigor/plugin/pundit.rb +46 -3
  141. data/plugins/rigor-rails/lib/rigor-rails.rb +1 -0
  142. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/effects.rb +47 -0
  143. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +7 -1
  144. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/acronyms.rb +81 -0
  145. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/helper_table.rb +13 -1
  146. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/routes_parser.rb +202 -15
  147. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +43 -3
  148. data/plugins/rigor-railties/lib/rigor/plugin/railties/effects.rb +171 -0
  149. data/plugins/rigor-railties/lib/rigor/plugin/railties.rb +62 -0
  150. data/plugins/rigor-railties/lib/rigor-railties.rb +3 -0
  151. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/schedule_scan.rb +122 -0
  152. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq.rb +56 -3
  153. data/sig/rigor/analysis/fact_store.rbs +1 -0
  154. data/sig/rigor/inference.rbs +2 -0
  155. data/sig/rigor/rbs_extended.rbs +6 -0
  156. data/sig/rigor.rbs +17 -1
  157. data/skills/rigor-unused-adjudicate/SKILL.md +90 -0
  158. metadata +79 -1
@@ -0,0 +1,67 @@
1
+ # Effect-label registry — the shared vocabulary of ADR-103 WD2, normative in
2
+ # `docs/type-specification/effect-labels.md`.
3
+ #
4
+ # Hand-written, NOT generated: every row is a deliberate vocabulary decision. The generated
5
+ # `data/builtins/ruby_core/*.yml` `purity:` facet answers fold-safety, not effects, and is never
6
+ # read as a label source (ADR-103 WD3).
7
+ #
8
+ # `vocabulary` bumps only on a rename or a removal, never on a leaf addition — adding a leaf can
9
+ # never change what a recognised bound admits, because subsumption is a segment-aware prefix test.
10
+ # A renamed or removed spelling is recorded in `retired:` so a snapshot written by an older Rigor
11
+ # still reads.
12
+ vocabulary: 1
13
+
14
+ labels:
15
+ # Steins v1, verbatim (rigortype/steins `docs/type-specification/effects.md`). Shared vocabulary:
16
+ # a policy naming these labels must read the same against a PHP service and a Rails app.
17
+ - exit
18
+ - ffi
19
+ - global.read
20
+ - global.write
21
+ - io
22
+ - io.db
23
+ - io.fs
24
+ - io.fs.read
25
+ - io.fs.write
26
+ - io.input
27
+ - io.ipc
28
+ - io.net
29
+ - io.net.http
30
+ - io.output
31
+ - io.output.buffer
32
+ - io.output.header
33
+ - io.output.stdout
34
+ - io.output.stderr
35
+ - io.process
36
+ - io.signal
37
+ - mutate
38
+ - mutate.local
39
+ - nondet
40
+ - nondet.random
41
+ - nondet.time
42
+
43
+ # Ruby leaves under `mutate` (Steins ADR-0055's names; ADR-103 WD14). `mutate.self` is self's
44
+ # state, `mutate.instance` a receiver that is neither self nor frame-owned, `mutate.static`
45
+ # class-level state. Unknown ownership taints rather than producing a bare `mutate`.
46
+ - mutate.self
47
+ - mutate.instance
48
+ - mutate.static
49
+
50
+ # Proposed shared core leaves, to raise with Steins: a `SELECT` is a read whichever language
51
+ # issued it, an `INSERT` a write, `BEGIN`/`COMMIT` neither.
52
+ - io.db.read
53
+ - io.db.write
54
+ - io.db.transaction
55
+
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.
59
+ - telemetry
60
+ - email.send
61
+ - job.enqueue
62
+ - cache.read
63
+ - cache.write
64
+
65
+ # Retired spellings: `old.label: [replacement, ...]`. Empty at vocabulary 1 — nothing has been
66
+ # renamed or removed yet.
67
+ retired: {}
@@ -165,6 +165,79 @@ so because you added it. The stacking and diagnostic semantics
165
165
  are in
166
166
  [manual — `conforms-to`](../manual/16-rbs-extended-annotations.md#conforms-to--a-checked-structural-contract).
167
167
 
168
+ ## Bounding what a method *does* — effect envelopes
169
+
170
+ Every directive so far describes what a method returns. One
171
+ describes what it *does*:
172
+
173
+ ```rbs
174
+ class UserRepository
175
+ %a{rigor:v1:effect io.db}
176
+ def find: (Integer) -> User
177
+
178
+ %a{pure}
179
+ def slug: (String) -> String
180
+ end
181
+ ```
182
+
183
+ `%a{rigor:v1:effect io.db}` says "this method may touch the
184
+ database, and nothing else the vocabulary names". `%a{pure}`
185
+ says "nothing at all" — it is rbs' own purity annotation, the
186
+ one Steep already reads, so Rigor honours the spelling that
187
+ exists rather than inventing a synonym. Both tolerate mutating
188
+ objects the method itself allocated and never let out, so a
189
+ `%a{pure}` method may still build and fill a local array.
190
+
191
+ The payload is a comma-separated list of bare labels
192
+ (`%a{rigor:v1:effect io.db, nondet.time}`), from the vocabulary
193
+ in
194
+ [effect-labels.md](../type-specification/effect-labels.md).
195
+ Write the annotation on a `class` or `module` declaration
196
+ instead and it applies to every method of that class —
197
+ reopenings and `attr_writer`-generated methods included, but
198
+ never to subclasses. A method that carries its own envelope
199
+ keeps it; nearest wins.
200
+
201
+ The bound covers the method's whole *code*, including what it
202
+ calls. A `find` declared `io.db` that reaches an HTTP request
203
+ through a helper exceeds its envelope, and Rigor says so — on
204
+ the Ruby `def`, naming the route it took:
205
+
206
+ ```text
207
+ lib/user_repository.rb:12: warning: Method UserRepository#find
208
+ performs io.net.http (Net::HTTP.get via PaymentGateway#charge),
209
+ but is declared %a{rigor:v1:effect io.db} at sig/repo.rbs:3,
210
+ so io.net.http exceeds the envelope.
211
+ ```
212
+
213
+ Two things it will not do. It never reports an effect it only
214
+ *suspects*: a call Rigor could not resolve makes the summary
215
+ read "and possibly more", and possibly is not a finding. And
216
+ none of this happens unless you asked for it — the check needs
217
+ an `effects:` block in `.rigor.yml`, so an `%a{pure}` already
218
+ sitting in your signatures for Steep's benefit stays inert
219
+ until you opt in. Set `effects.check: false` to keep the
220
+ `rigor effects` report and its snapshot while silencing the
221
+ diagnostic. Inert is not the same as unmentioned: annotations
222
+ with no `effects:` block earn one
223
+ [`effect.annotations-unchecked`](../manual/04-diagnostics.md#rule-effect-annotations-unchecked)
224
+ `:info` per run, so a bound nobody checks never goes unnoticed.
225
+
226
+ Misspell a label and the annotation does not narrow to the part
227
+ Rigor recognised — the **whole** tag reads as unbounded, so a
228
+ typo can never turn into findings on correct code. That would
229
+ be a silent loss of a contract you thought you had, so where
230
+ the spelling is evidently meant to be a label Rigor says so:
231
+ [`effect.unknown-label`](../manual/04-diagnostics.md#rule-effect-unknown-label),
232
+ at the line you wrote it on, naming the nearest real label.
233
+ A word that resembles nothing in the vocabulary stays silent —
234
+ you may be opening a root of your own.
235
+
236
+ The whole feature — the label vocabulary, the committed effect
237
+ snapshot, and `rigor effects` itself — is
238
+ [ADR-103](../adr/103-effect-labels.md); start with
239
+ [`rigor effects`](../manual/02-cli-reference.md#rigor-effects).
240
+
168
241
  ## Worked example: an assertion gate
169
242
 
170
243
  ```rbs
@@ -299,11 +372,39 @@ class Slug
299
372
  end
300
373
  ```
301
374
 
302
- You **cannot** put these `%a{rigor:v1:…}` directives inside a
303
- `.rb` file. The directives only fire when read from RBS —
304
- that is a design choice (see
305
- ADR-5, the robustness principle: strict on returns, lenient
306
- on parameters).
375
+ You can also write them **in a `.rb` file**, as rbs-inline
376
+ `# @rbs %a{…}` comments:
377
+
378
+ ```rb
379
+ # rbs_inline: enabled
380
+
381
+ class Slug
382
+ # @rbs %a{rigor:v1:return: non-empty-string}
383
+ # @rbs id: String
384
+ # @rbs return: String
385
+ def normalise(id) = id.strip
386
+ end
387
+ ```
388
+
389
+ `%a{}` is *rbs-inline's own* grammar, not a Rigor dialect, and
390
+ the annotation reaches Rigor through the ordinary path: the
391
+ rbs-inline writer copies it verbatim onto the signature it
392
+ generates, and that signature joins the same RBS environment
393
+ your `sig/` tree lands in. So this is not a per-directive
394
+ feature — every `RBS::Extended` directive is read from the same
395
+ annotation object whichever buffer it arrived in — the effect
396
+ envelopes above (`%a{pure}`, `%a{rigor:v1:effect …}`) included.
397
+ Reading them requires the rbs-inline library, which Rigor
398
+ ingests by default when it is installed
399
+ ([ADR-93](../adr/93-default-rbs-inline-ingestion.md)).
400
+
401
+ What Rigor does **not** offer is a Rigor-only comment dialect —
402
+ there is no `# rigor:effect` directive and no file pragma. The
403
+ `# rigor:` comment family stays suppression-only (`disable`,
404
+ `disable-file`). Application code never has to carry
405
+ Rigor-specific syntax ([ADR-0](../adr/0-concept.md)); an
406
+ upstream annotation form you may use if you want one is a
407
+ different thing from a requirement.
307
408
 
308
409
  ## Inline RBS in Ruby source — the `rigor-rbs-inline` plugin
309
410
 
@@ -48,6 +48,7 @@ the `paths:` list from the configuration file.
48
48
  | `--treat-all-as-inline-rbs` | Force-load `rigor-rbs-inline` with `require_magic_comment: false`, so every analysed file is treated as inline-RBS without the `# rbs_inline: enabled` comment (ADR-32). |
49
49
  | `--bleeding-edge[=ids]` | Adopt the bleeding-edge overlay for this run, overriding the configured [`bleeding_edge:`](03-configuration.md) selection (ADR-50 § WD2). Bare adopts every queued feature; `--bleeding-edge=a,b` adopts only the named feature ids. Inspect it with [`rigor show-bleedingedge`](#rigor-show-bleedingedge). |
50
50
  | `--no-bleeding-edge` | Ignore any configured `bleeding_edge:` selection for this run (adopt none). |
51
+ | `--no-tolerated-effects` | Check effect envelopes as if [`effects.tolerated:`](03-configuration.md) were empty — the audit switch for your discharge policy (ADR-103). Judgment only: the run, what it collects and its cache entry are identical either way, so this never costs a re-analysis. |
51
52
  | `--tmp-file=PATH --instead-of=PATH` | Editor mode: analyse `PATH` using the buffer in `--tmp-file`. Both required together. Alone, only the buffer's own file produces diagnostics; add `--incremental` for whole-project scope (see below). |
52
53
 
53
54
  Exit `0` when no error-severity diagnostics remain, `1` when
@@ -162,6 +163,208 @@ rigor type-scan PATH...
162
163
  unrecognized-node ratio exceeds `RATIO`, and `--format=text|json`
163
164
  selects the output format.
164
165
 
166
+ ## `rigor effects`
167
+
168
+ Report what each method *does* — its effect labels — rather
169
+ than what it returns, and manage the committed **effect
170
+ snapshot** that gates drift. Opt-in and observational: nothing
171
+ here emits a diagnostic or changes `rigor check`'s output, and
172
+ only `rigor effects check` ever exits non-zero.
173
+
174
+ ```sh
175
+ rigor effects [PATH...] # the report
176
+ rigor effects {update,check,diff,explain} # the snapshot
177
+ ```
178
+
179
+ With no paths the report analyses the configured `paths:`. It
180
+ runs with effect collection enabled even when your `.rigor.yml`
181
+ carries no `effects:` block, so you can try it before
182
+ configuring anything; such an ad-hoc run shares no cache with
183
+ `rigor check`, because a run served from that cache would have
184
+ collected nothing.
185
+
186
+ Each line is one method, sorted by key:
187
+
188
+ ```
189
+ Tracer::Reporter#report: [io.output.stdout, nondet.time]
190
+ Tracer::Gateway#fetch: [] …?
191
+ dynamic-receiver (external_gem_without_rbs)
192
+ ```
193
+
194
+ The labels are the **transitive** footprint — the method's own
195
+ plus every project method it reaches. A ` …?` suffix means the
196
+ list is not exhaustive: some call could not be resolved, so the
197
+ reading is "these effects, and possibly more". The indented
198
+ lines say why. A taint is never a finding.
199
+
200
+ A ` ≤ [...]` clause after the labels is the **declared** lane:
201
+ 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
206
+ the proven labels do, so a controller two hops above an
207
+ attributed gem call carries the claim rather than only a
208
+ "possibly more"; a declared label the proven list already
209
+ covers is not printed twice.
210
+
211
+ ```
212
+ Gateways::Client#fetch: [] ≤ [io.net.http] …?
213
+ plugin-attribution (Acme::Http.get)
214
+ ```
215
+
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.
220
+
221
+ `--format=text|json` selects the output format; the JSON
222
+ payload additionally carries each method's *direct* summary
223
+ broken down per origin. `--config=PATH` picks a config file.
224
+ `--no-tolerated-effects` is accepted for symmetry with the
225
+ subcommands and does nothing here: the report is an
226
+ observation, and observations are undischarged.
227
+
228
+ 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).
232
+
233
+ ### The effect snapshot
234
+
235
+ Four subcommands manage a committed record of the effects
236
+ Rigor observed — `.rigor-effects.yml`, the effect equivalent
237
+ of `db/schema.rb`:
238
+
239
+ ```sh
240
+ rigor effects update # write the snapshot; commit it
241
+ rigor effects check # 0 fresh, 1 drift — the CI gate
242
+ rigor effects diff # the same comparison, never gating
243
+ rigor effects explain # why an entry point reaches a label
244
+ ```
245
+
246
+ Unlike the report, the subcommands take no paths: a snapshot
247
+ records the whole project, and one written over a subset would
248
+ read as a project where every other method vanished. They all
249
+ accept `--config=PATH`, `--format=text|json` and `--full`;
250
+ `check`, `diff` and `explain` additionally accept
251
+ `--baseline=PATH` (compare against a file other than the
252
+ configured one — `--baseline <(git show
253
+ origin/main:.rigor-effects.yml)` in a bot),
254
+ `--strict-tolerated` and `--no-tolerated-effects`.
255
+
256
+ The file records two tables. `methods:` holds each method's
257
+ **direct** summary — what its own body does, block literals
258
+ and catalogued callees included, but not what the project
259
+ methods it calls do. That is deliberate: an entry moves only
260
+ when its own lines changed, so the diff stays attributable to
261
+ the pull request that caused it. `reach:` holds the
262
+ **transitive** footprint at the entry points
263
+ `effects.snapshot.reach:` names, where a leaf change is
264
+ supposed to fan out — the fan-out is the blast radius.
265
+
266
+ ```yaml
267
+ # .rigor-effects.yml — generated by `rigor effects update`. Commit it; review its diff.
268
+ schema: 1
269
+ rigor: "0.3.3"
270
+ vocabulary: 1
271
+ config_digest: "9ec82bfc…"
272
+ methods:
273
+ "PaymentGateway#charge":
274
+ effects: ["io.net.http", "telemetry"]
275
+ "Reports::Nightly#perform":
276
+ effects: ["io.db.read"]
277
+ exhaustive: false
278
+ unresolved: ["dynamic-send"]
279
+ reach:
280
+ "OrdersController#create":
281
+ effects: ["io.db.read", "io.net.http", "job.enqueue"]
282
+ ```
283
+
284
+ A method is left out when it is exhaustive and proves nothing
285
+ beyond `mutate.local`, when its summary is a synthesised
286
+ accessor's, and when it carries no label in either lane — a
287
+ row that would say only "not exhaustive, and here is why" is
288
+ something `rigor effects` and `rigor effects explain` answer
289
+ better than a committed record. `--full` records everything.
290
+ The header carries
291
+ the Rigor and vocabulary versions and a digest of your
292
+ `effects:` block, so an upgrade or a policy edit shows up as a
293
+ *regeneration event* rather than as silent reinterpretation.
294
+
295
+ Under `methods:` the declared lane is what that method's own
296
+ body claims; under `reach:` it is the transitive claim, like
297
+ the proven labels beside it.
298
+
299
+ `check` prints one line per difference: `+ label` / `- label`
300
+ for the proven lane, `≤+` / `≤-` for the declared one,
301
+ `materialised` when a declared label became proven,
302
+ `exhaustive → not` when someone introduced a call Rigor cannot
303
+ follow, and `+symbol` / `-symbol` for methods that appeared or
304
+ vanished (a rename is one of each, counted in the footer). A
305
+ removal read off a summary that is no longer exhaustive is
306
+ printed hedged — "possibly more" cannot prove an absence.
307
+
308
+ `effects.snapshot.gate:` decides what fails. `symmetric` (the
309
+ default) fails on any drift: a job that stopped enqueueing is
310
+ news too. `additions` is the ratchet — only growth fails.
311
+ `effects.tolerated:` is applied at judgment time, never while
312
+ writing: a difference confined to tolerated labels is printed
313
+ under a `tolerated:` heading and does not fail the gate unless
314
+ you pass `--strict-tolerated`. `--no-tolerated-effects` judges
315
+ as if the list were empty; on `update` it changes nothing,
316
+ because the record itself is undischarged.
317
+
318
+ Discharge works **per origin**, not per label. `Logger#info`
319
+ carries `io` and `telemetry` together, so `tolerated:
320
+ [telemetry]` frees the `io` that came with the logging — and
321
+ leaves an `io.fs.read` from a `File.read` two lines down
322
+ exactly where it was. An added label is discharged only when
323
+ every origin that introduces it is discharged.
324
+
325
+ ### Reviewing effect drift
326
+
327
+ Day one, run `rigor effects update` and commit the result. The
328
+ diff you are committing is the team's first map: which
329
+ controllers reach the network, which jobs write, which
330
+ presenters query.
331
+
332
+ Add `rigor effects check` to CI. From then on a pull request
333
+ that changes what the code *does* fails it with the reason
334
+ spelled out:
335
+
336
+ ```
337
+ Effect drift against .rigor-effects.yml:
338
+
339
+ methods:
340
+ PaymentGateway#charge + io.net.http
341
+
342
+ reach:
343
+ OrdersController#create + io.net.http
344
+
345
+ Run `rigor effects update` and commit the result if this change is intended.
346
+ ```
347
+
348
+ The author runs `rigor effects explain` to see the route —
349
+
350
+ ```
351
+ reach:
352
+ OrdersController#create → OrderService#place → PaymentGateway#charge → Net::HTTP.get [io.net.http]
353
+ ```
354
+
355
+ — then runs `rigor effects update` and commits the regenerated
356
+ file. **Intent is expressed by committing the regenerated
357
+ snapshot**, not by annotating the code; the reviewer reads the
358
+ two-line diff alongside the code change and either nods or
359
+ pushes back. A bundle update that moves effects with no code
360
+ diff of its own works the same way, and is exactly the case
361
+ worth seeing.
362
+
363
+ None of this is a diagnostic. `rigor check`'s output and exit
364
+ code are identical whether or not you use the snapshot, and
365
+ drift is never a finding: whether it *matters* is the
366
+ reviewer's judgment, which is what makes it a review artefact.
367
+
165
368
  ## `rigor explain`
166
369
 
167
370
  Print the catalogue entry for a diagnostic rule, or list every
@@ -300,6 +503,112 @@ rigor triage --format json | jq '[.selectors[] | select(.receiver == "String")]'
300
503
  The same `receiver_type` / `method_name` fields ride on each
301
504
  diagnostic of `rigor check --format json`, for per-site (rather than
302
505
  aggregated) grouping.
506
+ ## `rigor unused`
507
+
508
+ Report project constants that nothing reachable references — a
509
+ starting point for dead-code removal.
510
+
511
+ ```sh
512
+ rigor unused [paths] --entry-point='lib/cli.rb'
513
+ ```
514
+
515
+ **Read the output as a review queue, not a defect list.** On a
516
+ hand-adjudicated corpus target only **7% of the rows were genuinely
517
+ unused**; the rest were reachable by means static analysis cannot
518
+ see. That is why this is a separate command and never a `rigor check`
519
+ diagnostic — see
520
+ [ADR-102](https://github.com/rigortype/rigor/blob/master/docs/adr/102-unused-code-reachability-report.md).
521
+
522
+ Reachability is computed from **roots**, not by counting references,
523
+ so a cluster of classes that only reference each other is still
524
+ reported. Roots are the declarations in files matching
525
+ `--entry-point=GLOB` (repeatable), anything referenced at file level
526
+ by non-test code, and anything the project's **plugins** contribute.
527
+
528
+ Plugin-supplied roots are where framework knowledge enters. A Rails
529
+ controller is reached by name at request time, so nothing in the
530
+ project references it and a reference index cannot tell a live
531
+ controller from a dead one. `rigor-rails-routes` closes that by
532
+ reading `config/routes.rb` statically and naming every controller it
533
+ dispatches to — no Rails boot, and a route written under a
534
+ conditional (`get "/beta", to: "beta#index" if ENV["BETA"]`) is
535
+ visible where a booted app's route table would not show it. On two
536
+ Rails corpus targets this removed 56 % and 84 % of the candidate list.
537
+
538
+ `rigor unused` loads the same `plugins:` your `rigor check` run uses,
539
+ and prints how many roots came from them:
540
+
541
+ ```
542
+ roots: 404 (288 from plugins, 0 matched no declaration)
543
+ ```
544
+
545
+ `matched no declaration` is the number a plugin claimed that this
546
+ project does not declare — normally framework classes such as
547
+ `Rails::HealthController`. A number climbing away from zero means a
548
+ root source has drifted out of step with the code, which matters
549
+ because an over-claiming root source silently *hides* dead code. A
550
+ framework Rigor has no plugin for supplies no roots, so its
551
+ controllers still read as candidates.
552
+
553
+ `rigor-pundit` supplies the second kind of root: a policy class is
554
+ reached as `PostPolicy` from `authorize @post`, a name that appears
555
+ nowhere in the source. It publishes the policies your authorization
556
+ calls actually name — not every class under `app/policies`, because a
557
+ file's location is not evidence that anything authorizes against it.
558
+
559
+ `rigor-sidekiq` shows how narrow a root source has to be to be worth
560
+ having. A worker named as `class: "NightlyReportWorker"` in a cron
561
+ schedule is enqueued from YAML, so its name appears nowhere in the
562
+ code and it reads as dead — that name becomes a root. The queue list
563
+ in the same `sidekiq.yml` does not: a queue name is not a class name,
564
+ and inflecting one into a worker name would root a class on a naming
565
+ coincidence.
566
+
567
+ A plugin can also contribute a **reference** rather than a root, and
568
+ `rigor-factorybot` is why the distinction exists. `factory :user,
569
+ class: "Admin::User"` names a class as a string the scan cannot see,
570
+ so it is real evidence of use — but a factory lives in the test tree.
571
+ Supplied as a reference carrying the `test` role, the class leaves
572
+ the candidate list and appears under *Reachable only from test code*;
573
+ supplied as a root it would have been promoted to
574
+ production-reachable, and the more interesting finding would have
575
+ disappeared.
576
+
577
+ Most bundled plugins deliberately contribute nothing.
578
+ `MyJob.perform_later`, `MyMailer.welcome`, `MyWorker.perform_async`
579
+ and `RSpec.describe User` all write the class name as an ordinary
580
+ constant, which the report already records — and for the spec case,
581
+ records with the `test` role that makes the section above possible.
582
+ Each plugin's page says which choice it made and why.
583
+
584
+ Three things the report separates rather than merges:
585
+
586
+ - **Reachable only from test code** gets its own section — a class
587
+ used solely by its own spec is dead production code with a live
588
+ test, which is a more actionable finding than either bucket alone.
589
+ - **Constants something can name at runtime** are demoted to a
590
+ `cannot decide` section with the reason, never claimed as unused.
591
+ `"Foo".constantize` names `Foo` exactly, so it counts as an ordinary
592
+ reference; `"Foo::#{key}".constantize` instead marks everything
593
+ under `Foo` undecidable. A class name appearing as a string in a
594
+ `.yml`, `.json`, or template file demotes it the same way — that is
595
+ weaker evidence than a constant reference, so it is neither proof of
596
+ use nor grounds to call it dead.
597
+ - **Namespace modules** wrapping live code are excluded and counted,
598
+ because nothing references an intermediate namespace by itself. A
599
+ namespace whose contents are *all* unreachable is still reported.
600
+
601
+ Only class and module constants are reported at all; value constants
602
+ are omitted because they do not resolve across files.
603
+
604
+ References are harvested from a wider file set than the analysed
605
+ paths — `.rake` tasks, `config/`, specs and the project's own `sig/`
606
+ all count as references — because a constant used only from a Rake
607
+ task is not dead.
608
+
609
+ `--format json` emits the same data; `--limit=N` truncates the
610
+ printed lists. `--incremental` is refused: reachability is only sound
611
+ over a whole-project run.
303
612
 
304
613
  ## `rigor coverage`
305
614
 
@@ -654,6 +963,7 @@ Queued today:
654
963
  | `use-of-void-value` | severity | Using a value recovered from an author-declared `-> void` return in value context is reported as `static.value-use.void` (`warning`). |
655
964
  | `discovery-seeded-mutation-sites` | behaviour | [`rigor coverage --protection --mutation`](15-type-protection-coverage.md) measures against the same cross-file project discovery Tier 1 already uses — both when picking the sites and when deciding whether a breakage was caught — so a call on a project class declared in a *sibling* file is measured instead of dropped, and a breakage there can actually be caught. **Adds sites to the denominator, so the reported effectiveness ratio moves** — check it against any `--threshold` you pin in CI before adopting. |
656
965
  | `dependent-closure-kill-oracle` | behaviour | [`rigor coverage --protection --mutation`](15-type-protection-coverage.md) counts a breakage as caught when the diagnostic appears anywhere in the mutated file **or the files that depend on it**, instead of in the mutated file alone — so changing what a method returns counts as caught when the error lands in its callers. Can only **add** kills, so the ratio moves up or not at all; it costs about a third more wall time per mutant, and a ratio measured under it is not comparable with one measured without it. |
966
+ | `effects-on-by-default` | behaviour | A project whose `.rigor.yml` carries no [`effects:`](03-configuration.md#effect-labels) key at all is treated as if it had written `effects: {}` — [effect collection](03-configuration.md#effect-labels), the `rigor effects` verbs' cache sharing, and `effects.check` all turn on with every sub-key at its default. Writing `effects: false` explicitly still opts out. **Scheduled to graduate at v0.4.0** ([ADR-103](../adr/103-effect-labels.md) § WD15) rather than at the next major — an owner ruling specific to this feature, ahead of the general v1.0.0 majors-only cadence below. |
657
967
 
658
968
  Once a feature **graduates** — it becomes the default at a major
659
969
  ([ADR-50](../adr/50-release-engineering-and-stability-strategy.md) § WD7)
@@ -758,7 +1068,7 @@ diagnostics about Rigor's own inference cutoffs and memory — see
758
1068
  | Code | Meaning |
759
1069
  | --- | --- |
760
1070
  | `0` | Success — no error-severity diagnostics. |
761
- | `1` | Diagnostics found, or a per-command failure (parse error, missing file, new diagnostics on `diff`). |
1071
+ | `1` | Diagnostics found, or a per-command failure (parse error, missing file, new diagnostics on `diff`, effect drift on `effects check`). |
762
1072
  | `64` | Usage error — unknown command, bad flag, malformed argument. |
763
1073
 
764
1074
  `rigor triage` is the exception: it is advisory and always
@@ -70,7 +70,7 @@ cache:
70
70
  | --- | --- | --- | --- |
71
71
  | `libraries` | Array | `[]` | Standard-library / gem names whose bundled RBS to load. |
72
72
  | `signature_paths` | Array | `nil` | Extra directories of `.rbs` files. Relative entries resolve against the config file's directory. |
73
- | `pre_eval` | Array | `[]` | Files (or globs) walked before per-file analysis, to register project monkey-patches. |
73
+ | `pre_eval` | Array | `[]` | Files (or globs) walked before per-file analysis, to register project monkey-patches and publish their top-level constants project-wide. |
74
74
  | `plugins` | Array | `[]` | Plugins to activate — see [Using plugins](07-plugins.md). |
75
75
 
76
76
  ### Config validation warnings
@@ -174,6 +174,89 @@ explicitly with `bundler.bundle_path:`, or supply signatures another way:
174
174
  | `plugins_io.allowed_paths` | Array | `[]` | Filesystem paths plugins may read. |
175
175
  | `plugins_io.allowed_url_hosts` | Array | `[]` | URL hosts plugins may fetch from when `network: allowlist`. |
176
176
 
177
+ ### Effect labels
178
+
179
+ | Key | Type | Default | Meaning |
180
+ | --- | --- | --- | --- |
181
+ | `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
+ | `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
+ | `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). |
185
+ | `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
+ | `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
+ | `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. |
188
+ | `effects.envelopes` | Array | `[]` | Effect envelopes by **convention**, so a whole architectural layer is bounded by one stanza instead of a per-method annotation. Each entry names exactly one of `match:` (a project-relative path glob over the files a class is defined in) or `namespace:` (a constant glob: `*` is one segment, `**` is one or more), plus `effect:` — the labels the selected classes may perform, or `[]` for pure. Nearest wins: a per-method annotation beats a class-level one, which beats a stanza; among stanzas the **first** match wins. See the example below. |
189
+ | `effects.tolerated` | Array | `[]` | Labels your project has decided not to act on. Applied when a bound or a difference is **judged**, never when a record is written, and **per origin**: `Logger#info` carries `io` and `telemetry` together, so `tolerated: [telemetry]` frees the `io` that came with the logging and leaves an `io.fs.read` from a `File.read` in the same method exactly where it was. `rigor check --no-tolerated-effects` (and the same flag on `rigor effects check`) re-judges as if the list were empty — the audit switch for the policy. |
190
+
191
+ Want the block on without writing it? The
192
+ [`effects-on-by-default`](02-cli-reference.md#rigor-show-bleedingedge)
193
+ bleeding-edge feature (`bleeding_edge: [effects-on-by-default]`) makes a
194
+ config with no `effects:` key at all behave as `effects: {}` — collection,
195
+ `effects.check`, and everything else on this page all turn on at their
196
+ defaults. It only fills an *absence*: write `effects: false` and you stay
197
+ opted out regardless, and any `effects:` block you do write is left exactly
198
+ as written. It previews what becomes the default at **v0.4.0**
199
+ ([ADR-103](../adr/103-effect-labels.md) § WD15).
200
+
201
+ #### Entry-point presets
202
+
203
+ `reach:` asks "whose footprint should the record cover", and on a framework the honest answer is a fact
204
+ about the framework rather than about your code. So the plugin that models it names the set, and you
205
+ adopt it:
206
+
207
+ ```yaml
208
+ plugins:
209
+ - rigor-railties
210
+ - rigor-activerecord
211
+ - rigor-actionpack
212
+
213
+ effects:
214
+ snapshot:
215
+ reach: [rails]
216
+ ```
217
+
218
+ `rails` — registered by [`rigor-railties`](plugins/rigor-rails.md) — stands for `app/controllers/**`,
219
+ `app/jobs/**`, `app/mailers/**` and `app/channels/**`: every way the outside world enters the
220
+ application. The component plugins also register the narrower `rails-controllers`, `rails-jobs`,
221
+ `rails-mailers` and `rails-channels` if you want one layer's footprint rather than all four. A preset is
222
+ just a name for globs; mixing the two in one list is fine.
223
+
224
+ Listing the plugin is what registers its preset, so `reach: [rails]` without `rigor-railties` in
225
+ `plugins:` is an error saying so.
226
+
227
+ #### Envelopes by convention
228
+
229
+ The `envelopes:` list is the surface that pays on day one, before you have written any RBS:
230
+
231
+ ```yaml
232
+ effects:
233
+ envelopes:
234
+ - match: "app/presenters/**/*.rb" # presenters render; they do not query
235
+ effect: []
236
+ - namespace: "Policies::*" # Policies::Edit, not Policies::Admin::Edit
237
+ effect: [mutate.local]
238
+ - match: "app/jobs/**/*.rb"
239
+ effect: [io]
240
+ tolerated: [telemetry]
241
+ ```
242
+
243
+ A stanza attaches its bound to every method of every class it selects, exactly as an annotation on the
244
+ class would. A method that exceeds it gets one `effect.envelope-exceeded` at its `def`, naming the
245
+ stanza it broke:
246
+
247
+ ```
248
+ app/presenters/user_presenter.rb:14:1: warning: Method Presenters::User#render performs io.fs.read
249
+ (File.read), but is declared effect: [] at .rigor.yml effects.envelopes[0], so io.fs.read exceeds
250
+ the envelope. [effect.envelope-exceeded]
251
+ ```
252
+
253
+ When one method is a deliberate exception, write the narrower envelope on it in RBS — nearest wins,
254
+ and no `except:` key is needed. When a whole *kind* of effect is acceptable everywhere, name it in
255
+ `tolerated:` instead of loosening every stanza.
256
+
257
+ If a layer is not ready for a bound yet, leave `envelopes:` out and start with the committed snapshot
258
+ ([`rigor effects update`](02-cli-reference.md#the-effect-snapshot)) — it needs no declaration at all,
259
+ and stanzas are the second step, written once the record has told you what the layer actually does.
177
260
  ### Reserved for other implementations
178
261
 
179
262
  | Key | Type | Default | Meaning |
@@ -58,6 +58,10 @@ carries no `documentation_url`.
58
58
  | <a id="rule-def-override-return-widened"></a>`def.override-return-widened` | An override's declared return type widens the inherited return (covariance). | high |
59
59
  | <a id="rule-def-override-param-narrowed"></a>`def.override-param-narrowed` | An override narrows an inherited parameter type (contravariance). | high |
60
60
  | <a id="rule-static-value-use-void"></a>`static.value-use.void` | A value recovered from an author-declared `-> void` return is used in value context (an assignment right-hand side, a call receiver, or a call argument). Off by default; reaches a run only through the `use-of-void-value` bleeding-edge feature (ADR-100). A bare-statement `void` call and a legitimate `top` value both stay silent. | high |
61
+ | <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
+ | <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
+ | <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. | — |
61
65
  | <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 |
62
66
  | <a id="rule-suppression-empty"></a>`suppression.empty` | A `# rigor:disable[-file]` comment lists no rules, so it suppresses nothing. | high |
63
67
  | <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 |
@@ -104,8 +108,8 @@ are:
104
108
 
105
109
  | Severity | Rules |
106
110
  | --- | --- |
107
- | `warning` | `call.unresolved-toplevel`, `def.ivar-write-mismatch`, `def.return-type-mismatch`, `def.override-visibility-reduced`, `def.override-return-widened`, `def.override-param-narrowed`, `flow.unreachable-branch`, `flow.always-truthy-condition`, `flow.dead-assignment`, `flow.duplicate-hash-key`, `flow.return-in-ensure`, `flow.shadowed-rescue-clause`, `suppression.unknown-rule`, `suppression.empty`, `suppression.unknown-marker` |
108
- | `info` | `flow.unreachable-clause`, `dump.type` |
111
+ | `warning` | `call.unresolved-toplevel`, `def.ivar-write-mismatch`, `def.return-type-mismatch`, `def.override-visibility-reduced`, `def.override-return-widened`, `def.override-param-narrowed`, `flow.unreachable-branch`, `flow.always-truthy-condition`, `flow.dead-assignment`, `flow.duplicate-hash-key`, `flow.return-in-ensure`, `flow.shadowed-rescue-clause`, `suppression.unknown-rule`, `suppression.empty`, `suppression.unknown-marker`, `effect.envelope-exceeded`, `effect.liskov-widened` |
112
+ | `info` | `flow.unreachable-clause`, `dump.type`, `effect.unknown-label`, `effect.annotations-unchecked` |
109
113
  | `off` | `call.self-undefined-method`, `static.value-use.void` |
110
114
 
111
115
  Everything else emits as `error`. For one rule under all three