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
@@ -20,6 +20,27 @@ def read_name: () -> String
20
20
 
21
21
  The plain `() -> String` stays the compatibility contract; the
22
22
  annotation tells Rigor the return is a non-empty string.
23
+
24
+ You may also write any of them **in a `.rb` file**, as an
25
+ rbs-inline `# @rbs %a{…}` comment — `%a{}` is rbs-inline's own
26
+ upstream grammar, and the annotation reaches Rigor on the same
27
+ path the generated signature does:
28
+
29
+ ```rb
30
+ # rbs_inline: enabled
31
+
32
+ class Reader
33
+ # @rbs %a{rigor:v1:return: non-empty-string}
34
+ # @rbs return: String
35
+ def read_name = "x"
36
+ end
37
+ ```
38
+
39
+ This needs the `rbs-inline` library installed; Rigor ingests
40
+ inline annotations by default when it is
41
+ ([ADR-93](../adr/93-default-rbs-inline-ingestion.md)). There is
42
+ no Rigor-only comment dialect: `# rigor:` comments remain
43
+ suppression-only.
23
44
  This page is the *operational* reference — the directives you can
24
45
  write and their syntax. For the normative rules (conflict
25
46
  handling, merging, provenance) see
@@ -135,6 +156,62 @@ directives on one class combine like an intersection of
135
156
  interfaces. The directive is purely additive — a class that
136
157
  already satisfies the interface type-checks with or without it.
137
158
 
