rigortype 0.3.3 → 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 (171) 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 +860 -0
  16. data/data/effects/registry.yml +95 -0
  17. data/docs/handbook/07-rbs-and-extended.md +106 -5
  18. data/docs/manual/02-cli-reference.md +359 -2
  19. data/docs/manual/03-configuration.md +101 -1
  20. data/docs/manual/04-diagnostics.md +8 -3
  21. data/docs/manual/11-ci.md +37 -0
  22. data/docs/manual/12-caching.md +39 -0
  23. data/docs/manual/16-rbs-extended-annotations.md +90 -0
  24. data/docs/manual/18-removing-dead-code.md +326 -0
  25. data/docs/manual/19-effect-labels.md +671 -0
  26. data/docs/manual/README.md +10 -0
  27. data/docs/manual/ci-templates/README.md +9 -0
  28. data/docs/manual/plugins/rigor-actionmailer.md +5 -0
  29. data/docs/manual/plugins/rigor-activejob.md +47 -0
  30. data/docs/manual/plugins/rigor-factorybot.md +16 -0
  31. data/docs/manual/plugins/rigor-pundit.md +26 -0
  32. data/docs/manual/plugins/rigor-rails-routes.md +41 -2
  33. data/docs/manual/plugins/rigor-rspec-rails.md +5 -0
  34. data/docs/manual/plugins/rigor-rspec.md +8 -0
  35. data/docs/manual/plugins/rigor-sidekiq.md +54 -0
  36. data/lib/rigor/analysis/check_rules/rule_ids.rb +28 -2
  37. data/lib/rigor/analysis/incremental_session.rb +140 -6
  38. data/lib/rigor/analysis/reachability/graph.rb +244 -0
  39. data/lib/rigor/analysis/reachability/plugin_roots.rb +164 -0
  40. data/lib/rigor/analysis/reachability/project_files.rb +58 -0
  41. data/lib/rigor/analysis/reachability/scan.rb +241 -0
  42. data/lib/rigor/analysis/reachability/signature_scan.rb +134 -0
  43. data/lib/rigor/analysis/rule_catalog.rb +168 -3
  44. data/lib/rigor/analysis/run_cache_key.rb +16 -0
  45. data/lib/rigor/analysis/run_cache_probe.rb +69 -1
  46. data/lib/rigor/analysis/runner/declaration_position.rb +38 -0
  47. data/lib/rigor/analysis/runner/effect_annotation_residual_pass.rb +108 -0
  48. data/lib/rigor/analysis/runner/effect_envelope_pass.rb +358 -0
  49. data/lib/rigor/analysis/runner/envelope_messages.rb +61 -0
  50. data/lib/rigor/analysis/runner/pool_coordinator.rb +93 -10
  51. data/lib/rigor/analysis/runner/run_snapshots.rb +7 -4
  52. data/lib/rigor/analysis/runner.rb +429 -10
  53. data/lib/rigor/analysis/worker_session.rb +60 -2
  54. data/lib/rigor/bleeding_edge.rb +22 -0
  55. data/lib/rigor/cache/incremental_snapshot.rb +24 -5
  56. data/lib/rigor/cli/check_command.rb +13 -1
  57. data/lib/rigor/cli/check_runner_factory.rb +4 -1
  58. data/lib/rigor/cli/effects_command.rb +245 -0
  59. data/lib/rigor/cli/effects_diff_renderer.rb +127 -0
  60. data/lib/rigor/cli/effects_explain_renderer.rb +68 -0
  61. data/lib/rigor/cli/effects_renderer.rb +92 -0
  62. data/lib/rigor/cli/effects_report.rb +163 -0
  63. data/lib/rigor/cli/effects_snapshot_command.rb +275 -0
  64. data/lib/rigor/cli/unused_command.rb +288 -0
  65. data/lib/rigor/cli.rb +28 -1
  66. data/lib/rigor/configuration/severity_profile.rb +23 -0
  67. data/lib/rigor/configuration.rb +322 -28
  68. data/lib/rigor/configuration_error.rb +20 -0
  69. data/lib/rigor/effects/attribution.rb +76 -0
  70. data/lib/rigor/effects/catalog.rb +275 -0
  71. data/lib/rigor/effects/collector.rb +219 -0
  72. data/lib/rigor/effects/config_envelopes.rb +185 -0
  73. data/lib/rigor/effects/discharge.rb +69 -0
  74. data/lib/rigor/effects/effect_table.rb +92 -0
  75. data/lib/rigor/effects/entry_points.rb +139 -0
  76. data/lib/rigor/effects/envelope.rb +86 -0
  77. data/lib/rigor/effects/envelope_check.rb +172 -0
  78. data/lib/rigor/effects/envelope_index.rb +157 -0
  79. data/lib/rigor/effects/file_collection.rb +174 -0
  80. data/lib/rigor/effects/framework_units.rb +222 -0
  81. data/lib/rigor/effects/identity.rb +104 -0
  82. data/lib/rigor/effects/inline_anchor.rb +134 -0
  83. data/lib/rigor/effects/label.rb +77 -0
  84. data/lib/rigor/effects/label_intent.rb +73 -0
  85. data/lib/rigor/effects/label_set.rb +136 -0
  86. data/lib/rigor/effects/liskov_check.rb +167 -0
  87. data/lib/rigor/effects/local_ownership.rb +132 -0
  88. data/lib/rigor/effects/method_key.rb +40 -0
  89. data/lib/rigor/effects/mutation_classifier.rb +92 -0
  90. data/lib/rigor/effects/narrowing.rb +202 -0
  91. data/lib/rigor/effects/origin.rb +66 -0
  92. data/lib/rigor/effects/path_finder.rb +89 -0
  93. data/lib/rigor/effects/plugin_facts.rb +384 -0
  94. data/lib/rigor/effects/propagator.rb +335 -0
  95. data/lib/rigor/effects/registry.rb +200 -0
  96. data/lib/rigor/effects/scanner.rb +302 -0
  97. data/lib/rigor/effects/signature_sources.rb +90 -0
  98. data/lib/rigor/effects/snapshot.rb +396 -0
  99. data/lib/rigor/effects/snapshot_diff.rb +265 -0
  100. data/lib/rigor/effects/summary.rb +154 -0
  101. data/lib/rigor/effects/taint_cause.rb +39 -0
  102. data/lib/rigor/effects/unit_scan.rb +647 -0
  103. data/lib/rigor/effects/unknown_label_check.rb +86 -0
  104. data/lib/rigor/effects/unknown_label_report.rb +59 -0
  105. data/lib/rigor/effects.rb +47 -0
  106. data/lib/rigor/environment/rbs_loader.rb +34 -0
  107. data/lib/rigor/environment.rb +8 -1
  108. data/lib/rigor/flow_contribution/element.rb +1 -0
  109. data/lib/rigor/flow_contribution/merge_result.rb +5 -3
  110. data/lib/rigor/flow_contribution/merger.rb +16 -1
  111. data/lib/rigor/flow_contribution.rb +20 -4
  112. data/lib/rigor/inference/expression_typer.rb +18 -4
  113. data/lib/rigor/inference/pre_eval_constants.rb +189 -0
  114. data/lib/rigor/inference/scope_indexer.rb +15 -3
  115. data/lib/rigor/plugin/base.rb +28 -0
  116. data/lib/rigor/plugin/box.rb +18 -2
  117. data/lib/rigor/plugin/effect_ancestry.rb +80 -0
  118. data/lib/rigor/plugin/effect_attribution.rb +208 -0
  119. data/lib/rigor/plugin/effect_edge.rb +101 -0
  120. data/lib/rigor/plugin/effect_entry_points.rb +51 -0
  121. data/lib/rigor/plugin/first_party.rb +57 -0
  122. data/lib/rigor/plugin/isolation.rb +4 -1
  123. data/lib/rigor/plugin/manifest.rb +127 -3
  124. data/lib/rigor/plugin/registry.rb +55 -0
  125. data/lib/rigor/plugin.rb +1 -0
  126. data/lib/rigor/rbs_extended/envelope_scanner.rb +160 -0
  127. data/lib/rigor/rbs_extended.rb +188 -0
  128. data/lib/rigor/reflection.rb +131 -30
  129. data/lib/rigor/version.rb +1 -1
  130. data/lib/rigor.rb +1 -0
  131. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/effects.rb +90 -0
  132. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable.rb +7 -1
  133. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/effects.rb +85 -0
  134. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer.rb +8 -1
  135. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/effects.rb +152 -0
  136. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +9 -1
  137. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/effects.rb +156 -0
  138. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/recurring_scan.rb +113 -0
  139. data/plugins/rigor-activejob/lib/rigor/plugin/activejob.rb +68 -3
  140. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/effects.rb +186 -0
  141. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +12 -1
  142. data/plugins/rigor-activerecord/sig/active_record/relation.rbs +145 -0
  143. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/effects.rb +77 -0
  144. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage.rb +6 -1
  145. data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext/effects.rb +143 -0
  146. data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext.rb +9 -1
  147. data/plugins/rigor-activesupport-core-ext/sig/active_support/core_ext.rbs +401 -29
  148. data/plugins/rigor-devise/lib/rigor/plugin/devise.rb +27 -0
  149. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot.rb +30 -2
  150. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/authorization_scan.rb +151 -0
  151. data/plugins/rigor-pundit/lib/rigor/plugin/pundit.rb +46 -3
  152. data/plugins/rigor-rails/lib/rigor-rails.rb +1 -0
  153. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/effects.rb +47 -0
  154. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +7 -1
  155. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/acronyms.rb +81 -0
  156. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/helper_table.rb +13 -1
  157. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/routes_parser.rb +202 -15
  158. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +43 -3
  159. data/plugins/rigor-railties/lib/rigor/plugin/railties/effects.rb +171 -0
  160. data/plugins/rigor-railties/lib/rigor/plugin/railties.rb +62 -0
  161. data/plugins/rigor-railties/lib/rigor-railties.rb +3 -0
  162. data/plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline.rb +56 -1
  163. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/effects.rb +75 -0
  164. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/schedule_scan.rb +122 -0
  165. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq.rb +68 -3
  166. data/sig/rigor/analysis/fact_store.rbs +1 -0
  167. data/sig/rigor/inference.rbs +2 -0
  168. data/sig/rigor/rbs_extended.rbs +6 -0
  169. data/sig/rigor.rbs +17 -1
  170. data/skills/rigor-unused-adjudicate/SKILL.md +90 -0
  171. metadata +84 -1
