rigortype 0.3.2 → 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 (179) 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 +14 -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/always_truthy_condition_collector.rb +8 -0
  33. data/lib/rigor/analysis/check_rules/declaration_sourced_guard.rb +53 -0
  34. data/lib/rigor/analysis/check_rules/rule_ids.rb +28 -2
  35. data/lib/rigor/analysis/check_rules/rule_walk.rb +7 -0
  36. data/lib/rigor/analysis/check_rules.rb +62 -22
  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 +163 -1
  44. data/lib/rigor/analysis/run_cache_key.rb +16 -0
  45. data/lib/rigor/analysis/runner/declaration_position.rb +54 -0
  46. data/lib/rigor/analysis/runner/effect_annotation_residual_pass.rb +108 -0
  47. data/lib/rigor/analysis/runner/effect_envelope_pass.rb +358 -0
  48. data/lib/rigor/analysis/runner/envelope_messages.rb +61 -0
  49. data/lib/rigor/analysis/runner/pool_coordinator.rb +68 -10
  50. data/lib/rigor/analysis/runner.rb +420 -10
  51. data/lib/rigor/analysis/severity_stamp.rb +2 -1
  52. data/lib/rigor/analysis/worker_session.rb +58 -2
  53. data/lib/rigor/bleeding_edge.rb +22 -0
  54. data/lib/rigor/cache/incremental_snapshot.rb +24 -5
  55. data/lib/rigor/cli/check_command.rb +13 -1
  56. data/lib/rigor/cli/check_runner_factory.rb +4 -1
  57. data/lib/rigor/cli/effects_command.rb +131 -0
  58. data/lib/rigor/cli/effects_diff_renderer.rb +123 -0
  59. data/lib/rigor/cli/effects_explain_renderer.rb +68 -0
  60. data/lib/rigor/cli/effects_renderer.rb +54 -0
  61. data/lib/rigor/cli/effects_report.rb +53 -0
  62. data/lib/rigor/cli/effects_snapshot_command.rb +256 -0
  63. data/lib/rigor/cli/unused_command.rb +288 -0
  64. data/lib/rigor/cli.rb +16 -0
  65. data/lib/rigor/configuration/severity_profile.rb +23 -0
  66. data/lib/rigor/configuration.rb +296 -14
  67. data/lib/rigor/effects/attribution.rb +76 -0
  68. data/lib/rigor/effects/catalog.rb +275 -0
  69. data/lib/rigor/effects/collector.rb +182 -0
  70. data/lib/rigor/effects/config_envelopes.rb +185 -0
  71. data/lib/rigor/effects/discharge.rb +69 -0
  72. data/lib/rigor/effects/effect_table.rb +92 -0
  73. data/lib/rigor/effects/entry_points.rb +93 -0
  74. data/lib/rigor/effects/envelope.rb +86 -0
  75. data/lib/rigor/effects/envelope_check.rb +172 -0
  76. data/lib/rigor/effects/envelope_index.rb +157 -0
  77. data/lib/rigor/effects/file_collection.rb +160 -0
  78. data/lib/rigor/effects/framework_units.rb +167 -0
  79. data/lib/rigor/effects/identity.rb +104 -0
  80. data/lib/rigor/effects/label.rb +77 -0
  81. data/lib/rigor/effects/label_intent.rb +73 -0
  82. data/lib/rigor/effects/label_set.rb +136 -0
  83. data/lib/rigor/effects/liskov_check.rb +167 -0
  84. data/lib/rigor/effects/local_ownership.rb +132 -0
  85. data/lib/rigor/effects/method_key.rb +40 -0
  86. data/lib/rigor/effects/mutation_classifier.rb +92 -0
  87. data/lib/rigor/effects/narrowing.rb +202 -0
  88. data/lib/rigor/effects/origin.rb +66 -0
  89. data/lib/rigor/effects/path_finder.rb +89 -0
  90. data/lib/rigor/effects/plugin_facts.rb +335 -0
  91. data/lib/rigor/effects/propagator.rb +275 -0
  92. data/lib/rigor/effects/registry.rb +193 -0
  93. data/lib/rigor/effects/scanner.rb +290 -0
  94. data/lib/rigor/effects/signature_sources.rb +74 -0
  95. data/lib/rigor/effects/snapshot.rb +380 -0
  96. data/lib/rigor/effects/snapshot_diff.rb +265 -0
  97. data/lib/rigor/effects/summary.rb +154 -0
  98. data/lib/rigor/effects/taint_cause.rb +38 -0
  99. data/lib/rigor/effects/unit_scan.rb +572 -0
  100. data/lib/rigor/effects/unknown_label_check.rb +86 -0
  101. data/lib/rigor/effects/unknown_label_report.rb +59 -0
  102. data/lib/rigor/effects.rb +47 -0
  103. data/lib/rigor/environment/rbs_loader.rb +34 -0
  104. data/lib/rigor/environment.rb +8 -1
  105. data/lib/rigor/flow_contribution/element.rb +1 -0
  106. data/lib/rigor/flow_contribution/merge_result.rb +5 -3
  107. data/lib/rigor/flow_contribution/merger.rb +16 -1
  108. data/lib/rigor/flow_contribution.rb +20 -4
  109. data/lib/rigor/inference/anonymous_meta_class.rb +81 -0
  110. data/lib/rigor/inference/expression_typer.rb +74 -17
  111. data/lib/rigor/inference/fork_map.rb +45 -11
  112. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +147 -17
  113. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +97 -2
  114. data/lib/rigor/inference/method_dispatcher.rb +41 -10
  115. data/lib/rigor/inference/optimistic_origin.rb +68 -3
  116. data/lib/rigor/inference/pre_eval_constants.rb +189 -0
  117. data/lib/rigor/inference/scope_indexer.rb +134 -31
  118. data/lib/rigor/inference/singleton_object_constant.rb +63 -0
  119. data/lib/rigor/inference/statement_evaluator.rb +25 -12
  120. data/lib/rigor/plugin/base.rb +24 -0
  121. data/lib/rigor/plugin/effect_attribution.rb +208 -0
  122. data/lib/rigor/plugin/effect_edge.rb +101 -0
  123. data/lib/rigor/plugin/effect_entry_points.rb +51 -0
  124. data/lib/rigor/plugin/first_party.rb +57 -0
  125. data/lib/rigor/plugin/isolation.rb +4 -1
  126. data/lib/rigor/plugin/manifest.rb +103 -3
  127. data/lib/rigor/plugin/node_rule_walk.rb +6 -0
  128. data/lib/rigor/plugin/registry.rb +48 -0
  129. data/lib/rigor/plugin.rb +1 -0
  130. data/lib/rigor/protection/closure_kill_oracle.rb +20 -1
  131. data/lib/rigor/rbs_extended/envelope_scanner.rb +160 -0
  132. data/lib/rigor/rbs_extended.rb +168 -0
  133. data/lib/rigor/reflection.rb +131 -30
  134. data/lib/rigor/scope.rb +62 -2
  135. data/lib/rigor/source/node_walker.rb +12 -0
  136. data/lib/rigor/type/anonymous_class_name.rb +40 -0
  137. data/lib/rigor/type/nominal.rb +5 -2
  138. data/lib/rigor/type/singleton.rb +4 -1
  139. data/lib/rigor/version.rb +1 -1
  140. data/lib/rigor.rb +1 -0
  141. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/effects.rb +90 -0
  142. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable.rb +7 -1
  143. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/effects.rb +85 -0
  144. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer.rb +8 -1
  145. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/effects.rb +152 -0
  146. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +9 -1
  147. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/effects.rb +156 -0
  148. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/recurring_scan.rb +113 -0
  149. data/plugins/rigor-activejob/lib/rigor/plugin/activejob.rb +68 -3
  150. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/effects.rb +186 -0
  151. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +12 -1
  152. data/plugins/rigor-activerecord/sig/active_record/relation.rbs +145 -0
  153. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/effects.rb +77 -0
  154. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage.rb +6 -1
  155. data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext/effects.rb +143 -0
  156. data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext.rb +9 -1
  157. data/plugins/rigor-activesupport-core-ext/sig/active_support/core_ext.rbs +385 -28
  158. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot.rb +30 -2
  159. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/authorization_scan.rb +151 -0
  160. data/plugins/rigor-pundit/lib/rigor/plugin/pundit.rb +46 -3
  161. data/plugins/rigor-rails/lib/rigor-rails.rb +1 -0
  162. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/effects.rb +47 -0
  163. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +7 -1
  164. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/acronyms.rb +81 -0
  165. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/helper_table.rb +13 -1
  166. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/routes_parser.rb +202 -15
  167. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +43 -3
  168. data/plugins/rigor-railties/lib/rigor/plugin/railties/effects.rb +171 -0
  169. data/plugins/rigor-railties/lib/rigor/plugin/railties.rb +62 -0
  170. data/plugins/rigor-railties/lib/rigor-railties.rb +3 -0
  171. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/schedule_scan.rb +122 -0
  172. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq.rb +56 -3
  173. data/sig/rigor/analysis/fact_store.rbs +1 -0
  174. data/sig/rigor/inference.rbs +2 -0
  175. data/sig/rigor/rbs_extended.rbs +6 -0
  176. data/sig/rigor/scope.rbs +4 -0
  177. data/sig/rigor.rbs +21 -1
  178. data/skills/rigor-unused-adjudicate/SKILL.md +90 -0
  179. metadata +83 -1