159
+ ## Effect envelopes — bounding what a method *does*
160
+
161
+ Every directive above describes what a method returns. Two
162
+ describe what it *does*: `%a{pure}` — rbs' own purity
163
+ annotation, read as "nothing at all" — and
164
+ `%a{rigor:v1:effect <labels>}`, a comma-separated list of bare
165
+ [effect labels](../type-specification/effect-labels.md) the
166
+ method may not exceed. Both attach to a method or to a `class` /
167
+ `module`, where they distribute to that class's own methods
168
+ (nearest wins), and both tolerate mutating objects the method
169
+ itself allocated and never let out:
170
+
171
+ ```rbs
172
+ class UserRepository
173
+ %a{rigor:v1:effect io.db, nondet.time}
174
+ def find: (Integer) -> User
175
+
176
+ %a{pure}
177
+ def slug: (String) -> String
178
+ end
179
+ ```
180
+
181
+ The same two work as rbs-inline comments in a `.rb` file:
182
+
183
+ ```rb
184
+ # rbs_inline: enabled
185
+
186
+ class UserRepository
187
+ # @rbs %a{rigor:v1:effect io.db}
188
+ # @rbs id: Integer
189
+ # @rbs return: User
190
+ def find(id) = User.find(id)
191
+
192
+ # @rbs %a{pure}
193
+ # @rbs return: String
194
+ def slug(s) = s.strip.downcase
195
+ end
196
+ ```
197
+
198
+ Three things follow, and all three need an `effects:` block in
199
+ `.rigor.yml` — an annotation alone never turns effect collection
200
+ on:
201
+
202
+ - A method whose proven effects escape its bound fires
203
+ [`effect.envelope-exceeded`](04-diagnostics.md#rule-effect-envelope-exceeded),
204
+ positioned at the Ruby `def`.
205
+ - A label the registry does not recognise makes the **whole
206
+ annotation** read as unbounded — a typo can never manufacture
207
+ a finding — and, where the spelling is evidently meant to be a
208
+ label, says so as
209
+ [`effect.unknown-label`](04-diagnostics.md#rule-effect-unknown-label)
210
+ at the declaration.
211
+ - Without the block, one
212
+ [`effect.annotations-unchecked`](04-diagnostics.md#rule-effect-annotations-unchecked)
213
+ `:info` per run tells you the annotations are inert.
214
+
138
215
  ## Higher-kinded type directives
139
216
 
140
217
  Two declaration-level directives register and define the
@@ -0,0 +1,326 @@
1
+ # Removing dead code with `rigor unused`
2
+
3
+ This chapter is for the person handed an old codebase and asked what
4
+ can go. `rigor unused` turns "which of these 1,500 classes is dead?"
5
+ into a finite list of specific questions. It does not answer them.
6
+
7
+ The command reference — every flag, every output section — is in
8
+ [CLI reference](02-cli-reference.md#rigor-unused). This chapter is the
9
+ workflow around it.
10
+
11
+ **Before you start** you need a working `rigor check` setup: a config
12
+ file with `paths:`, and the plugins for your framework enabled. See
13
+ [Configuration](03-configuration.md) and [Using plugins](07-plugins.md).
14
+ `rigor unused` reads the same config and loads the same `plugins:`, and
15
+ the plugin half is not optional — the section on
16
+ [roots](#roots-are-the-lever) explains why a Rails app without
17
+ `rigor-rails-routes` produces a report that is mostly noise.
18
+
19
+ ## What it can and cannot tell you
20
+
21
+ `rigor unused` answers one question exactly: **which project classes
22
+ and modules does no reachable code name?** That is not the same
23
+ question as "what is dead", and the gap between them is the whole
24
+ subject of this chapter.
25
+
26
+ On one mid-sized Rails application, every surviving candidate was
27
+ hand-checked against the whole repository: **4 of 57 were genuinely
28
+ dead.** The other 53 were live code reached by means the analysis
29
+ cannot see.
30
+
31
+ Treat that ratio as an order of magnitude, not a constant. It is one
32
+ adjudication of one codebase; your rate depends on how much of your
33
+ framework has plugin coverage. What generalises is the shape: most
34
+ rows will be live code, so the output is a review queue and not a work
35
+ list. That is also why this ships as its own command rather than a
36
+ `rigor check` diagnostic — see [ADR-102][adr-102].
37
+
38
+ Three limits are worth knowing before you plan any work:
39
+
40
+ - **Naming is not calling.** A class whose constant is named once and
41
+ whose every method is dead counts as fully reachable. This is a
42
+ constant-level report; unused *methods* are not reported at all.
43
+ - **It under-reports, by an unknown amount.** An over-claiming root
44
+ source removes rows silently — see
45
+ [watching for drift](#running-it-as-a-campaign). There is no recall
46
+ figure, and no way to measure one short of adjudicating classes the
47
+ report never showed you.
48
+ - **Value constants** (`TIMEOUT = 30`) are omitted entirely; they do
49
+ not resolve across files, so every one would be a false row.
50
+ **Reopened gem or stdlib classes** are never candidates — reopening
51
+ `String` in an initializer declares it in your project, but it is
52
+ not yours to call unused.
53
+
54
+ ## The first run
55
+
56
+ ```sh
57
+ rigor unused
58
+ ```
59
+
60
+ It analyses the whole project every time; there is no incremental
61
+ mode, because reachability computed over a subset is not sound. Budget
62
+ roughly the cost of a full `rigor check`: about 14 seconds for 1,300
63
+ files, about 4 minutes for 11,000.
64
+
65
+ The summary tells you whether the rest of the report is worth reading:
66
+
67
+ ```
68
+ Reachability
69
+ declared (project-owned): 1497
70
+ roots: 428 (312 from plugins, 3 matched no declaration)
71
+ reachable: 1417
72
+ candidates: 45
73
+ reachable only from tests: 164
74
+ cannot decide: 25
75
+ namespace-only (excluded): 10
76
+ ```
77
+
78
+ `reachable`, `candidates`, `cannot decide` and `namespace-only`
79
+ partition `declared` — they sum to 1497. **`reachable only from tests`
80
+ is a subset of `reachable`**, not a fifth bucket, so do not add it in.
81
+
82
+ Read `roots` first. A root is a declaration something outside your code
83
+ reaches — an entry point, a route, a framework convention.
84
+ Reachability is computed *from* roots, so a thin root set inflates
85
+ everything below it. The pathological case is a Rails application with
86
+ `0 from plugins`: nothing in Ruby source names a controller, so every
87
+ controller you own appears dead. There is no published healthy ratio
88
+ to compare against; what you are checking is whether the number is
89
+ plausible for your framework, and `0 from plugins` on a framework app
90
+ never is.
91
+
92
+ ## Getting the number down
93
+
94
+ Two things change the count. Only one of them helps.
95
+
96
+ ### Roots are the lever
97
+
98
+ Wiring up root sources took one application's candidate list from 129
99
+ to 57, and another's from 278 to 45 — **56% and 84%**.
100
+
101
+ **Your plugins**, first and most of it. `rigor-rails-routes` reads
102
+ `config/routes.rb` statically and names every controller it dispatches
103
+ to, including routes written under a conditional; it follows
104
+ `draw(:admin)` into `config/routes/*.rb`. `rigor-pundit` publishes the
105
+ policies your `authorize` calls actually name. Mounted engines, and
106
+ Devise/Doorkeeper controller remappings, are not covered — those
107
+ controllers will still appear as candidates. If your framework has no
108
+ Rigor plugin at all, its entry points supply nothing, and that is a
109
+ gap in coverage rather than a finding about your code.
110
+
111
+ **Entry points you declare**, for anything no plugin reaches:
112
+
113
+ ```sh
114
+ rigor unused --entry-point='lib/cli.rb' --entry-point='lib/workers/**/*.rb'
115
+ ```
116
+
117
+ Repeatable. Each glob is matched against every declaration's path, and
118
+ roots every declaration in a matching file.
119
+
120
+ **File-level references**, free. Code that runs on load — an
121
+ initializer naming a class, a `.rake` task — roots what it names. This
122
+ happens whether or not those directories are in `paths:`, because
123
+ references are harvested from the whole project while declarations come
124
+ only from `paths:`.
125
+
126
+ ### Widening `paths:` is not the lever
127
+
128
+ The obvious first move is to analyse more of the project. Measured on
129
+ three applications, adding `config/` to `paths:` moved the candidate
130
+ count by **+0.7%, −0.8% and +3.4%** — nothing, in two directions.
131
+
132
+ The reason follows from the asymmetry above: references already came
133
+ from the whole project, so widening `paths:` adds *declarations* to
134
+ explain without adding references that explain them. Analyse `config/`
135
+ if you want it type-checked. It will not shorten this list.
136
+
137
+ ## Working the list
138
+
139
+ Four sections, needing four different decisions:
140
+
141
+ | Section | What it means | What to do |
142
+ | --- | --- | --- |
143
+ | **Reachable only from test code** | Live test, no production caller | Work these first |
144
+ | **Candidates** | Nothing reachable names it | Adjudicate — most are still live |
145
+ | **Cannot decide** | Something can name it at runtime | Read the reason; do not delete from here |
146
+ | **Namespace-only** | A module wrapping live code | Excluded from candidates; count only |
147
+
148
+ Work them in that order — which is not the order you *read* the
149
+ summary in, where `roots` comes first because it tells you whether to
150
+ trust the rest.
151
+
152
+ ### Test-only rows first
153
+
154
+ Not because the section is smaller — in the sample above it is 164
155
+ rows against 45 candidates — but because each row carries its own
156
+ evidence. A class with a passing spec and no production caller is
157
+ either dead or reached by a mechanism worth writing down, and either
158
+ way you can settle it from the spec file. On one application this
159
+ section held 22 policy classes whose only remaining caller was
160
+ `spec/policies/`. Those 22 were not adjudicated, so treat the section
161
+ as high-signal rather than high-yield; the yield is unmeasured.
162
+
163
+ ### Candidates: sort by how conventional the class is
164
+
165
+ A candidate row is its name and declaration site:
166
+
167
+ ```
168
+ Candidates — nothing reachable references these (45)
169
+ 1 Api::V1::Timelines::TopicController app/controllers/api/v1/timelines/topic_controller.rb:3
170
+ ```
171
+
172
+ The 53 false positives in the adjudicated run fell into a few recurring
173
+ shapes, and 28 of them were the first one. Recognising these lets you
174
+ skip most of a list quickly:
175
+
176
+ - **Framework naming conventions** — a helper module paired to a
177
+ controller by name, a Rails generator, a `ClassMethods` module
178
+ auto-extended by `ActiveSupport::Concern`, a decorator a gem applies
179
+ to a model by name, a join model reached through
180
+ `has_many :speakers_talks`, or a custom validator derived from an
181
+ option key (`validates :start_date, date: true` resolves the name
182
+ `DateValidator`). Nothing writes the name; the framework derives it.
183
+ Two of these clear whole clusters at once, so check them before
184
+ working through rows one at a time: **`include_all_helpers` defaults
185
+ to true**, so unless the app disables it every `app/helpers/*Helper`
186
+ is live; and a decorator gem's convention covers `app/decorators`
187
+ wholesale.
188
+ - **Named as a string in configuration** — a recurring-job schedule
189
+ (`config/recurring.yml`, `config/schedule.yml`, a `sidekiq.yml`
190
+ scheduler block), a queue definition, a class name in a settings
191
+ file. The scheduler resolves it by name, so no Ruby code has to, and
192
+ a scheduled job commonly has no `perform_later` call anywhere in the
193
+ repository. Check that the file is actually loaded — a `Procfile` or
194
+ deploy config passing `--recurring_schedule_file` is the confirming
195
+ evidence.
196
+ - **Called from a view** — `app/views/**/*.erb` is not Ruby and is not
197
+ analysed, so a helper used only from templates has no caller the
198
+ report can see. Grep the view tree before believing any helper row.
199
+ - **Self-registering classes** — a class whose body calls a
200
+ registration DSL (`add "link"`, `register :thing`). The evidence of
201
+ use is inside the class, not outside it.
202
+ - **Published extension points** — a base class subclassed only from
203
+ outside your repository. Dead from within, load-bearing without.
204
+ - **Scaffolding for a feature you never used** — `rails new` generates
205
+ `ApplicationCable::Connection` and `ApplicationCable::Channel`
206
+ whether or not you add a channel. If `app/channels` holds nothing
207
+ else, the honest finding is that the feature is unused, not the
208
+ class. Decide about the feature.
209
+
210
+ ### Cannot decide: read the reason, do not delete
211
+
212
+ These were demoted out of `candidates` because something can name the
213
+ class at runtime. Each row says what:
214
+
215
+ ```
216
+ 1 Handlers::Alpha lib/handlers.rb:2
217
+ constantize on an interpolated string (lib/dispatch.rb:14)
218
+ ```
219
+
220
+ `"Foo".constantize` names `Foo` exactly, so it counts as an ordinary
221
+ reference and never reaches this section. `"Foo::#{key}".constantize`
222
+ can only bound the namespace, so everything under `Foo` is demoted. A
223
+ class name appearing as a string in a `.yml`, `.json` or template file
224
+ demotes the same way — weaker evidence than a constant reference, and
225
+ neither proof of use nor grounds to call it dead.
226
+
227
+ A large section here is telling you that dynamic dispatch is
228
+ load-bearing in this codebase, which is the honest reason a deletion
229
+ campaign stops where it does.
230
+
231
+ ## Before you delete
232
+
233
+ A candidate row is the start of a case. Confirm each of these:
234
+
235
+ 1. **Search for spellings the report cannot match.** Do not re-grep
236
+ the fully-qualified name — a row would not be in `candidates` if
237
+ the FQN appeared anywhere the report reads. Grep instead for the
238
+ forms it cannot: the snake_cased autoload path
239
+ (`admin/reports_controller`), the demodulized leaf, any name
240
+ assembled by interpolation, and the view tree.
241
+ 2. **Check it is not reached by convention** — the shapes above.
242
+ 3. **Check nothing outside the repository references it.** For a gem,
243
+ a library, or an app with third-party extensions, "unused" means
244
+ "unused by callers I can see", and your public API is exactly what
245
+ you cannot see.
246
+ 4. **Read the git history** — but check it is real first. A class
247
+ added recently and never wired up is unfinished work rather than
248
+ dead code. In a shallow clone, or one whose history is all
249
+ dependency bumps, the "last change" date is an artifact and proves
250
+ nothing; say so instead of quoting a bot commit as the file's age.
251
+ 5. **Delete its tests with it.** A spec that must survive the class it
252
+ tests is testing nothing.
253
+
254
+ **Empty is not the same as dead.** A module the framework includes
255
+ everywhere but which declares no methods is live by every measure this
256
+ report uses, and still worth deleting — as generator residue, not as
257
+ dead code. Say which of the two you found; they justify different
258
+ amounts of care from a reviewer.
259
+
260
+ ### Making the case to someone else
261
+
262
+ The useful artifacts are the candidate row with its declaration site,
263
+ the result of step 1, and the date of the file's last substantive
264
+ change.
265
+
266
+ State the precision figure yourself rather than waiting to be asked. A
267
+ reviewer who knows the report is mostly false positives, and that you
268
+ checked this row by hand, has more reason to trust the specific claim —
269
+ not less. Be equally direct that the tool under-reports: it will not
270
+ have shown you everything.
271
+
272
+ ## Running it as a campaign
273
+
274
+ **Re-run after configuring, not after deleting.** The list moves far
275
+ more when you add a root source than when you remove a class.
276
+
277
+ **Watch `matched no declaration`.** This counts plugin-supplied roots
278
+ naming something your project does not declare:
279
+
280
+ ```
281
+ roots: 428 (312 from plugins, 3 matched no declaration)
282
+ ```
283
+
284
+ A few are normal — framework classes such as `Rails::HealthController`
285
+ are correctly named and correctly not yours. There is no threshold to
286
+ quote; what matters is the trend, because **an over-claiming root
287
+ source silently hides dead code**. Under-supply leaves rows on the list
288
+ where you can see them; over-supply removes them where you cannot.
289
+
290
+ **Expect drift in one direction.** New code adds candidates; new routes
291
+ and entry points remove them. If the count climbs over months with
292
+ nobody adding dead code, suspect a plugin stopped loading before you
293
+ suspect the codebase.
294
+
295
+ To track any of this you have to keep the numbers yourself — commit the
296
+ `--format json` output, or record the summary line. There is no
297
+ baseline file for this command.
298
+
299
+ ### If your project ships generated RBS
300
+
301
+ Signature files both declare and reference, and only the references
302
+ count. `class Talk < ApplicationRecord` in `sig/` references
303
+ `ApplicationRecord`; the `Talk` it declares is not a reference to
304
+ `Talk`. If it were, every class with a generated signature would root
305
+ itself and the report would be empty of exactly the rows you want.
306
+
307
+ That is worth knowing because it was wrong until recently, and the
308
+ symptom was silent: on one application 48 of 101 roots came from `sig/`
309
+ and eleven rows never appeared. If you are on a release before this was
310
+ fixed ([issue #363][issue-363]), compare against a run with
311
+ `signature_paths:` emptied — a large gap between the two is the tell.
312
+
313
+ ## Automating it
314
+
315
+ **Do not gate a build on this.** The command always exits 0 when it
316
+ runs, precisely so that it cannot become a check; the only non-zero
317
+ exit is a usage error, such as passing `--incremental`. A threshold on
318
+ the candidate count would fire on your own tooling as often as on your
319
+ code — the count climbing usually means a plugin stopped loading.
320
+
321
+ Running it as a **reporting** job is fine, and is the intended way to
322
+ notice the drift described above: run it on a schedule, publish the
323
+ JSON, and let a person read the trend.
324
+
325
+ [adr-102]: https://github.com/rigortype/rigor/blob/master/docs/adr/102-unused-code-reachability-report.md
326
+ [issue-363]: https://github.com/rigortype/rigor/issues/363
@@ -61,6 +61,11 @@ flag, key, or command that *acts* on it.
61
61
  and your tests into one safety net (`rigor coverage
62
62
  --protection [--mutation --with-tests --include-dynamic]`).
63
63
 
64
+ 18. [Removing dead code](18-removing-dead-code.md) — working
65
+ `rigor unused` as a campaign on an old codebase: why the list
66
+ starts long, which lever actually shortens it, what to confirm
67
+ before deleting, and how to make the case to a reviewer.
68
+
64
69
  ### Integration and operations
65
70
 
66
71
  9. [Editor integration](09-editor-integration.md) — wiring
@@ -64,6 +64,11 @@ plugins:
64
64
  - A brand-new view file does not invalidate the cached index until
65
65
  something the mailer file touches changes (the read-tracking
66
66
  trade-off).
67
+ - **No roots for [`rigor unused`](../02-cli-reference.md#rigor-unused).**
68
+ `MyMailer.welcome(...)` names the mailer as an ordinary constant,
69
+ which the report already records. Rooting every class under
70
+ `app/mailers` instead would mark a mailer nothing sends reachable
71
+ forever on no evidence.
67
72
 
68
73
  ## Plugin internals
69
74
 
@@ -36,8 +36,47 @@ plugins:
36
36
  config:
37
37
  job_search_paths: ["app/jobs"] # default
38
38
  job_base_classes: ["ApplicationJob", "ActiveJob::Base"] # default
39
+ recurring_paths: ["config/recurring.yml"] # default
39
40
  ```
40
41
 
42
+ `recurring_paths` are the schedule *files* — not directories —
43
+ behind the reachability roots below. The default is where Solid
44
+ Queue's recurring schedule conventionally lives; list your own path
45
+ if you keep it elsewhere.
46
+
47
+ ## Job roots for `rigor unused`
48
+
49
+ Solid Queue is the Active Job backend Rails ships by default from
50
+ 8.0, and a **recurring task names its job by string**:
51
+
52
+ ```yaml
53
+ # config/recurring.yml
54
+ production:
55
+ send_reminder:
56
+ class: "SendReminderJob"
57
+ schedule: "*/3 * * * *"
58
+ ```
59
+
60
+ That job runs every three minutes and there is no `perform_later`
61
+ for it anywhere, so the constant scan sees nothing and
62
+ [`rigor unused`](../02-cli-reference.md#rigor-unused) reports live
63
+ production code as possibly dead. This plugin supplies the jobs your
64
+ recurring schedule names, so they drop out of the candidate list.
65
+
66
+ **Every environment block is read**, not just `production:` — a job
67
+ scheduled in `staging:` is still live code. A flat, environment-less
68
+ document works too.
69
+
70
+ Only `class:` is read. A `command:` entry is inline Ruby
71
+ (`command: "SomeModel.cleanup"`), and parsing a constant out of an
72
+ arbitrary snippet would root a class on a guess, so it supplies
73
+ nothing. A `class:` naming a job the plugin never discovered is
74
+ dropped rather than published: a typo costs you a root instead of
75
+ quietly hiding a dead job.
76
+
77
+ The schedule is read with `YAML.safe_load`. Nothing boots Rails and
78
+ nothing loads Solid Queue.
79
+
41
80
  ## Limitations
42
81
 
43
82
  - **Direct-superclass match only.** `class WelcomeJob < BaseJob`
@@ -48,6 +87,14 @@ plugins:
48
87
  - **Positional arity only.** Required keyword arguments are
49
88
  recorded by the discoverer but not yet validated at the call
50
89
  site.
90
+ - **Roots only from a recurring schedule.** A job is rooted for
91
+ `rigor unused` when `config/recurring.yml` names it, and never for
92
+ existing under `app/jobs`: `MyJob.perform_later` names the job as
93
+ an ordinary constant the report already records, and rooting every
94
+ discovered job would mark an orphaned one reachable forever on no
95
+ evidence. A schedule loaded from Ruby rather than from a file under
96
+ `recurring_paths` supplies nothing, by the same "read only what is
97
+ written" rule.
51
98
 
52
99
  ## Plugin internals
53
100
 
@@ -54,6 +54,22 @@ plugins:
54
54
  # Minitest projects: ["test/factories"]
55
55
  ```
56
56
 
57
+ ## Factory references for `rigor unused`
58
+
59
+ `factory :user, class: "Admin::User"` names a class as a string, and
60
+ a bare `factory :user` relies on FactoryBot constantizing the factory
61
+ name. Neither is a constant reference, so
62
+ [`rigor unused`](../02-cli-reference.md#rigor-unused) cannot see them
63
+ and would report a factoried model as dead.
64
+
65
+ This plugin supplies those model classes — as **test-role
66
+ references**, not as roots. A class reached only through a factory
67
+ therefore leaves the candidate list and appears under **Reachable
68
+ only from test code** instead. That is deliberate: a model kept alive
69
+ by its factory and its spec and nothing else is dead production code
70
+ with a live test, and rooting factories would have hidden exactly
71
+ that finding.
72
+
57
73
  ## Limitations
58
74
 
59
75
  - **Literal arguments only** — `FactoryBot.create(name)` with a
@@ -48,8 +48,34 @@ plugins:
48
48
  config:
49
49
  policy_search_paths: ["app/policies"] # default
50
50
  policy_base_classes: ["ApplicationPolicy"] # default
51
+ authorization_call_paths: ["app/controllers"] # default
51
52
  ```
52
53
 
54
+ `authorization_call_paths` is where the plugin looks for the
55
+ `authorize` / `policy` / `policy_scope` calls behind the reachability
56
+ roots below. Widen it if you also authorize from `app/graphql` or
57
+ `app/services`; it defaults narrow because the walk is a parse per
58
+ file on every run.
59
+
60
+ ## Policy roots for `rigor unused`
61
+
62
+ `authorize @post` runs `PostPolicy#update?`, and the name
63
+ `PostPolicy` is never written down. So without help,
64
+ [`rigor unused`](../02-cli-reference.md#rigor-unused) reports every
65
+ policy in your app as possibly dead. This plugin supplies the
66
+ policies your code actually authorizes against, so they drop out of
67
+ the candidate list.
68
+
69
+ It publishes the policies **an authorization call names**, not every
70
+ class under `policy_search_paths`. A policy nothing authorizes
71
+ against stays in the report, which is the answer you wanted to see:
72
+ a root source that claims more than it can show hides real dead code
73
+ without telling you.
74
+
75
+ A policy the plugin cannot attribute to a call — a namespaced
76
+ `Admin::PostPolicy`, a record argument it cannot read — stays a
77
+ candidate rather than being guessed at.
78
+
53
79
  ## Limitations
54
80
 
55
81
  - **Direct-superclass match only.** `class AdminPostPolicy <
@@ -83,6 +83,44 @@ The parsed helper table is published as the `:helper_table`
83
83
  cross-plugin fact (ADR-9), which `rigor-actionpack` consumes to
84
84
  validate helper calls inside controllers.
85
85
 
86
+ ### Controller roots for `rigor unused`
87
+
88
+ The same parse also yields the **controller classes** your routes
89
+ dispatch to, published as the `:reachability_roots` fact that
90
+ [`rigor unused`](../02-cli-reference.md#rigor-unused) seeds its
91
+ reachability report with.
92
+
93
+ This matters because Rails reaches a controller by *name* at request
94
+ time. Nothing in your code references `Admin::UsersController`, so
95
+ without route knowledge every controller in the app reads as
96
+ unreferenced — on Mastodon that was 233 spurious candidates.
97
+
98
+ Controller names are composed the way Rails composes them:
99
+ `resources :users` inside `namespace :admin` is
100
+ `Admin::UsersController`; a singular `resource :profile` is served by
101
+ the plural `ProfilesController`; `scope module:` and a `module:`
102
+ option on a resource extend the namespace; `to: "posts#index"`,
103
+ `controller:`, and the hashrocket `get "help" => "help#show"` form
104
+ are all read. Acronyms declared in
105
+ `config/initializers/inflections.rb` are applied, so
106
+ `scope module: :activitypub` resolves to `ActivityPub::…`.
107
+
108
+ Two properties follow from parsing rather than booting, and are the
109
+ reason to prefer this over reading a running app's route table:
110
+
111
+ - **No Rails runtime is loaded.**
112
+ - **A route under a conditional is still a root.**
113
+ `get "/beta", to: "beta#index" if ENV["ENABLE_BETA"]` does not
114
+ appear in a booted app's route table unless the condition happened
115
+ to hold; both branches are visible to a static read.
116
+
117
+ **Not covered as roots** (deliberately, rather than half-covered):
118
+ controllers remapped by `devise_for … controllers:`, by
119
+ `use_doorkeeper … controllers:`, or served through a mounted Grape
120
+ API. Those mappings name controllers the plugin does not resolve, so
121
+ they may appear as `rigor unused` candidates even though they are
122
+ routed. Helper-name recognition for all three is unaffected.
123
+
86
124
  ## Limitations
87
125
 
88
126
  - **Statically unfoldable route definitions.** Helpers produced by
@@ -92,9 +130,10 @@ validate helper calls inside controllers.
92
130
  `unknown-helper`. Record those in a baseline, or
93
131
  `# rigor:disable` the line.
94
132
  - **Project-custom inflections** declared in
95
- `config/initializers/inflections.rb` are not yet ingested
133
+ `config/initializers/inflections.rb` are not yet fully ingested
96
134
  (ADR-39 slice 3); the standard ActiveSupport inflections are
97
- covered.
135
+ covered, and `inflect.acronym` declarations are read for
136
+ controller-class composition only.
98
137
 
99
138
  ## Plugin internals
100
139
 
@@ -62,6 +62,11 @@ The plugin has no configuration knobs.
62
62
  / `have_received` (overlap engine constant / undefined-method rules).
63
63
  - **Literal arguments only** — a status passed via a variable or method
64
64
  call is not statically checkable, so it's accepted silently.
65
+ - **No roots for [`rigor unused`](../02-cli-reference.md#rigor-unused)**,
66
+ for the reason [`rigor-rspec`](rigor-rspec.md#limitations) gives: a
67
+ spec's constant reference is already recorded with the `test` role,
68
+ and rooting it would delete the report's "reachable only from tests"
69
+ answer.
65
70
 
66
71
  ## Plugin internals
67
72
 
@@ -67,6 +67,14 @@ to enable project-wide alongside non-spec files.
67
67
  not flagged.
68
68
  - Constant validation (`RSpec.describe SomeClass`) is the
69
69
  engine's job, not this plugin's.
70
+ - **No roots for [`rigor unused`](../02-cli-reference.md#rigor-unused).**
71
+ `RSpec.describe User` is already recorded as a reference, stamped
72
+ with the `test` role because it came from a spec. Publishing spec
73
+ references as roots would strip that role, promote every
74
+ spec-referenced class to production-reachable, and delete the
75
+ report's **Reachable only from test code** section — the row that
76
+ tells you a class is dead production code with a live test. The
77
+ ordinary handling is the correct one.
70
78
 
71
79
  ## Related plugins
72
80