@@ -0,0 +1,95 @@
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
+ # 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
+
33
+ labels:
34
+ # Steins v1, verbatim (rigortype/steins `docs/type-specification/effects.md`). Shared vocabulary:
35
+ # a policy naming these labels must read the same against a PHP service and a Rails app.
36
+ - exit
37
+ - ffi
38
+ - global.read
39
+ - global.write
40
+ - io
41
+ - io.db
42
+ - io.fs
43
+ - io.fs.read
44
+ - io.fs.write
45
+ - io.input
46
+ - io.ipc
47
+ - io.net
48
+ - io.net.http
49
+ - io.output
50
+ - io.output.buffer
51
+ - io.output.header
52
+ - io.output.stdout
53
+ - io.output.stderr
54
+ - io.process
55
+ - io.signal
56
+ - mutate
57
+ - mutate.local
58
+ - nondet
59
+ - nondet.random
60
+ - nondet.time
61
+
62
+ # Ruby leaves under `mutate` (Steins ADR-0055's names; ADR-103 WD14). `mutate.self` is self's
63
+ # state, `mutate.instance` a receiver that is neither self nor frame-owned, `mutate.static`
64
+ # class-level state. Unknown ownership taints rather than producing a bare `mutate`.
65
+ - mutate.self
66
+ - mutate.instance
67
+ - mutate.static
68
+
69
+ # Proposed shared core leaves, to raise with Steins: a `SELECT` is a read whichever language
70
+ # issued it, an `INSERT` a write, `BEGIN`/`COMMIT` neither.
71
+ - io.db.read
72
+ - io.db.write
73
+ - io.db.transaction
74
+
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).
87
+ - telemetry
88
+ - email.send
89
+ - job.enqueue
90
+ - cache.read
91
+ - cache.write
92
+
93
+ # Retired spellings: `old.label: [replacement, ...]`. Empty at vocabulary 1 — nothing has been
94
+ # renamed or removed yet.
95
+ 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,247 @@ 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. 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).
176
+
177
+ ```sh
178
+ rigor effects [PATH...] # the report
179
+ rigor effects {update,check,diff,explain} # the snapshot
180
+ ```
181
+
182
+ With no paths the report analyses the configured `paths:`. It
183
+ runs with effect collection enabled even when your `.rigor.yml`
184
+ carries no `effects:` block, so you can try it before
185
+ configuring anything; such an ad-hoc run shares no cache with
186
+ `rigor check`, because a run served from that cache would have
187
+ collected nothing.
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
+
196
+ Each line is one method, sorted by key:
197
+
198
+ ```
199
+ Tracer::Reporter#report: [io.output.stdout, nondet.time]
200
+ Tracer::Gateway#fetch: [] …?
201
+ dynamic-receiver (external_gem_without_rbs)
202
+ ```
203
+
204
+ The labels are the **transitive** footprint — the method's own
205
+ plus every project method it reaches. A ` …?` suffix means the
206
+ list is not exhaustive: some call could not be resolved, so the
207
+ reading is "these effects, and possibly more". The indented
208
+ lines say why. A taint is never a finding.
209
+
210
+ A ` ≤ [...]` clause after the labels is the **declared** lane:
211
+ what a source Rigor trusts but did not verify *claims* the
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
219
+ the proven labels do, so a controller two hops above an
220
+ attributed gem call carries the claim rather than only a
221
+ "possibly more"; a declared label the proven list already
222
+ covers is not printed twice.
223
+
224
+ ```
225
+ Gateways::Client#fetch: [] ≤ [io.net.http] …?
226
+ plugin-attribution (Acme::Http.get)
227
+ ```
228
+
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.
253
+
254
+ `--format=text|json` selects the output format; the JSON
255
+ payload additionally carries each method's *direct* summary
256
+ broken down per origin. `--config=PATH` picks a config file.
257
+ `--no-tolerated-effects` is accepted for symmetry with the
258
+ subcommands and does nothing here: the report is an
259
+ observation, and observations are undischarged.
260
+
261
+ What is collected and how it propagates is
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/>.
271
+
272
+ ### The effect snapshot
273
+
274
+ Four subcommands manage a committed record of the effects
275
+ Rigor observed — `.rigor-effects.yml`, the effect equivalent
276
+ of `db/schema.rb`:
277
+
278
+ ```sh
279
+ rigor effects update # write the snapshot; commit it
280
+ rigor effects check # 0 fresh, 1 drift — the CI gate
281
+ rigor effects diff # the same comparison, never gating
282
+ rigor effects explain # why an entry point reaches a label
283
+ ```
284
+
285
+ Unlike the report, the subcommands take no paths: a snapshot
286
+ records the whole project, and one written over a subset would
287
+ read as a project where every other method vanished. They all
288
+ accept `--config=PATH`, `--format=text|json` and `--full`;
289
+ `check`, `diff` and `explain` additionally accept
290
+ `--baseline=PATH` (compare against a file other than the
291
+ configured one — `--baseline <(git show
292
+ origin/main:.rigor-effects.yml)` in a bot),
293
+ `--strict-tolerated` and `--no-tolerated-effects`.
294
+
295
+ The file records two tables. `methods:` holds each method's
296
+ **direct** summary — what its own body does, block literals
297
+ and catalogued callees included, but not what the project
298
+ methods it calls do. That is deliberate: an entry moves only
299
+ when its own lines changed, so the diff stays attributable to
300
+ the pull request that caused it. `reach:` holds the
301
+ **transitive** footprint at the entry points
302
+ `effects.snapshot.reach:` names, where a leaf change is
303
+ supposed to fan out — the fan-out is the blast radius.
304
+
305
+ ```yaml
306
+ # .rigor-effects.yml — generated by `rigor effects update`. Commit it; review its diff.
307
+ schema: 1
308
+ rigor: "0.3.3"
309
+ vocabulary: 1
310
+ config_digest: "9ec82bfc…"
311
+ methods:
312
+ "PaymentGateway#charge":
313
+ effects: ["io.net.http", "telemetry"]
314
+ "Reports::Nightly#perform":
315
+ effects: ["io.db.read"]
316
+ exhaustive: false
317
+ unresolved: ["dynamic-send"]
318
+ reach:
319
+ "OrdersController#create":
320
+ effects: ["io.db.read", "io.net.http", "job.enqueue"]
321
+ ```
322
+
323
+ A method is left out when it is exhaustive and proves nothing
324
+ beyond `mutate.local`, when its summary is a synthesised
325
+ accessor's, and when it carries no label in either lane — a
326
+ row that would say only "not exhaustive, and here is why" is
327
+ something `rigor effects` and `rigor effects explain` answer
328
+ better than a committed record. `--full` records everything.
329
+ The header carries
330
+ the Rigor and vocabulary versions and a digest of your
331
+ `effects:` block, so an upgrade or a policy edit shows up as a
332
+ *regeneration event* rather than as silent reinterpretation.
333
+
334
+ Under `methods:` the declared lane is what that method's own
335
+ body claims; under `reach:` it is the transitive claim, like
336
+ the proven labels beside it.
337
+
338
+ `check` prints one line per difference: `+ label` / `- label`
339
+ for the proven lane, `≤+` / `≤-` for the declared one,
340
+ `materialised` when a declared label became proven,
341
+ `exhaustive → not` when someone introduced a call Rigor cannot
342
+ follow, and `+symbol` / `-symbol` for methods that appeared or
343
+ vanished (a rename is one of each, counted in the footer). A
344
+ removal read off a summary that is no longer exhaustive is
345
+ printed hedged — "possibly more" cannot prove an absence.
346
+
347
+ `effects.snapshot.gate:` decides what fails. `symmetric` (the
348
+ default) fails on any drift: a job that stopped enqueueing is
349
+ news too. `additions` is the ratchet — only growth fails.
350
+ `effects.tolerated:` is applied at judgment time, never while
351
+ writing: a difference confined to tolerated labels is printed
352
+ under a `tolerated:` heading and does not fail the gate unless
353
+ you pass `--strict-tolerated`. `--no-tolerated-effects` judges
354
+ as if the list were empty; on `update` it changes nothing,
355
+ because the record itself is undischarged.
356
+
357
+ Discharge works **per origin**, not per label. `Logger#info`
358
+ carries `io` and `telemetry` together, so `tolerated:
359
+ [telemetry]` frees the `io` that came with the logging — and
360
+ leaves an `io.fs.read` from a `File.read` two lines down
361
+ exactly where it was. An added label is discharged only when
362
+ every origin that introduces it is discharged.
363
+
364
+ ### Reviewing effect drift
365
+
366
+ Day one, run `rigor effects update` and commit the result. The
367
+ diff you are committing is the team's first map: which
368
+ controllers reach the network, which jobs write, which
369
+ presenters query.
370
+
371
+ Add `rigor effects check` to CI. From then on a pull request
372
+ that changes what the code *does* fails it with the reason
373
+ spelled out:
374
+
375
+ ```
376
+ Effect drift against .rigor-effects.yml:
377
+
378
+ methods:
379
+ PaymentGateway#charge + io.net.http
380
+
381
+ reach:
382
+ OrdersController#create + io.net.http
383
+
384
+ Run `rigor effects update` and commit the result if this change is intended.
385
+ ```
386
+
387
+ The author runs `rigor effects explain` to see the route —
388
+
389
+ ```
390
+ reach:
391
+ OrdersController#create → OrderService#place → PaymentGateway#charge → Net::HTTP.get [io.net.http]
392
+ ```
393
+
394
+ — then runs `rigor effects update` and commits the regenerated
395
+ file. **Intent is expressed by committing the regenerated
396
+ snapshot**, not by annotating the code; the reviewer reads the
397
+ two-line diff alongside the code change and either nods or
398
+ pushes back. A bundle update that moves effects with no code
399
+ diff of its own works the same way, and is exactly the case
400
+ worth seeing.
401
+
402
+ None of this is a diagnostic. `rigor check`'s output and exit
403
+ code are identical whether or not you use the snapshot, and
404
+ drift is never a finding: whether it *matters* is the
405
+ reviewer's judgment, which is what makes it a review artefact.
406
+
165
407
  ## `rigor explain`