@@ -0,0 +1,256 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "optionparser"
4
+
5
+ require_relative "../analysis/runner"
6
+ require_relative "../cache/store"
7
+ require_relative "../configuration"
8
+ require_relative "../effects/discharge"
9
+ require_relative "../effects/path_finder"
10
+ require_relative "../effects/snapshot"
11
+ require_relative "../effects/snapshot_diff"
12
+ require_relative "command"
13
+ require_relative "effects_diff_renderer"
14
+ require_relative "effects_explain_renderer"
15
+ require_relative "options"
16
+
17
+ module Rigor
18
+ class CLI
19
+ # ADR-103 WD7 — the snapshot verbs of `rigor effects`: `update`, `check`, `diff` and `explain`.
20
+ #
21
+ # rigor effects update # write .rigor-effects.yml; commit it
22
+ # rigor effects check # 0 fresh, 1 drift — the CI gate
23
+ # rigor effects diff --baseline PATH # the same comparison, never gating
24
+ # rigor effects explain --symbol KEY # the shortest edge path behind a reach change
25
+ #
26
+ # None of them emits a diagnostic or enters `rigor check`'s stream: drift between two observations by
27
+ # the same tool is 100 % precise, and whether it *matters* is the reviewer's judgment — which is
28
+ # exactly what makes it a review artefact rather than a finding
29
+ # ([ADR-102](../adr/102-unused-code-reachability-report.md)).
30
+ #
31
+ # **The verbs take no paths.** The report does (`rigor effects lib/foo.rb`), because a report is a
32
+ # view; a snapshot is a record of the whole project, and one written over a subset would read as a
33
+ # project where every other method vanished. The analysed set is always the configured `paths:`.
34
+ class EffectsSnapshotCommand < Command
35
+ VERBS = %w[update check diff explain].freeze
36
+
37
+ FORMATS = %w[text json].freeze
38
+ private_constant :FORMATS
39
+
40
+ # The verbs that read a committed record, and so accept `--baseline PATH` —
41
+ # `--baseline <(git show origin/main:.rigor-effects.yml)` is the bot form.
42
+ COMPARING_VERBS = %w[check diff explain].freeze
43
+ private_constant :COMPARING_VERBS
44
+
45
+ def initialize(argv:, verb:, out: $stdout, err: $stderr)
46
+ super(argv: argv, out: out, err: err)
47
+ @verb = verb
48
+ end
49
+
50
+ # @return [Integer] 0 fresh / written, 1 on drift under `check`, 64 on a usage error.
51
+ def run
52
+ options = parse_options
53
+ return usage_error("unsupported format: #{options.fetch(:format)}") unless
54
+ FORMATS.include?(options[:format])
55
+ return usage_error("rigor effects #{@verb} takes no paths; it records the configured paths:") unless
56
+ @argv.empty?
57
+
58
+ execute(options)
59
+ rescue OptionParser::ParseError => e
60
+ usage_error(e.message)
61
+ end
62
+
63
+ private
64
+
65
+ def execute(options)
66
+ configuration = Configuration.load(options.fetch(:config)).with_effects_enabled
67
+ current = build_snapshot(configuration, full: options.fetch(:full))
68
+ return write(configuration, current) if @verb == "update"
69
+
70
+ compare(configuration, current, options)
71
+ end
72
+
73
+ # ---- update -------------------------------------------------
74
+
75
+ # Always writes — there is no generate / regenerate split, because a snapshot has no "already
76
+ # exists" hazard: it records observations, so overwriting it is the whole point of running it.
77
+ def write(configuration, snapshot)
78
+ path = configuration.effects_snapshot_path
79
+ File.write(path, snapshot.to_yaml)
80
+ @err.puts("rigor: wrote #{path} (#{snapshot.methods.size} method(s), #{snapshot.reach.size} reach entr" \
81
+ "#{snapshot.reach.size == 1 ? 'y' : 'ies'})")
82
+ @err.puts("rigor: note — `effects.snapshot.reach:` is empty, so the snapshot records `methods:` only.") if
83
+ configuration.effects_snapshot_reach.empty?
84
+ 0
85
+ end
86
+
87
+ # ---- check / diff / explain ---------------------------------
88
+
89
+ def compare(configuration, current, options)
90
+ path = options.fetch(:baseline) || configuration.effects_snapshot_path
91
+ recorded = load_recorded(path)
92
+ return CLI::EXIT_USAGE if recorded == :error
93
+
94
+ discharge = options.fetch(:no_tolerated) ? Effects::Discharge.none : effect_discharge(configuration)
95
+ diff = Effects::SnapshotDiff.compare(
96
+ recorded: recorded, current: current,
97
+ tolerated: options.fetch(:no_tolerated) ? [] : configuration.effects_tolerated,
98
+ gate: configuration.effects_snapshot_gate,
99
+ strict_tolerated: options.fetch(:strict_tolerated),
100
+ undischarged: Effects::Snapshot.undischarged_index(
101
+ snapshot: current, table: @table, discharge: discharge
102
+ )
103
+ )
104
+ return explain(diff, options) if @verb == "explain"
105
+
106
+ EffectsDiffRenderer.new(out: @out, path: path).render(diff, format: options.fetch(:format))
107
+ @verb == "check" && diff.drift? ? 1 : 0
108
+ end
109
+
110
+ def effect_discharge(configuration)
111
+ Effects::Discharge.new(configuration.effects_tolerated)
112
+ end
113
+
114
+ # A missing snapshot is drift with a routed message, not an error: it is the state every project is
115
+ # in before its first `update`, and the fix is one command away.
116
+ def load_recorded(path)
117
+ return nil unless File.exist?(path)
118
+
119
+ Effects::Snapshot.load(path)
120
+ rescue Effects::Snapshot::ParseError => e
121
+ @err.puts("rigor: effect snapshot load failed: #{path}: #{e.message}")
122
+ :error
123
+ end
124
+
125
+ # ---- explain ------------------------------------------------
126
+
127
+ def explain(diff, options)
128
+ rows = options.fetch(:symbol) ? rows_for_symbol(options.fetch(:symbol)) : rows_for_changes(diff)
129
+ EffectsExplainRenderer.new(out: @out).render(rows, format: options.fetch(:format))
130
+ 0
131
+ end
132
+
133
+ def rows_for_symbol(symbol)
134
+ entry = @table[symbol]
135
+ if entry.nil?
136
+ @err.puts("rigor: no effect unit named #{symbol}")
137
+ return []
138
+ end
139
+
140
+ reach_rows(symbol, entry.proven.to_a) + method_rows(symbol, entry.direct.proven.to_a)
141
+ end
142
+
143
+ # Every label a change introduced, explained once: a `reach:` change gets its shortest edge path, a
144
+ # `methods:` change gets the origin in the method's own body.
145
+ def rows_for_changes(diff)
146
+ diff.events.flat_map do |event|
147
+ labels = labels_of(event)
148
+ next [] if labels.empty?
149
+
150
+ event.table == "reach" ? reach_rows(event.symbol, labels) : method_rows(event.symbol, labels)
151
+ end.uniq
152
+ end
153
+
154
+ def labels_of(event)
155
+ return [event.label] if event.label
156
+ return [] unless event.category == Effects::SnapshotDiff::SYMBOL_ADDED
157
+
158
+ entry = @table[event.symbol]
159
+ entry ? entry.proven.to_a : []
160
+ end
161
+
162
+ def reach_rows(symbol, labels)
163
+ labels.filter_map do |label|
164
+ path = Effects::PathFinder.shortest(@table, symbol: symbol, label: label)
165
+ next if path.nil?
166
+
167
+ EffectsExplainRenderer::Row.new(table: "reach", symbol: symbol, label: label,
168
+ path: path.to_a, origin: path.origin)
169
+ end
170
+ end
171
+
172
+ def method_rows(symbol, labels)
173
+ entry = @table[symbol]
174
+ return [] if entry.nil?
175
+
176
+ labels.filter_map do |label|
177
+ origin = entry.direct.bundles.find { |_origin, set| set.include?(label) }&.first
178
+ next if origin.nil?
179
+
180
+ EffectsExplainRenderer::Row.new(table: "methods", symbol: symbol, label: label,
181
+ path: [].freeze, origin: origin.to_s)
182
+ end
183
+ end
184
+
185
+ # ---- the run ------------------------------------------------
186
+
187
+ # Same run the report takes: collection forced on for this invocation, sequential, and going through
188
+ # the ADR-45 whole-run result cache like `rigor check` — the diagnostics entry plus the #382 effects
189
+ # sidecar keyed beside it, so `rigor effects check` after `rigor check` under a configured `effects:`
190
+ # block is a warm hit plus the fixpoint.
191
+ def build_snapshot(configuration, full:)
192
+ runner = Analysis::Runner.new(
193
+ configuration: configuration,
194
+ cache_store: Cache::Store.new(root: configuration.cache_path),
195
+ collect_stats: false,
196
+ workers: 0
197
+ )
198
+ runner.run(configuration.paths)
199
+ @table = runner.effect_table
200
+ Effects::Snapshot.build(
201
+ table: @table, configuration: configuration, sources: runner.effect_sources, full: full,
202
+ registry: Effects::Registry.for_configuration(configuration,
203
+ plugin_facts: runner.effect_plugin_facts)
204
+ )
205
+ end
206
+
207
+ # ---- options ------------------------------------------------
208
+
209
+ def parse_options
210
+ options = { config: nil, format: "text", full: false, baseline: nil, symbol: nil,
211
+ strict_tolerated: false, no_tolerated: false }
212
+ OptionParser.new do |opts|
213
+ opts.banner = "Usage: rigor effects #{@verb} [options]"
214
+ Options.add_config(opts, options)
215
+ add_common_options(opts, options)
216
+ add_comparison_options(opts, options) if COMPARING_VERBS.include?(@verb)
217
+ if @verb == "explain"
218
+ opts.on("--symbol=KEY", "Explain this unit instead of the changed ones") do |value|
219
+ options[:symbol] = value
220
+ end
221
+ end
222
+ end.parse!(@argv)
223
+ options
224
+ end
225
+
226
+ # The policy switches are accepted by every verb, `update` included, and there they are deliberately
227
+ # inert: the record is **undischarged**, so `update --no-tolerated-effects` and `update` write
228
+ # byte-identical files. A flag that changed the record would make the file a function of policy and
229
+ # a policy change indistinguishable from a code change.
230
+ def add_common_options(opts, options)
231
+ opts.on("--format=FORMAT", "Output format: text (default) or json") { |value| options[:format] = value }
232
+ opts.on("--full", "Record every method, including the omitted trivial and synthesised ones") do
233
+ options[:full] = true
234
+ end
235
+ opts.on("--strict-tolerated", "Fail the gate on tolerated changes too (check / diff)") do
236
+ options[:strict_tolerated] = true
237
+ end
238
+ opts.on("--no-tolerated-effects", "Judge as if effects.tolerated: were empty (check / diff)") do
239
+ options[:no_tolerated] = true
240
+ end
241
+ end
242
+
243
+ def add_comparison_options(opts, options)
244
+ opts.on("--baseline=PATH", "Compare against PATH instead of effects.snapshot.path") do |value|
245
+ options[:baseline] = value
246
+ end
247
+ end
248
+
249
+ def usage_error(message)
250
+ @err.puts(message)
251
+ @err.puts("Usage: rigor effects #{@verb} [options]")
252
+ CLI::EXIT_USAGE
253
+ end
254
+ end
255
+ end
256
+ end
@@ -0,0 +1,288 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "optionparser"
4
+ require "json"
5
+
6
+ require_relative "../configuration"
7
+ require_relative "../analysis/path_expansion"
8
+ require_relative "../analysis/reachability/scan"
9
+ require_relative "../analysis/reachability/graph"
10
+ require_relative "../analysis/reachability/plugin_roots"
11
+ require_relative "../analysis/reachability/signature_scan"
12
+ require_relative "../analysis/reachability/project_files"
13
+ require_relative "options"
14
+ require_relative "command"
15
+ require_relative "probe_environment"
16
+
17
+ module Rigor
18
+ class CLI
19
+ # ADR-102 — executes `rigor unused`.
20
+ #
21
+ # Reports project constants that nothing reachable references. It is a REPORT, never a diagnostic: the
22
+ # measured precision of this signal is 7.0% on an adjudicated corpus target
23
+ # (`docs/notes/20260813-unused-constant-fp-baseline.md`), so its output is a review queue, not a defect
24
+ # list, and it never enters `rigor check`'s stream at any severity (WD1). Exits 0 whatever it finds.
25
+ class UnusedCommand < Command
26
+ USAGE = "Usage: rigor unused [options] [paths]"
27
+
28
+ # WD7 — the REFERENCE corpus is wider than the ANALYSIS corpus. `.rake` files sit inside `paths:` and
29
+ # reference project constants, but `PathExpansion::RUBY_GLOB` never reads them, which made them pure
30
+ # artifacts on two of three corpus targets. Harvesting references from a file is far cheaper than
31
+ # type-checking it, so the report takes the wider set.
32
+ REFERENCE_GLOB = "**/*.{rb,rake}"
33
+
34
+ # Templates and data files that carry class names as strings. A name here is NOT counted as a reference —
35
+ # a YAML value is far weaker evidence than a constant node, and treating it as proof would silently hide
36
+ # real dead code. It demotes the declaration to `cannot-decide` instead, with the file named, so the
37
+ # reader judges it (ADR-102 WD4).
38
+ TEMPLATE_GLOB = "**/*.{erb,haml,slim,yml,yaml,json}"
39
+
40
+ def run
41
+ options = parse_options
42
+ return CLI::EXIT_USAGE if options == :usage_error
43
+
44
+ configuration = Configuration.load(options.fetch(:config))
45
+ paths = @argv.empty? ? configuration.paths : @argv
46
+ declarations, references, dynamic_uses = scan(paths, configuration)
47
+ references.concat(signature_references(configuration))
48
+ dynamic_uses.concat(template_mentions(declarations))
49
+
50
+ contribution = Analysis::Reachability::PluginRoots.collect(configuration: configuration)
51
+ references.concat(plugin_references(contribution.references))
52
+ graph = Analysis::Reachability::Graph.new(
53
+ declarations: declarations, references: references, dynamic_uses: dynamic_uses,
54
+ root_fqns: root_fqns(declarations, options.fetch(:entry_points)) + contribution.roots,
55
+ foreign: foreign_predicate(configuration)
56
+ )
57
+ emit(graph.report, options, supply: root_supply(contribution.roots, declarations))
58
+ 0
59
+ end
60
+
61
+ private
62
+
63
+ def parse_options
64
+ options = { config: nil, format: "text", entry_points: [], limit: nil }
65
+ parser = OptionParser.new do |opts|
66
+ opts.banner = USAGE
67
+ Options.add_config(opts, options)
68
+ opts.on("--format=FORMAT", "Output format: text (default) or json") { |v| options[:format] = v }
69
+ opts.on("--entry-point=GLOB", "Treat declarations in files matching GLOB as roots (repeatable)") do |v|
70
+ options[:entry_points] << v
71
+ end
72
+ opts.on("--limit=N", Integer, "Print at most N candidates (default: all)") { |v| options[:limit] = v }
73
+ # WD5 — refuse rather than silently absorb. A reachability answer is sound only over a full run, and a
74
+ # user who asked for the fast path must not silently get the slow one.
75
+ opts.on("--incremental", "(unsupported)") { options[:incremental] = true }
76
+ end
77
+ parser.parse!(@argv)
78
+ return usage_error(parser) if options[:incremental]
79
+
80
+ options
81
+ rescue OptionParser::ParseError => e
82
+ @err.puts(e.message)
83
+ :usage_error
84
+ end
85
+
86
+ def usage_error(_parser)
87
+ @err.puts("rigor unused does not support --incremental: reachability is only sound over a whole-project " \
88
+ "run, so an incremental pass would report constants as unused merely because the files that " \
89
+ "reference them were served from cache. Re-run without --incremental.")
90
+ :usage_error
91
+ end
92
+
93
+ # Declarations come from the analysed paths; references additionally from the wider corpus (WD7).
94
+ def scan(paths, configuration)
95
+ declaration_files = Analysis::PathExpansion.ruby_files(paths, configuration.exclude_patterns).to_set
96
+ declarations = []
97
+ references = []
98
+ dynamic_uses = []
99
+ (declaration_files + reference_files(paths, configuration)).sort.each do |file|
100
+ result = read_and_scan(file, configuration)
101
+ next if result.nil?
102
+
103
+ declarations.concat(result.declarations) if declaration_files.include?(file)
104
+ references.concat(result.references)
105
+ dynamic_uses.concat(result.dynamic_uses)
106
+ end
107
+ [declarations, references, dynamic_uses]
108
+ end
109
+
110
+ # The reference corpus is the PROJECT, not the analysed paths. A constant declared in `lib/` is commonly
111
+ # referenced from `config/initializers`, a `.rake` task, or a spec — none of which are in `paths:` — and
112
+ # treating those as absent is what manufactured artifacts on two of three corpus targets in #345.
113
+ # Widening the *declaration* set the same way would cancel the gain (measured: +1 / −3 / +2 candidates,
114
+ # ADR-102 WD2), which is exactly why the two corpora are separated rather than both widened.
115
+ def reference_files(_paths, configuration)
116
+ relative = Analysis::Reachability::ProjectFiles.own(Dir.glob(REFERENCE_GLOB, base: Dir.pwd), Dir.pwd)
117
+ absolute = relative.map { |rel| File.expand_path(rel) }
118
+ Analysis::PathExpansion.reject_excluded(absolute, configuration.exclude_patterns).to_set
119
+ end
120
+
121
+ def read_and_scan(file, configuration)
122
+ Analysis::Reachability::Scan.call(path: file, source: File.read(file),
123
+ target_ruby: configuration.target_ruby)
124
+ rescue SystemCallError
125
+ nil
126
+ end
127
+
128
+ # A glob is matched against the declaration's path AND its path relative to the working directory:
129
+ # `configuration.paths` are expanded to absolute, so a user-written `--entry-point=lib/entry.rb` would
130
+ # otherwise never match anything and silently produce a root set of zero.
131
+ # A constant named only from the project's own `sig/` is referenced — the #345 probe reported exactly this
132
+ # as a false candidate until an RBS-side hook was added, because `Reflection.resolve_constant_type` is
133
+ # source-side only. A signature also DECLARES, though, and {Analysis::Reachability::SignatureScan} is
134
+ # what keeps the two apart (issue #363).
135
+ def signature_references(configuration)
136
+ Array(configuration.signature_paths).flat_map do |dir|
137
+ next [] unless File.directory?(dir)
138
+
139
+ Dir.glob(File.join(dir, "**/*.rbs")).flat_map { |file| Analysis::Reachability::SignatureScan.call(file) }
140
+ end
141
+ end
142
+
143
+ # #350 — a plugin's `:reachability_references` fact enters the graph exactly where a scanned file-level
144
+ # reference does: `from: nil` (so it seeds) carrying the role the plugin claimed. That is what keeps a
145
+ # class named only by a FactoryBot `class:` string in the `reachable only from tests` section instead of
146
+ # promoting it to production-reachable, which rooting it would have done silently.
147
+ #
148
+ # `path` is a synthetic marker rather than a file: the graph never reads a reference's path (rows are
149
+ # located from the DECLARATION site), and inventing a plausible-looking file path for a fact would be a
150
+ # worse lie than an obviously synthetic one.
151
+ def plugin_references(rows)
152
+ rows.map do |reference|
153
+ Analysis::Reachability::Scan::Reference.new(as_written: reference.name, nesting: [].freeze, from: nil,
154
+ role: reference.role, path: "(plugin)", line: 1)
155
+ end
156
+ end
157
+
158
+ def template_mentions(declarations)
159
+ names = declarations.map(&:fqn)
160
+ return [] if names.empty?
161
+
162
+ Analysis::Reachability::ProjectFiles.own(Dir.glob(TEMPLATE_GLOB, base: Dir.pwd), Dir.pwd).flat_map do |rel|
163
+ text = File.read(File.expand_path(rel)).scrub
164
+ names.filter_map do |fqn|
165
+ next unless text.include?(fqn)
166
+
167
+ Analysis::Reachability::Scan::DynamicUse.new(name: nil, prefix: fqn, site: nil,
168
+ reason: "named as a string in #{rel}",
169
+ path: rel, line: 1)
170
+ end
171
+ rescue SystemCallError, ArgumentError
172
+ []
173
+ end
174
+ end
175
+
176
+ def root_fqns(declarations, globs)
177
+ return [] if globs.empty?
178
+
179
+ matcher = Analysis::Reachability::ProjectFiles.method(:entry_point_match?)
180
+ cwd = "#{File.expand_path(Dir.pwd)}/"
181
+ declarations.filter_map do |d|
182
+ relative = d.path.delete_prefix(cwd)
183
+ d.fqn if globs.any? { |g| matcher.call(g, d.path) || matcher.call(g, relative) }
184
+ end
185
+ end
186
+
187
+ # WD6 — ownership means "declared FIRST here". Reopening a gem or stdlib class registers it as a project
188
+ # declaration, which produced three of redmine's artifacts from a single initializer. A name the bundled
189
+ # (non-project) environment already knows is not ours to call unused. The project's own `sig/` is
190
+ # deliberately excluded from this environment, so a project class that ships a signature stays owned.
191
+ def foreign_predicate(configuration)
192
+ env = Environment.for_project(libraries: configuration.libraries, signature_paths: [])
193
+ ->(fqn) { !env.singleton_for_name(fqn).nil? }
194
+ rescue StandardError
195
+ ->(_fqn) { false }
196
+ end
197
+
198
+ # ADR-102 § Consequences — "a root source that OVER-supplies silently hides real dead code, which is
199
+ # worse than one that under-supplies, so each plugin's contribution needs its own corpus check". A
200
+ # supplied root naming a constant the project does not declare is inert in the graph, but it is the
201
+ # observable symptom of a root source drifting away from the code — a renamed controller, an
202
+ # inflection the plugin gets wrong, a convention that stopped holding. Reporting the count makes that
203
+ # drift measurable on a real project instead of invisible.
204
+ Supply = Data.define(:supplied, :unmatched)
205
+ private_constant :Supply
206
+
207
+ def root_supply(plugin_roots, declarations)
208
+ declared = declarations.to_set(&:fqn)
209
+ Supply.new(supplied: plugin_roots.size, unmatched: plugin_roots.count { |fqn| !declared.include?(fqn) })
210
+ end
211
+
212
+ def emit(report, options, supply:)
213
+ @out.puts(options.fetch(:format) == "json" ? json(report, options, supply) : text(report, options, supply))
214
+ end
215
+
216
+ def json(report, options, supply)
217
+ JSON.pretty_generate(
218
+ declared: report.declared, reachable: report.reachable, roots: report.roots, edges: report.edges,
219
+ namespaces: report.namespaces,
220
+ plugin_roots: supply.supplied, plugin_roots_unmatched: supply.unmatched,
221
+ candidates: rows_json(report.candidates, options), test_only: rows_json(report.test_only, options),
222
+ undecidable: limited(report.undecidable, options).map do |u|
223
+ { name: u.fqn, path: u.path, line: u.line, reason: u.reason }
224
+ end
225
+ )
226
+ end
227
+
228
+ def rows_json(rows, options)
229
+ limited(rows, options).map { |c| { name: c.fqn, path: c.path, line: c.line } }
230
+ end
231
+
232
+ def text(report, options, supply)
233
+ lines = ["Reachability", " declared (project-owned): #{report.declared}",
234
+ " roots: #{report.roots}#{plugin_root_note(supply)}",
235
+ " reachable: #{report.reachable}",
236
+ " candidates: #{report.candidates.size}",
237
+ " reachable only from tests: #{report.test_only.size}",
238
+ " cannot decide: #{report.undecidable.size}",
239
+ " namespace-only (excluded): #{report.namespaces}"]
240
+ lines.concat(section("Candidates — nothing reachable references these", report.candidates, options))
241
+ lines.concat(section("Reachable only from test code — live test, dead production path",
242
+ report.test_only, options))
243
+ lines.concat(undecidable_section(report.undecidable, options))
244
+ lines << ""
245
+ lines << "These are CANDIDATES, not findings. The measured precision of this report on an adjudicated"
246
+ lines << "corpus target is 7% — every row needs a human to confirm it. Roots a framework supplies come"
247
+ lines << "from the project's plugins, so a framework Rigor has no plugin for still reads as an upper bound."
248
+ lines.join("\n")
249
+ end
250
+
251
+ # `(N from plugins, M matched nothing)` — omitted entirely when no plugin contributed, so a
252
+ # non-Rails project's report is unchanged. A non-zero `matched nothing` is the over-supply signal
253
+ # described on {#root_supply}: those roots claim constants this project does not declare.
254
+ def plugin_root_note(supply)
255
+ return "" if supply.supplied.zero?
256
+
257
+ " (#{supply.supplied} from plugins, #{supply.unmatched} matched no declaration)"
258
+ end
259
+
260
+ def undecidable_section(rows, options)
261
+ return [] if rows.empty?
262
+
263
+ out = ["", "Cannot decide — something can name these at runtime (#{rows.size})"]
264
+ limited(rows, options).each_with_index do |u, i|
265
+ out << format(" %<n>3d %<name>-52s %<path>s:%<line>d", n: i + 1, name: u.fqn, path: u.path,
266
+ line: u.line)
267
+ out << " #{u.reason}"
268
+ end
269
+ out
270
+ end
271
+
272
+ def section(title, rows, options)
273
+ return [] if rows.empty?
274
+
275
+ out = ["", "#{title} (#{rows.size})"]
276
+ limited(rows, options).each_with_index do |c, i|
277
+ out << format(" %<n>3d %<name>-52s %<path>s:%<line>d", n: i + 1, name: c.fqn, path: c.path, line: c.line)
278
+ end
279
+ out
280
+ end
281
+
282
+ def limited(candidates, options)
283
+ limit = options.fetch(:limit)
284
+ limit ? candidates.first(limit) : candidates
285
+ end
286
+ end
287
+ end
288
+ end
data/lib/rigor/cli.rb CHANGED
@@ -37,6 +37,7 @@ module Rigor
37
37
  "type-of" => :run_type_of,
