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,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,
@@ -82,6 +84,17 @@ module Rigor
82
84
  rescue OptionParser::ParseError => e
83
85
  @err.puts(e.message)
84
86
  EXIT_USAGE
87
+ rescue ConfigurationError => e
88
+ # #433 — a mistake in `.rigor.yml` is the same kind of event as a bad flag, and belongs in the same
89
+ # shape: one `rigor:` line naming the key, and the conventional usage exit code. It used to escape
90
+ # as an uncaught exception with a ~30-frame backtrace naming a file inside `lib/rigor/`, which reads
91
+ # as a crash even though the message it carried said exactly which key to fix.
92
+ #
93
+ # Caught here rather than per command because every command loads a configuration, and the ones
94
+ # that resolve a key later (`effects update` expanding `snapshot.reach:` once the plugins that
95
+ # register presets have loaded) would each need their own rescue at their own point.
96
+ @err.puts("rigor: #{e.message}")
97
+ EXIT_USAGE
85
98
  end
86
99
 
87
100
  private
@@ -158,7 +171,7 @@ module Rigor
158
171
  # - paths: directories scanned by `rigor check` and
159
172
  # `rigor type-scan` when no path is given.
160
173
  # - plugins: opt-in list of plugin gem names to load.
161
- # See https://github.com/rigortype/rigor/tree/main/plugins
174
+ # See https://github.com/rigortype/rigor/tree/master/plugins
162
175
  # for production plugins (rigor-activerecord, rigor-sorbet, …).
163
176
  # - disable: list of `rigor check` rule identifiers to
164
177
  # silence project-wide. The shipped rules are
@@ -220,6 +233,12 @@ module Rigor
220
233
  TypeScanCommand.new(argv: @argv, out: @out, err: @err).run
221
234
  end
222
235
 
236
+ def run_effects
237
+ require_relative "cli/effects_command"
238
+
239
+ EffectsCommand.new(argv: @argv, out: @out, err: @err).run
240
+ end
241
+
223
242
  def run_explain
224
243
  require_relative "cli/explain_command"
225
244
 
@@ -262,6 +281,12 @@ module Rigor
262
281
  CLI::TriageCommand.new(argv: @argv, out: @out, err: @err).run
263
282
  end
264
283
 
284
+ def run_unused
285
+ require_relative "cli/unused_command"
286
+
287
+ CLI::UnusedCommand.new(argv: @argv, out: @out, err: @err).run
288
+ end
289
+
265
290
  def run_coverage
266
291
  require_relative "cli/coverage_command"
267
292
 
@@ -341,6 +366,8 @@ module Rigor
341
366
  type-of Print the inferred type at FILE:LINE:COL
342
367
  trace Replay how the engine typed FILE as a terminal animation
343
368
  type-scan Report Scope#type_of coverage across PATHs
369
+ effects Report each method's effect labels, and the committed effect snapshot
370
+ (ADR-103, opt-in; effects update/check/diff/explain)
344
371
  explain Print the description of one or all CheckRules
345
372
  diff Compare current diagnostics to a saved baseline JSON
346
373
  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