166
408
 
167
409
  Print the catalogue entry for a diagnostic rule, or list every
@@ -300,6 +542,112 @@ rigor triage --format json | jq '[.selectors[] | select(.receiver == "String")]'
300
542
  The same `receiver_type` / `method_name` fields ride on each
301
543
  diagnostic of `rigor check --format json`, for per-site (rather than
302
544
  aggregated) grouping.
545
+ ## `rigor unused`
546
+
547
+ Report project constants that nothing reachable references — a
548
+ starting point for dead-code removal.
549
+
550
+ ```sh
551
+ rigor unused [paths] --entry-point='lib/cli.rb'
552
+ ```
553
+
554
+ **Read the output as a review queue, not a defect list.** On a
555
+ hand-adjudicated corpus target only **7% of the rows were genuinely
556
+ unused**; the rest were reachable by means static analysis cannot
557
+ see. That is why this is a separate command and never a `rigor check`
558
+ diagnostic — see
559
+ [ADR-102](https://github.com/rigortype/rigor/blob/master/docs/adr/102-unused-code-reachability-report.md).
560
+
561
+ Reachability is computed from **roots**, not by counting references,
562
+ so a cluster of classes that only reference each other is still
563
+ reported. Roots are the declarations in files matching
564
+ `--entry-point=GLOB` (repeatable), anything referenced at file level
565
+ by non-test code, and anything the project's **plugins** contribute.
566
+
567
+ Plugin-supplied roots are where framework knowledge enters. A Rails
568
+ controller is reached by name at request time, so nothing in the
569
+ project references it and a reference index cannot tell a live
570
+ controller from a dead one. `rigor-rails-routes` closes that by
571
+ reading `config/routes.rb` statically and naming every controller it
572
+ dispatches to — no Rails boot, and a route written under a
573
+ conditional (`get "/beta", to: "beta#index" if ENV["BETA"]`) is
574
+ visible where a booted app's route table would not show it. On two
575
+ Rails corpus targets this removed 56 % and 84 % of the candidate list.
576
+
577
+ `rigor unused` loads the same `plugins:` your `rigor check` run uses,
578
+ and prints how many roots came from them:
579
+
580
+ ```
581
+ roots: 404 (288 from plugins, 0 matched no declaration)
582
+ ```
583
+
584
+ `matched no declaration` is the number a plugin claimed that this
585
+ project does not declare — normally framework classes such as
586
+ `Rails::HealthController`. A number climbing away from zero means a
587
+ root source has drifted out of step with the code, which matters
588
+ because an over-claiming root source silently *hides* dead code. A
589
+ framework Rigor has no plugin for supplies no roots, so its
590
+ controllers still read as candidates.
591
+
592
+ `rigor-pundit` supplies the second kind of root: a policy class is
593
+ reached as `PostPolicy` from `authorize @post`, a name that appears
594
+ nowhere in the source. It publishes the policies your authorization
595
+ calls actually name — not every class under `app/policies`, because a
596
+ file's location is not evidence that anything authorizes against it.
597
+
598
+ `rigor-sidekiq` shows how narrow a root source has to be to be worth
599
+ having. A worker named as `class: "NightlyReportWorker"` in a cron
600
+ schedule is enqueued from YAML, so its name appears nowhere in the
601
+ code and it reads as dead — that name becomes a root. The queue list
602
+ in the same `sidekiq.yml` does not: a queue name is not a class name,
603
+ and inflecting one into a worker name would root a class on a naming
604
+ coincidence.
605
+
606
+ A plugin can also contribute a **reference** rather than a root, and
607
+ `rigor-factorybot` is why the distinction exists. `factory :user,
608
+ class: "Admin::User"` names a class as a string the scan cannot see,
609
+ so it is real evidence of use — but a factory lives in the test tree.
610
+ Supplied as a reference carrying the `test` role, the class leaves
611
+ the candidate list and appears under *Reachable only from test code*;
612
+ supplied as a root it would have been promoted to
613
+ production-reachable, and the more interesting finding would have
614
+ disappeared.
615
+
616
+ Most bundled plugins deliberately contribute nothing.
617
+ `MyJob.perform_later`, `MyMailer.welcome`, `MyWorker.perform_async`
618
+ and `RSpec.describe User` all write the class name as an ordinary
619
+ constant, which the report already records — and for the spec case,
620
+ records with the `test` role that makes the section above possible.
621
+ Each plugin's page says which choice it made and why.
622
+
623
+ Three things the report separates rather than merges:
624
+
625
+ - **Reachable only from test code** gets its own section — a class
626
+ used solely by its own spec is dead production code with a live
627
+ test, which is a more actionable finding than either bucket alone.
628
+ - **Constants something can name at runtime** are demoted to a
629
+ `cannot decide` section with the reason, never claimed as unused.
630
+ `"Foo".constantize` names `Foo` exactly, so it counts as an ordinary
631
+ reference; `"Foo::#{key}".constantize` instead marks everything
632
+ under `Foo` undecidable. A class name appearing as a string in a
633
+ `.yml`, `.json`, or template file demotes it the same way — that is
634
+ weaker evidence than a constant reference, so it is neither proof of
635
+ use nor grounds to call it dead.
636
+ - **Namespace modules** wrapping live code are excluded and counted,
637
+ because nothing references an intermediate namespace by itself. A
638
+ namespace whose contents are *all* unreachable is still reported.
639
+
640
+ Only class and module constants are reported at all; value constants
641
+ are omitted because they do not resolve across files.
642
+
643
+ References are harvested from a wider file set than the analysed
644
+ paths — `.rake` tasks, `config/`, specs and the project's own `sig/`
645
+ all count as references — because a constant used only from a Rake
646
+ task is not dead.
647
+
648
+ `--format json` emits the same data; `--limit=N` truncates the
649
+ printed lists. `--incremental` is refused: reachability is only sound
650
+ over a whole-project run.
303
651
 
304
652
  ## `rigor coverage`
305
653
 
@@ -654,6 +1002,7 @@ Queued today:
654
1002
  | `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
1003
  | `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
1004
  | `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. |
1005
+ | `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
1006
 
658
1007
  Once a feature **graduates** — it becomes the default at a major
659
1008
  ([ADR-50](../adr/50-release-engineering-and-stability-strategy.md) § WD7)
@@ -758,8 +1107,16 @@ diagnostics about Rigor's own inference cutoffs and memory — see
758
1107
  | Code | Meaning |
759
1108
  | --- | --- |
760
1109
  | `0` | Success — no error-severity diagnostics. |
761
- | `1` | Diagnostics found, or a per-command failure (parse error, missing file, new diagnostics on `diff`). |
762
- | `64` | Usage error — unknown command, bad flag, malformed argument. |
1110
+ | `1` | Diagnostics found, or a per-command failure (parse error, missing file, new diagnostics on `diff`, effect drift on `effects check`). |
1111
+ | `64` | Usage error — unknown command, bad flag, malformed argument, or a value in `.rigor.yml` the loader cannot proceed on. |
763
1112
 
764
1113
  `rigor triage` is the exception: it is advisory and always
765
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
+ ```