38
38
  "trace" => :run_trace,
39
39
  "type-scan" => :run_type_scan,
40
+ "effects" => :run_effects,
40
41
  "explain" => :run_explain,
41
42
  "diff" => :run_diff,
42
43
  "sig-gen" => :run_sig_gen,
@@ -44,6 +45,7 @@ module Rigor
44
45
  "mcp" => :run_mcp,
45
46
  "baseline" => :run_baseline,
46
47
  "triage" => :run_triage,
48
+ "unused" => :run_unused,
47
49
  "coverage" => :run_coverage,
48
50
  "plugins" => :run_plugins,
49
51
  "plugin" => :run_plugin,
@@ -220,6 +222,12 @@ module Rigor
220
222
  TypeScanCommand.new(argv: @argv, out: @out, err: @err).run
221
223
  end
222
224
 
225
+ def run_effects
226
+ require_relative "cli/effects_command"
227
+
228
+ EffectsCommand.new(argv: @argv, out: @out, err: @err).run
229
+ end
230
+
223
231
  def run_explain
224
232
  require_relative "cli/explain_command"
225
233
 
@@ -262,6 +270,12 @@ module Rigor
262
270
  CLI::TriageCommand.new(argv: @argv, out: @out, err: @err).run
263
271
  end
264
272
 
273
+ def run_unused
274
+ require_relative "cli/unused_command"
275
+
276
+ CLI::UnusedCommand.new(argv: @argv, out: @out, err: @err).run
277
+ end
278
+
265
279
  def run_coverage
266
280
  require_relative "cli/coverage_command"
267
281
 
@@ -341,6 +355,8 @@ module Rigor
341
355
  type-of Print the inferred type at FILE:LINE:COL
342
356
  trace Replay how the engine typed FILE as a terminal animation
343
357
  type-scan Report Scope#type_of coverage across PATHs
358
+ effects Report each method's effect labels, and the committed effect snapshot
359
+ (ADR-103, opt-in; effects update/check/diff/explain)
344
360
  explain Print the description of one or all CheckRules
345
361
  diff Compare current diagnostics to a saved baseline JSON
346
362
  sig-gen Emit RBS skeletons inferred from .rb sources (ADR-14)
@@ -63,6 +63,19 @@ module Rigor
63
63
  # ADR-100 — a new required diagnostic (ADR-50 WD1), so it is `:off` in every shipped profile and
64
64
  # reaches a user only through the `use-of-void-value` bleeding-edge feature, which overrides this
65
65
  # to `:warning`.
66
+ # ADR-103 WD8 / #383 — opt-in twice over (the `effects:` block, then the author's own
67
+ # envelope directive), so it is never unsolicited noise and needs no bleeding-edge gate:
68
+ # `:warning` even under lenient, `:error` under strict.
69
+ "effect.envelope-exceeded" => :warning,
70
+ # ADR-103 WD14 / #386 — the inherited-bound reading rides its sibling's severities exactly:
71
+ # both-sides-authored (someone wrote the ancestor's envelope), and as strict as proven.
72
+ "effect.liskov-widened" => :warning,
73
+ # ADR-103 WD14 — `unknown-label` info / info / warning. It reports that a bound stopped
74
+ # bounding, never that code is wrong, so even `strict` stops at `:warning`.
75
+ "effect.unknown-label" => :info,
76
+ # The residual is advisory in every profile: it says a declaration is inert, and the fix is
77
+ # a config edit the author may deliberately not want.
78
+ "effect.annotations-unchecked" => :info,
66
79
  "static.value-use.void" => :off,
67
80
  # Opt-in author assertion: you only see it if you wrote a
68
81
  # `conforms-to` directive, so it stays a :warning even in
@@ -101,6 +114,10 @@ module Rigor
101
114
  "suppression.empty" => :warning,
102
115
  "suppression.unknown-marker" => :warning,
103
116
  "static.value-use.void" => :off,
117
+ "effect.envelope-exceeded" => :warning,
118
+ "effect.liskov-widened" => :warning,
119
+ "effect.unknown-label" => :info,
120
+ "effect.annotations-unchecked" => :info,
104
121
  "rbs_extended.unsatisfied-conformance" => :warning
105
122
  }.freeze,
106
123
  strict: {
@@ -135,6 +152,12 @@ module Rigor
135
152
  "suppression.unknown-marker" => :warning,
136
153
  # `:off` even under strict: the gate is `bleeding_edge:`, not the profile (ADR-50 WD1 / ADR-100).
137
154
  "static.value-use.void" => :off,
155
+ "effect.envelope-exceeded" => :error,
156
+ "effect.liskov-widened" => :error,
157
+ "effect.unknown-label" => :warning,
158
+ # `:info` even under strict: a residual that failed a build would punish the project for
159
+ # carrying an annotation it has not opted into checking, which is the opposite of the point.
160
+ "effect.annotations-unchecked" => :info,
138
161
  "rbs_extended.unsatisfied-conformance" => :error
139
162
  }.freeze
140
163
  }.freeze