rigortype 0.4.4 → 0.4.6

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 (233) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +5 -5
  3. data/docs/handbook/11-sig-gen.md +100 -18
  4. data/docs/manual/02-cli-reference.md +85 -16
  5. data/docs/manual/03-configuration.md +12 -9
  6. data/docs/manual/04-diagnostics.md +17 -6
  7. data/docs/manual/05-inspecting-types.md +2 -2
  8. data/docs/manual/06-baseline.md +1 -1
  9. data/docs/manual/07-plugins.md +2 -1
  10. data/docs/manual/08-skills.md +7 -0
  11. data/docs/manual/10-mcp-server.md +17 -2
  12. data/docs/manual/12-caching.md +8 -1
  13. data/docs/manual/18-removing-dead-code.md +18 -7
  14. data/docs/manual/plugins/rigor-actionmailer.md +30 -0
  15. data/docs/manual/plugins/rigor-actionpack.md +30 -0
  16. data/docs/manual/plugins/rigor-devise.md +15 -0
  17. data/docs/manual/plugins/rigor-rspec.md +102 -1
  18. data/lib/rigor/analysis/buffer_binding.rb +14 -0
  19. data/lib/rigor/analysis/check_rules/clause_coverage.rb +134 -0
  20. data/lib/rigor/analysis/check_rules/inferred_param_guard.rb +30 -9
  21. data/lib/rigor/analysis/check_rules/ivar_write_collector.rb +142 -15
  22. data/lib/rigor/analysis/check_rules/lexical_method_sites.rb +115 -7
  23. data/lib/rigor/analysis/check_rules/rbs_receiver_surface.rb +253 -0
  24. data/lib/rigor/analysis/check_rules/rebinding_block_callees/dispatch.rb +112 -0
  25. data/lib/rigor/analysis/check_rules/rebinding_block_callees.rb +394 -0
  26. data/lib/rigor/analysis/check_rules/ruby_deprecations.rb +7 -0
  27. data/lib/rigor/analysis/check_rules/rule_ids.rb +3 -1
  28. data/lib/rigor/analysis/check_rules/source_arity.rb +32 -3
  29. data/lib/rigor/analysis/check_rules/unreachable_clause_collector.rb +64 -4
  30. data/lib/rigor/analysis/check_rules.rb +319 -21
  31. data/lib/rigor/analysis/dependency_recorder.rb +11 -0
  32. data/lib/rigor/analysis/dependency_source_inference/walker.rb +2 -1
  33. data/lib/rigor/analysis/edit_replay.rb +67 -0
  34. data/lib/rigor/analysis/incremental.rb +86 -0
  35. data/lib/rigor/analysis/incremental_run_slot/write_guard.rb +36 -18
  36. data/lib/rigor/analysis/incremental_session.rb +336 -46
  37. data/lib/rigor/analysis/path_expansion.rb +44 -1
  38. data/lib/rigor/analysis/reachability/graph.rb +90 -13
  39. data/lib/rigor/analysis/reachability/scan.rb +279 -39
  40. data/lib/rigor/analysis/require_relative_closure.rb +100 -0
  41. data/lib/rigor/analysis/rule_catalog.rb +22 -5
  42. data/lib/rigor/analysis/run_disclosure_credit.rb +80 -0
  43. data/lib/rigor/analysis/run_disclosure_ledger.rb +155 -0
  44. data/lib/rigor/analysis/runner/buffer_pool_dispatcher.rb +6 -2
  45. data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +11 -3
  46. data/lib/rigor/analysis/runner/pool_coordinator.rb +82 -16
  47. data/lib/rigor/analysis/runner/project_pre_passes.rb +45 -3
  48. data/lib/rigor/analysis/runner/scan_environment.rb +51 -0
  49. data/lib/rigor/analysis/runner.rb +222 -111
  50. data/lib/rigor/analysis/severity_stamp.rb +11 -0
  51. data/lib/rigor/analysis/worker_session.rb +24 -1
  52. data/lib/rigor/cache/descriptor.rb +40 -7
  53. data/lib/rigor/cache/engine_source.rb +3 -2
  54. data/lib/rigor/cache/incremental_snapshot.rb +49 -8
  55. data/lib/rigor/cache/rbs_descriptor.rb +2 -1
  56. data/lib/rigor/cache/store.rb +4 -3
  57. data/lib/rigor/cli/check_command.rb +93 -4
  58. data/lib/rigor/cli/command.rb +3 -1
  59. data/lib/rigor/cli/diff_command.rb +2 -1
  60. data/lib/rigor/cli/docs_command.rb +3 -2
  61. data/lib/rigor/cli/doctor_command.rb +1 -1
  62. data/lib/rigor/cli/explain_command.rb +3 -2
  63. data/lib/rigor/cli/lens_command.rb +5 -5
  64. data/lib/rigor/cli/mcp_command.rb +2 -1
  65. data/lib/rigor/cli/options.rb +20 -3
  66. data/lib/rigor/cli/plugin_command.rb +2 -1
  67. data/lib/rigor/cli/plugins_command.rb +7 -1
  68. data/lib/rigor/cli/plugins_renderer.rb +24 -2
  69. data/lib/rigor/cli/show_bleedingedge_command.rb +2 -1
  70. data/lib/rigor/cli/sig_gen_command.rb +73 -13
  71. data/lib/rigor/cli/skill_command.rb +2 -1
  72. data/lib/rigor/cli/skill_describe.rb +3 -2
  73. data/lib/rigor/cli/type_of_command.rb +2 -1
  74. data/lib/rigor/cli/type_of_project_scope.rb +27 -0
  75. data/lib/rigor/cli/unused_command.rb +10 -7
  76. data/lib/rigor/cli.rb +3 -2
  77. data/lib/rigor/config_audit.rb +72 -16
  78. data/lib/rigor/configuration/severity_profile.rb +20 -2
  79. data/lib/rigor/configuration.rb +43 -7
  80. data/lib/rigor/effects/signature_sources.rb +3 -1
  81. data/lib/rigor/environment/bundle_sig_discovery.rb +3 -2
  82. data/lib/rigor/environment/gated_signature_guard.rb +2 -1
  83. data/lib/rigor/environment/gated_signature_plan.rb +2 -1
  84. data/lib/rigor/environment/installed_gem_set.rb +2 -1
  85. data/lib/rigor/environment/missing_gem_constant_index.rb +4 -3
  86. data/lib/rigor/environment/rbs_loader.rb +136 -7
  87. data/lib/rigor/environment/required_features.rb +136 -29
  88. data/lib/rigor/environment/ruby_library_roots.rb +122 -0
  89. data/lib/rigor/environment.rb +4 -2
  90. data/lib/rigor/inference/block_call_timing.rb +7 -1
  91. data/lib/rigor/inference/builtins/method_catalog.rb +2 -1
  92. data/lib/rigor/inference/callee_mutation_chain.rb +115 -0
  93. data/lib/rigor/inference/captured_locals.rb +44 -13
  94. data/lib/rigor/inference/clause_widening.rb +90 -0
  95. data/lib/rigor/inference/deep_erasure.rb +138 -0
  96. data/lib/rigor/inference/def_return_typer.rb +159 -9
  97. data/lib/rigor/inference/definer_resolution.rb +101 -20
  98. data/lib/rigor/inference/error_info.rb +17 -3
  99. data/lib/rigor/inference/expression_typer.rb +97 -18
  100. data/lib/rigor/inference/global_write_census.rb +36 -1
  101. data/lib/rigor/inference/intervening_ivar_reset.rb +303 -0
  102. data/lib/rigor/inference/ivar_writer_census.rb +195 -0
  103. data/lib/rigor/inference/loop_value_record.rb +53 -0
  104. data/lib/rigor/inference/macro_block_self_type.rb +20 -6
  105. data/lib/rigor/inference/match_rebinding/frame.rb +19 -0
  106. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +278 -42
  107. data/lib/rigor/inference/method_dispatcher/kernel_dispatch.rb +6 -2
  108. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +50 -2
  109. data/lib/rigor/inference/method_dispatcher/set_folding.rb +53 -20
  110. data/lib/rigor/inference/method_dispatcher.rb +113 -19
  111. data/lib/rigor/inference/module_function_state/spellings.rb +64 -0
  112. data/lib/rigor/inference/module_function_state.rb +149 -42
  113. data/lib/rigor/inference/mutation_widening.rb +5 -2
  114. data/lib/rigor/inference/narrowing.rb +116 -11
  115. data/lib/rigor/inference/object_mixins.rb +13 -1
  116. data/lib/rigor/inference/parameter_inference_collector.rb +4 -26
  117. data/lib/rigor/inference/project_patched_methods.rb +168 -4
  118. data/lib/rigor/inference/project_patched_mixins.rb +162 -0
  119. data/lib/rigor/inference/project_patched_scanner.rb +51 -12
  120. data/lib/rigor/inference/project_patched_visibility.rb +214 -0
  121. data/lib/rigor/inference/project_seed_tables.rb +121 -0
  122. data/lib/rigor/inference/project_yielder.rb +128 -0
  123. data/lib/rigor/inference/refined_dispatch/constructed_class.rb +113 -0
  124. data/lib/rigor/inference/refined_dispatch/own_method.rb +41 -12
  125. data/lib/rigor/inference/reflective_ivar_write.rb +6 -6
  126. data/lib/rigor/inference/return_barrier.rb +16 -0
  127. data/lib/rigor/inference/scope_indexer/certainty.rb +9 -0
  128. data/lib/rigor/inference/scope_indexer/declared_ivar_types.rb +64 -0
  129. data/lib/rigor/inference/scope_indexer/declared_param_writes.rb +162 -0
  130. data/lib/rigor/inference/scope_indexer/unseen_ivar_writers.rb +133 -0
  131. data/lib/rigor/inference/scope_indexer.rb +1144 -197
  132. data/lib/rigor/inference/self_call_ivar_writes.rb +29 -12
  133. data/lib/rigor/inference/singleton_external_ahead.rb +64 -0
  134. data/lib/rigor/inference/singleton_hook_decline.rb +7 -0
  135. data/lib/rigor/inference/statement_evaluator.rb +1088 -213
  136. data/lib/rigor/inference/synthetic_method_index.rb +23 -0
  137. data/lib/rigor/inference/synthetic_method_scanner/project_members.rb +130 -0
  138. data/lib/rigor/inference/synthetic_method_scanner.rb +72 -12
  139. data/lib/rigor/inference/unthreaded_rebinds.rb +3 -1
  140. data/lib/rigor/language_server/buffer_table.rb +6 -0
  141. data/lib/rigor/language_server/diagnostic_publisher.rb +20 -6
  142. data/lib/rigor/language_server/project_context.rb +59 -3
  143. data/lib/rigor/language_server/server.rb +5 -3
  144. data/lib/rigor/lens/contract_reader.rb +45 -2
  145. data/lib/rigor/lens/document.rb +2 -1
  146. data/lib/rigor/lens/file_reader.rb +12 -2
  147. data/lib/rigor/lens/members.rb +16 -2
  148. data/lib/rigor/lens/run.rb +34 -7
  149. data/lib/rigor/mcp/server.rb +11 -3
  150. data/lib/rigor/plugin/base.rb +29 -13
  151. data/lib/rigor/plugin/io_boundary.rb +42 -6
  152. data/lib/rigor/plugin/isolation.rb +4 -3
  153. data/lib/rigor/plugin/manifest.rb +29 -3
  154. data/lib/rigor/plugin/registry.rb +38 -0
  155. data/lib/rigor/protection/discovery_seed.rb +64 -36
  156. data/lib/rigor/protection/mutation_cache.rb +8 -2
  157. data/lib/rigor/rooted_glob.rb +36 -0
  158. data/lib/rigor/scope/discovery_index.rb +5 -0
  159. data/lib/rigor/scope/resolution_chain/relevance.rb +14 -0
  160. data/lib/rigor/scope/resolution_chain.rb +40 -5
  161. data/lib/rigor/scope.rb +59 -7
  162. data/lib/rigor/sig_gen/alias_index.rb +2 -1
  163. data/lib/rigor/sig_gen/classification.rb +4 -1
  164. data/lib/rigor/sig_gen/generator.rb +465 -49
  165. data/lib/rigor/sig_gen/layout_index.rb +5 -2
  166. data/lib/rigor/sig_gen/observation_collector.rb +141 -12
  167. data/lib/rigor/sig_gen/rbs_validity.rb +1 -1
  168. data/lib/rigor/sig_gen/renderer.rb +16 -155
  169. data/lib/rigor/sig_gen/skip_reason_catalog.rb +11 -0
  170. data/lib/rigor/sig_gen/type_elaborator.rb +21 -5
  171. data/lib/rigor/sig_gen/write_reporter.rb +203 -0
  172. data/lib/rigor/sig_gen/write_result.rb +3 -1
  173. data/lib/rigor/sig_gen/writer.rb +5 -3
  174. data/lib/rigor/signature_path_audit.rb +4 -2
  175. data/lib/rigor/type/combinator.rb +4 -3
  176. data/lib/rigor/type/constant.rb +54 -3
  177. data/lib/rigor/type/hash_shape.rb +14 -9
  178. data/lib/rigor/type/struct_instance.rb +3 -2
  179. data/lib/rigor/version.rb +1 -1
  180. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_discoverer.rb +1 -1
  181. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_discoverer.rb +61 -44
  182. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer.rb +20 -1
  183. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_discoverer.rb +1 -1
  184. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/render_locals.rb +6 -1
  185. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/view_assigns.rb +1 -1
  186. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +13 -0
  187. data/plugins/rigor-active-model-serializers/lib/rigor/plugin/active_model_serializers/serializer_discoverer.rb +1 -1
  188. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/effects.rb +4 -3
  189. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_discoverer.rb +1 -1
  190. data/plugins/rigor-activejob/lib/rigor/plugin/activejob.rb +4 -2
  191. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +1 -1
  192. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/attachment_discoverer.rb +1 -1
  193. data/plugins/rigor-alba/lib/rigor/plugin/alba/resource_discoverer.rb +1 -1
  194. data/plugins/rigor-devise/lib/rigor/plugin/devise.rb +5 -3
  195. data/plugins/rigor-dry-types/lib/rigor/plugin/dry_types.rb +7 -6
  196. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_discoverer.rb +1 -1
  197. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/discoverer.rb +38 -9
  198. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/target_detector.rb +1 -1
  199. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/authorization_scan.rb +1 -1
  200. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/policy_discoverer.rb +1 -1
  201. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/locale_index.rb +7 -2
  202. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/locale_loader.rb +5 -5
  203. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +18 -17
  204. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +1 -1
  205. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/matcher_analyzer.rb +102 -11
  206. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/worker_discoverer.rb +1 -1
  207. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq.rb +4 -2
  208. data/plugins/rigor-sinatra/lib/rigor/plugin/sinatra.rb +4 -2
  209. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +2 -2
  210. data/plugins/rigor-typelizer/lib/rigor/plugin/typelizer/serializer_discoverer.rb +2 -2
  211. data/plugins/rigor-typelizer/lib/rigor/plugin/typelizer.rb +1 -1
  212. data/sig/rigor/analysis/baseline.rbs +3 -2
  213. data/sig/rigor/effects/file_collection.rbs +5 -6
  214. data/sig/rigor/effects/registry.rbs +8 -6
  215. data/sig/rigor/effects/summary.rbs +6 -5
  216. data/sig/rigor/environment.rbs +5 -3
  217. data/sig/rigor/inference/optimistic_origin.rbs +11 -0
  218. data/sig/rigor/inference.rbs +6 -0
  219. data/sig/rigor/plugin/additional_initializer.rbs +5 -5
  220. data/sig/rigor/plugin/effect_ancestry.rbs +6 -4
  221. data/sig/rigor/plugin/effect_attribution.rbs +25 -10
  222. data/sig/rigor/plugin/effect_edge.rbs +12 -5
  223. data/sig/rigor/plugin/effect_entry_points.rbs +8 -3
  224. data/sig/rigor/plugin/io_boundary.rbs +5 -2
  225. data/sig/rigor/plugin/manifest.rbs +16 -18
  226. data/sig/rigor/plugin/protocol_contract.rbs +10 -9
  227. data/sig/rigor/plugin/registry.rbs +1 -1
  228. data/sig/rigor/rooted_glob.rbs +7 -0
  229. data/sig/rigor/scope.rbs +34 -52
  230. data/sig/rigor/type.rbs +0 -6
  231. data/skills/rigor-doctor/references/01-checks.md +23 -11
  232. data/skills/rigor-type-oracle/references/01-oracle-commands.md +2 -1
  233. metadata +32 -1
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "../analysis/buffer_binding"
4
+ require_relative "../configuration_error"
4
5
 
5
6
  module Rigor
6
7
  class CLI
@@ -10,15 +11,31 @@ module Rigor
10
11
  # `--instead-of` flag pair and the buffer-binding resolution that validates it. That resolution used to be copied
11
12
  # verbatim into both `Rigor::CLI` and `TypeOfCommand`, so a fix to one (the paired-flag check, the readability
12
13
  # check, the error wording) could silently miss the other. Centralising it keeps the two editor-mode entry points in
13
- # lockstep.
14
+ # lockstep. It also owns the `--config` check (#1992), so every command and the MCP tools that pass `--config`
15
+ # read the same rule.
14
16
  module Options
15
17
  module_function
16
18
 
17
19
  # Defines the standard `--config=PATH` flag on `parser`, writing the path into `options[:config]`. Used by every
18
20
  # subcommand that loads a `.rigor.yml`; the few whose `--config` help text is intentionally bespoke (`diff`,
19
- # `mcp`, `show-bleedingedge`) keep their own `parser.on` rather than this shared wording.
21
+ # `mcp`, `show-bleedingedge`) keep their own `parser.on` and call {.config_path} for the same check.
20
22
  def add_config(parser, options)
21
- parser.on("--config=PATH", "Path to the Rigor configuration file") { |value| options[:config] = value }
23
+ parser.on("--config=PATH", "Path to the Rigor configuration file") do |value|
24
+ options[:config] = config_path(value)
25
+ end
26
+ end
27
+
28
+ # The `--config` value, checked where the flag is read (#1992). A named file that is not there is an error, not
29
+ # a request for the defaults: the run would otherwise analyse under a configuration the caller did not ask for,
30
+ # and an MCP caller would get a result for it with no sign anything was missing. Auto-discovery, with no
31
+ # `--config` given, is not affected.
32
+ def config_path(value)
33
+ raise ConfigurationError, "empty config path" if value.strip.empty?
34
+ raise ConfigurationError, "config path is a directory: #{value}" if File.directory?(value)
35
+ raise ConfigurationError, "config file not found: #{value}" unless File.exist?(value)
36
+ raise ConfigurationError, "config file not readable: #{value}" unless File.readable?(value)
37
+
38
+ value
22
39
  end
23
40
 
24
41
  # Defines the `--tmp-file` / `--instead-of` editor-mode flag pair on `parser`, writing into `options`.
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "command"
4
+ require_relative "../rooted_glob"
4
5
 
5
6
  module Rigor
6
7
  class CLI
@@ -204,7 +205,7 @@ module Rigor
204
205
  candidate = File.join(dir, "lib", "#{plugin.fetch(:name)}.rb")
205
206
  return candidate if File.file?(candidate)
206
207
 
207
- Dir.glob(File.join(dir, "lib", "*.rb")).min
208
+ RootedGlob.glob(dir, "lib/*.rb").min
208
209
  end
209
210
 
210
211
  def docker_note
@@ -13,6 +13,7 @@ require_relative "../reflection"
13
13
  require_relative "../type/combinator"
14
14
  require_relative "plugins_renderer"
15
15
  require_relative "command"
16
+ require_relative "../rooted_glob"
16
17
 
17
18
  module Rigor
18
19
  class CLI
@@ -232,6 +233,8 @@ module Rigor
232
233
  signature_paths: signature_path_rows(plugin),
233
234
  open_receivers: manifest.open_receivers.dup,
234
235
  owns_receivers: manifest.owns_receivers.dup,
236
+ per_invocation_classes: manifest.per_invocation_classes.dup,
237
+ per_invocation_non_action_macros: manifest.per_invocation_non_action_macros.map(&:to_s),
235
238
  produces: manifest.produces.map(&:to_s),
236
239
  consumes: manifest.consumes.map { |c| "#{c.plugin_id}/#{c.name}#{'?' if c.optional}" },
237
240
  source_rbs_synthesizer: !manifest.source_rbs_synthesizer.nil?
@@ -264,6 +267,8 @@ module Rigor
264
267
  signature_paths: [],
265
268
  open_receivers: [],
266
269
  owns_receivers: [],
270
+ per_invocation_classes: [],
271
+ per_invocation_non_action_macros: [],
267
272
  produces: [],
268
273
  consumes: [],
269
274
  block_as_methods: 0,
@@ -297,7 +302,7 @@ module Rigor
297
302
  def rbs_file_count(dir)
298
303
  return 0 unless File.directory?(dir)
299
304
 
300
- Dir.glob(File.join(dir, "**", "*.rbs")).size
305
+ RootedGlob.glob(dir, "**/*.rbs").size
301
306
  end
302
307
 
303
308
  def entry_gem_name(entry)
@@ -344,6 +349,7 @@ module Rigor
344
349
  config: {},
345
350
  signature_paths: [],
346
351
  open_receivers: [], owns_receivers: [], produces: [], consumes: [],
352
+ per_invocation_classes: [], per_invocation_non_action_macros: [],
347
353
  block_as_methods: 0, heredoc_templates: 0, trait_registries: 0,
348
354
  type_node_resolvers: 0,
349
355
  hkt_registrations: 0, hkt_definitions: 0,
@@ -185,12 +185,26 @@ module Rigor
185
185
  lines = []
186
186
  lines << " open_receivers: #{row[:open_receivers].join(', ')}" if row[:open_receivers].any?
187
187
  lines << " owns_receivers: #{row[:owns_receivers].join(', ')}" if row[:owns_receivers].any?
188
+ lines.concat(per_invocation_lines(row))
188
189
  lines << " produces: #{row[:produces].join(', ')}" if row[:produces].any?
189
190
  lines << " consumes: #{row[:consumes].join(', ')}" if row[:consumes].any?
190
191
  lines.concat(narrow_protocol_lines(row))
191
192
  lines
192
193
  end
193
194
 
195
+ # ADR-125 — the per-invocation classes a plugin vouches for, and the macros whose arguments name a method that is
196
+ # not one of their actions.
197
+ def per_invocation_lines(row)
198
+ lines = []
199
+ if row[:per_invocation_classes].any?
200
+ lines << " per_invocation_classes: #{row[:per_invocation_classes].join(', ')}"
201
+ end
202
+ if row[:per_invocation_non_action_macros].any?
203
+ lines << " per_invocation_non_action_macros: #{row[:per_invocation_non_action_macros].join(', ')}"
204
+ end
205
+ lines
206
+ end
207
+
194
208
  # ADR-37 narrow extension protocols (node_rule / dynamic_return / narrowing_facts). Surfaced in the full report
195
209
  # alongside the declarative surfaces; `--capabilities` is the focused view.
196
210
  def narrow_protocol_lines(row)
@@ -249,8 +263,6 @@ module Rigor
249
263
  "signature_paths" => row[:signature_paths].map do |sp|
250
264
  { "path" => sp[:path], "exists" => sp[:exists], "rbs_files" => sp[:rbs_files] }
251
265
  end,
252
- "open_receivers" => row[:open_receivers],
253
- "owns_receivers" => row[:owns_receivers],
254
266
  "produces" => row[:produces],
255
267
  "consumes" => row[:consumes],
256
268
  "block_as_methods" => row[:block_as_methods],
@@ -265,6 +277,16 @@ module Rigor
265
277
  "dynamic_return_receivers" => row[:dynamic_return_receivers],
266
278
  "narrowing_facts_methods" => row[:narrowing_facts_methods],
267
279
  "load_error" => row[:load_error]
280
+ }.merge(receiver_json(row))
281
+ end
282
+
283
+ # ADR-125 — the per-invocation keys are additive; an empty array when the plugin declares none.
284
+ def receiver_json(row)
285
+ {
286
+ "open_receivers" => row[:open_receivers],
287
+ "owns_receivers" => row[:owns_receivers],
288
+ "per_invocation_classes" => row[:per_invocation_classes],
289
+ "per_invocation_non_action_macros" => row[:per_invocation_non_action_macros]
268
290
  }
269
291
  end
270
292
 
@@ -6,6 +6,7 @@ require "optionparser"
6
6
  require_relative "../bleeding_edge"
7
7
  require_relative "../configuration"
8
8
  require_relative "command"
9
+ require_relative "options"
9
10
 
10
11
  module Rigor
11
12
  class CLI
@@ -44,7 +45,7 @@ module Rigor
44
45
  options[:format] = fmt
45
46
  end
46
47
  opt.on("--config=PATH", "Path to a .rigor.yml (default: auto-discovery).") do |path|
47
- options[:config] = path
48
+ options[:config] = Options.config_path(path)
48
49
  end
49
50
  end.parse!(@argv)
50
51
  options
@@ -3,6 +3,7 @@
3
3
  require "optionparser"
4
4
 
5
5
  require_relative "../configuration"
6
+ require_relative "../analysis/path_expansion"
6
7
  require_relative "../effects/config_envelopes"
7
8
  require_relative "../effects/registry"
8
9
  require_relative "options"
@@ -55,7 +56,11 @@ module Rigor
55
56
  configuration = Configuration.load(options.fetch(:config))
56
57
  paths = @argv.empty? ? configuration.paths : @argv
57
58
 
58
- observations = collect_observations(configuration, options)
59
+ path_errors = path_diagnostics(paths, configuration)
60
+ report_path_errors(path_errors, options)
61
+
62
+ observation_roots = observation_roots(configuration, options)
63
+ observations = collect_observations(configuration, options, observation_roots, paths)
59
64
  generator = SigGen::Generator.new(configuration: configuration, paths: paths,
60
65
  observations: observations,
61
66
  include_private: options.fetch(:include_private),
@@ -68,15 +73,20 @@ module Rigor
68
73
  when :write then dispatch_write(candidates, configuration, options)
69
74
  when :check then dispatch_check(candidates, configuration, options)
70
75
  else
71
- dispatch_print_or_diff(candidates, mode, options)
76
+ dispatch_print_or_diff(candidates, mode, options, observation_roots, path_errors)
72
77
  0
73
78
  end
79
+ status = 1 if path_errors.any?(&:error?)
80
+ report_run_notes(candidates, generator, options)
81
+ status
82
+ end
83
+
84
+ def report_run_notes(candidates, generator, options)
74
85
  report_skipped(candidates, options)
75
86
  report_inline_declared(candidates, options)
76
87
  report_withheld_annotations(candidates, options)
77
88
  report_unrenderable(generator.unrenderable)
78
89
  report_unresolvable_superclasses(generator.unresolvable_superclasses)
79
- status
80
90
  end
81
91
 
82
92
  private
@@ -219,12 +229,38 @@ module Rigor
219
229
  unresolvable.sort.each { |class_name, superclass| @err.puts(" #{class_name} < #{superclass}") }
220
230
  end
221
231
 
222
- def dispatch_print_or_diff(candidates, mode, options)
232
+ # #1991 — the path errors `rigor check` reports for the arguments: a path that is neither a directory nor a
233
+ # `.rb` file. Warned about beside files that will be read, an error when nothing will be, as `check` grades it.
234
+ def path_diagnostics(paths, configuration)
235
+ bad = Analysis::PathExpansion.unusable(paths)
236
+ return [] if bad.empty?
237
+
238
+ any_files = !Analysis::PathExpansion.ruby_files(paths, configuration.exclude_patterns,
239
+ base: configuration.exclude_base).empty?
240
+ Analysis::PathExpansion.diagnostics(bad, any_files: any_files)
241
+ end
242
+
243
+ # Without this a missing path was read as empty, so the run said "No candidates" and exited 0 (#1991). JSON
244
+ # print/diff carries the errors in its payload (see {#path_errors_in_payload?}); every other form says them on
245
+ # stderr, as `rigor check` does.
246
+ def report_path_errors(path_errors, options)
247
+ return if path_errors_in_payload?(options)
248
+
249
+ path_errors.each { |diagnostic| @err.puts(diagnostic.to_s) }
250
+ end
251
+
252
+ def path_errors_in_payload?(options)
253
+ options.fetch(:format) == "json" && %w[print diff].include?(options.fetch(:mode))
254
+ end
255
+
256
+ def dispatch_print_or_diff(candidates, mode, options, observation_roots, path_errors)
223
257
  SigGen::Renderer.new(out: @out).render(
224
258
  candidates: candidates,
225
259
  mode: mode,
226
260
  format: options.fetch(:format),
227
- selection: options.fetch(:selection)
261
+ selection: options.fetch(:selection),
262
+ observation: observation_report(observation_roots, options),
263
+ diagnostics: path_errors_in_payload?(options) ? path_errors : []
228
264
  )
229
265
  end
230
266
 
@@ -289,28 +325,52 @@ module Rigor
289
325
  # specified, observe the project's test roots: `test_paths:`, or whichever of `spec/` and `test/` exist. With no
290
326
  # root at all the run still succeeds, but says so — otherwise every parameter stays `untyped` with nothing
291
327
  # naming the reason.
292
- def collect_observations(configuration, options)
328
+ #
329
+ # The collector pre-indexes the classes of the paths the run generates for as well as the configured ones, so
330
+ # a call site on a class named only on the command line (`sig-gen ../lib` from a subdirectory, where no
331
+ # config file is discovered and the default `paths:` resolve under the subdirectory) attaches (#2078).
332
+ def collect_observations(configuration, options, observe_paths, paths)
293
333
  return {} if options.fetch(:params) != "observed"
294
334
 
295
- observe_paths = options.fetch(:observe)
296
- observe_paths = configuration.resolved_test_paths if observe_paths.empty?
297
335
  if observe_paths.empty?
298
336
  warn_no_test_roots(configuration)
299
337
  else
300
338
  warn_missing_test_roots(observe_paths)
301
339
  end
302
- SigGen::ObservationCollector.new(configuration: configuration, paths: observe_paths).collect
340
+ SigGen::ObservationCollector.new(configuration: configuration, paths: observe_paths,
341
+ source_paths: (configuration.paths + paths).uniq).collect
342
+ end
343
+
344
+ # The roots `--params=observed` reads: `--observe=PATH...` when given, else the project's test roots.
345
+ def observation_roots(configuration, options)
346
+ return [] if options.fetch(:params) != "observed"
347
+
348
+ observe_paths = options.fetch(:observe)
349
+ observe_paths.empty? ? configuration.resolved_test_paths : observe_paths
350
+ end
351
+
352
+ # #1398 — the same fact the stderr notes carry, as data for `--format=json` (and the MCP tool, whose
353
+ # stderr nobody reads). Absent under `--params=untyped`, where no root is read. Paths are spelled as
354
+ # {#observation_roots} yields them: `--observe=PATH` as typed, `test_paths:` as the configuration
355
+ # resolved them, an auto-detected `spec/` / `test/` absolute.
356
+ def observation_report(observe_paths, options)
357
+ return nil if options.fetch(:params) != "observed"
358
+
359
+ present, missing = observe_paths.uniq.partition { |path| SigGen::ObservationCollector.readable_root?(path) }
360
+ { paths: present, missing: missing }
303
361
  end
304
362
 
305
- # A declared root that does not exist is read as empty, so name it: `sig-gen` is the only command that reads
306
- # the test roots, and without this the run looks exactly like one whose tests pass no typed arguments.
363
+ # A declared root that does not exist (or is not a directory or `.rb` file) is read as empty, so name it:
364
+ # `sig-gen` is the only command that reads the test roots, and without this the run looks exactly like one
365
+ # whose tests pass no typed arguments.
307
366
  def warn_missing_test_roots(observe_paths)
308
- missing = observe_paths.reject { |path| File.exist?(path) }
367
+ missing = observe_paths.reject { |path| SigGen::ObservationCollector.readable_root?(path) }
309
368
  return if missing.empty?
310
369
 
311
370
  consequence = missing.size == observe_paths.size ? "; every parameter stays untyped" : ""
312
371
  @err.puts("rigor sig-gen: no call sites are observed from #{missing.map(&:inspect).join(', ')}, " \
313
- "which does not exist#{consequence}. Check `test_paths:` or --observe=PATH.")
372
+ "which does not exist or is not a directory or `.rb` file#{consequence}. " \
373
+ "Check `test_paths:` or --observe=PATH.")
314
374
  end
315
375
 
316
376
  def warn_no_test_roots(configuration)
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "command"
4
+ require_relative "../rooted_glob"
4
5
 
5
6
  module Rigor
6
7
  class CLI
@@ -227,7 +228,7 @@ module Rigor
227
228
  return [] unless File.directory?(dir)
228
229
 
229
230
  # `Dir.glob` returns lexicographically sorted paths (Ruby 3.0+), so the `NN-` prefixes already drive read order.
230
- Dir.glob(File.join(dir, "*.md"))
231
+ RootedGlob.glob(dir, "*.md")
231
232
  end
232
233
 
233
234
  def name_error(name)
@@ -3,6 +3,7 @@
3
3
  require "yaml"
4
4
 
5
5
  require_relative "../plugin_gap_advisory"
6
+ require_relative "../rooted_glob"
6
7
 
7
8
  module Rigor
8
9
  class CLI
@@ -78,7 +79,7 @@ module Rigor
78
79
  vscode = File.join(@root, ".vscode")
79
80
  return :none unless File.directory?(vscode)
80
81
 
81
- files = Dir.glob(File.join(vscode, "*.json"))
82
+ files = RootedGlob.glob(vscode, "*.json")
82
83
  return :unwired if files.empty?
83
84
 
84
85
  files.any? { |path| file_mentions_rigor?(path) } ? :wired : :unwired
@@ -103,7 +104,7 @@ module Rigor
103
104
  end
104
105
 
105
106
  def ci_config_files
106
- files = Dir.glob(File.join(@root, ".github", "workflows", "*.{yml,yaml}"))
107
+ files = RootedGlob.glob(@root, ".github/workflows/*.{yml,yaml}")
107
108
  gitlab = File.join(@root, ".gitlab-ci.yml")
108
109
  files << gitlab if File.file?(gitlab)
109
110
  files
@@ -17,6 +17,7 @@ require_relative "type_of_template_probe"
17
17
  require_relative "command"
18
18
  require_relative "options"
19
19
  require_relative "../project_environment"
20
+ require_relative "type_of_project_scope"
20
21
 
21
22
  module Rigor
22
23
  class CLI
@@ -88,7 +89,7 @@ module Rigor
88
89
  def execute(targets:, options:, buffer: nil)
89
90
  configuration = Configuration.load(options.fetch(:config))
90
91
  environment = project_environment(targets.map(&:file).uniq, configuration, buffer)
91
- base_scope = Scope.empty(environment: environment)
92
+ base_scope = TypeOfProjectScope.build(environment: environment, configuration: configuration, buffer: buffer)
92
93
 
93
94
  results_by_target = Array.new(targets.length)
94
95
  targets.each_with_index.group_by { |target, _index| target.file }.each do |file, indexed_targets|
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../scope"
4
+ require_relative "../protection/discovery_seed"
5
+
6
+ module Rigor
7
+ class CLI
8
+ # Issue #512 — the scope `rigor type-of` probes with: single-file, plus the cross-file tables a prior
9
+ # `rigor check --incremental` left in the snapshot ({Protection::DiscoverySeed.from_snapshot}) when there are
10
+ # any. A probe never re-parses the project for its seed, so without a reusable snapshot this is the bare
11
+ # `Scope.empty` it always was.
12
+ #
13
+ # The seed is the full {Inference::ProjectSeedTables} fold, so a `contested_*` sibling travels with the member
14
+ # it qualifies (ADR-119): this is a recorded whole-index consumer in `admission_census.yml`.
15
+ module TypeOfProjectScope
16
+ module_function
17
+
18
+ def build(environment:, configuration:, buffer: nil)
19
+ base = Scope.empty(environment: environment)
20
+ seed = Protection::DiscoverySeed.from_snapshot(configuration: configuration, buffer: buffer)
21
+ return base if seed.nil? || seed.empty?
22
+
23
+ base.with_discovery(base.discovery.with(**seed))
24
+ end
25
+ end
26
+ end
27
+ end
@@ -15,6 +15,7 @@ require_relative "../cache/store"
15
15
  require_relative "options"
16
16
  require_relative "command"
17
17
  require_relative "../project_environment"
18
+ require_relative "../rooted_glob"
18
19
 
19
20
  module Rigor
20
21
  class CLI
@@ -51,7 +52,7 @@ module Rigor
51
52
  paths = @argv.empty? ? configuration.paths : @argv
52
53
  establish_root(configuration)
53
54
  scan_cache = open_scan_cache(configuration)
54
- declarations, references, dynamic_uses, shadows = scan(paths, configuration, scan_cache)
55
+ declarations, references, dynamic_uses, shadows, ancestries = scan(paths, configuration, scan_cache)
55
56
  references.concat(signature_references(configuration))
56
57
  dynamic_uses.concat(template_mentions(declarations, scan_cache))
57
58
 
@@ -60,7 +61,7 @@ module Rigor
60
61
  references.concat(plugin_references(contribution.references))
61
62
  graph = build_graph(configuration, options, contribution,
62
63
  declarations: declarations, references: references, dynamic_uses: dynamic_uses,
63
- shadows: shadows)
64
+ shadows: shadows, ancestries: ancestries)
64
65
  emit(graph.report, options, supply: root_supply(contribution.roots, declarations))
65
66
  scan_cache.save
66
67
  0
@@ -109,9 +110,9 @@ module Rigor
109
110
  cwd == @root || inside?(cwd, @root) ? [@root] : [@root, cwd]
110
111
  end
111
112
 
112
- def build_graph(configuration, options, contribution, declarations:, references:, dynamic_uses:, shadows:)
113
+ def build_graph(configuration, options, contribution, declarations:, **scanned)
113
114
  Analysis::Reachability::Graph.new(
114
- declarations: declarations, references: references, dynamic_uses: dynamic_uses, shadows: shadows,
115
+ declarations: declarations, **scanned,
115
116
  root_fqns: root_fqns(declarations, options.fetch(:entry_points)) + contribution.roots,
116
117
  foreign: foreign_predicate(configuration)
117
118
  )
@@ -160,16 +161,18 @@ module Rigor
160
161
  # is the one the report prints; any other spelling's declarations are shadows, as a wider-corpus
161
162
  # file's are (#1732).
162
163
  def scan(paths, configuration, cache)
163
- out = { declarations: [], references: [], dynamic_uses: [], shadows: [] }
164
+ out = { declarations: [], references: [], dynamic_uses: [], shadows: [], ancestries: [] }
164
165
  file_spellings(paths, configuration).sort.each do |real, list|
165
166
  primary, analysed = list.find { |_, declared| declared } || list.first
166
167
  result = cache.serve(:scan, primary) { read_and_scan(primary, real, configuration) }
167
168
  next if result.nil?
168
169
 
169
170
  out[analysed ? :declarations : :shadows].concat(result.declarations)
171
+ # An ancestry edge is a fact about the program, not about the file's role, so one spelling carries it.
172
+ out[:ancestries].concat(result.ancestries)
170
173
  list.map(&:first).each { |file| add_spelling(out, result, file, real, primary) }
171
174
  end
172
- out.values_at(:declarations, :references, :dynamic_uses, :shadows)
175
+ out.values_at(:declarations, :references, :dynamic_uses, :shadows, :ancestries)
173
176
  end
174
177
 
175
178
  def add_spelling(out, result, file, real, primary)
@@ -289,7 +292,7 @@ module Rigor
289
292
  Array(configuration.signature_paths).flat_map do |dir|
290
293
  next [] unless File.directory?(dir)
291
294
 
292
- Dir.glob(File.join(dir, "**/*.rbs")).flat_map { |file| Analysis::Reachability::SignatureScan.call(file) }
295
+ RootedGlob.glob(dir, "**/*.rbs").flat_map { |file| Analysis::Reachability::SignatureScan.call(file) }
293
296
  end
294
297
  end
295
298
 
data/lib/rigor/cli.rb CHANGED
@@ -250,8 +250,9 @@ module Rigor
250
250
  # ships (run `rigor explain <rule>` for what
251
251
  # each one catches):
252
252
  #{rule_ids_comment}
253
- # A bare family token (`call`, `flow`,
254
- # `assert`, `dump`, `def`) wildcards every
253
+ # A bare family token (`call`, `flow`, `def`,
254
+ # `assert`, `dump`, `global`, `suppression`,
255
+ # `static`, `effect`, `pre-eval`) wildcards every
255
256
  # rule under that prefix. Legacy unprefixed
256
257
  # names (`undefined-method`, …) still
257
258
  # resolve. In-source
@@ -3,6 +3,7 @@
3
3
  require "did_you_mean"
4
4
 
5
5
  require_relative "config_path"
6
+ require_relative "flow_contribution/conflict"
6
7
  require_relative "signature_path_audit"
7
8
  # ADR-87 WD4 — only the pure rule-id table (`ALL_RULES` / `RULE_FAMILIES`) is needed, so require the light
8
9
  # `rule_ids.rb` rather than the engine-pulling `check_rules.rb`; keeps `config_audit` (loaded by every check)
@@ -26,12 +27,13 @@ module Rigor
26
27
  #
27
28
  # Every check is held to the same bar that {SignaturePathAudit} set: it mirrors the
28
29
  # loader's own acceptance test, so a warning means the loader really did load nothing, and
29
- # it never fires on a setup that works. In particular the `severity_overrides:` check only
30
- # flags a key under a built-in family (`call`, `flow`, …) — a plugin- or `rbs_extended.*`
31
- # rule id (whose family Rigor cannot statically enumerate, since a `node_rule` block emits
32
- # any `rule:` string it likes) is left alone. The `disable:` check goes further because the
33
- # list's reach is enumerable (issue #1953): a plugin's row answers only to its qualified
34
- # `plugin.<id>.<rule>` id, so a bare plugin rule there is inert (see {.disable_token_warning}).
30
+ # it never fires on a setup that works. The rule-token checks lean on the reach of `disable:`
31
+ # and `severity_overrides:` being enumerable (issues #1953, #1985): a plugin's row answers only
32
+ # to its qualified `plugin.<id>.<rule>` id (or `plugin.<id>`), so a bare plugin rule there is
33
+ # inert (see {.disable_token_warning}, {.override_key_warning}). A `plugin.`-qualified token is
34
+ # checked only against `plugins:`, and a token under a non-check engine family
35
+ # (`rbs_extended.*`, `rbs.*`, `dynamic.*`) is left alone, since the engine emits those ids
36
+ # outside the catalogue.
35
37
  module ConfigAudit
36
38
  # One config-level finding. `kind` discriminates the source key (`:signature_path`,
37
39
  # `:bundled_plugin_signature_path`, `:library`, `:disabled_rule`, `:severity_override`,
@@ -148,24 +150,65 @@ module Rigor
148
150
  end
149
151
 
150
152
  # `disable:` tokens that reach no row ({.disable_token_warning}) and `severity_overrides:` keys
151
- # that name no rule. An override key is flagged only under a built-in family, so a plugin
152
- # rule id is never mis-flagged (see the module docstring).
153
+ # that reach none ({.override_key_warning}).
153
154
  def self.rule_token_warnings(configuration)
154
155
  plugin_ids = configured_plugin_ids(configuration.plugins)
155
156
  disable = Array(configuration.disabled_rules).filter_map do |token|
156
157
  disable_token_warning(token.to_s, plugin_ids)
157
158
  end
158
- overrides = configuration.severity_overrides.keys.filter_map do |key|
159
- next unless inert_builtin_token?(key.to_s)
159
+ keys = configuration.severity_overrides.keys.map(&:to_s)
160
+ overrides = keys.filter_map { |key| override_key_warning(key, plugin_ids, keys) }
161
+ disable + overrides
162
+ end
160
163
 
161
- Warning.new(
162
- kind: :severity_override,
163
- message: "severity_overrides: #{key.to_s.inspect} is not a recognized rule id; the override has no effect",
164
- fields: { "token" => key.to_s }
165
- )
164
+ # Issue #1985 — the plugin loader's own rows, which a `severity_overrides:` key names by their qualified
165
+ # id; they also answer to the bare `load-error` / `runtime-error`.
166
+ PLUGIN_LOADER_ROWS = %w[plugin_loader.load-error plugin_loader.runtime-error].freeze
167
+ private_constant :PLUGIN_LOADER_ROWS
168
+
169
+ # A warning for a `severity_overrides:` key that can reach no row, or nil. Mirrors {.disable_token_warning}
170
+ # (issue #1985): a plugin's row answers only to its qualified `plugin.<id>.<rule>` or `plugin.<id>`, so a
171
+ # `plugin.`-qualified key naming an unconfigured plugin, and any key outside the built-in vocabulary that is
172
+ # not a qualified plugin id (a plugin's bare rule included), is inert. A reserved run-level row is the home of
173
+ # this map, so naming one is never flagged; `all` is not a family wildcard here, so it is.
174
+ def self.override_key_warning(key, plugin_ids, present = [])
175
+ reason = legacy_alias_problem(key, present) || override_key_problem(key, plugin_ids)
176
+ return nil if reason.nil?
177
+
178
+ Warning.new(kind: :severity_override, message: "severity_overrides: #{key.inspect} #{reason}",
179
+ fields: { "token" => key })
180
+ end
181
+
182
+ # #2005 — `disable:` expands a legacy alias to its canonical id, but the severity resolver matches canonical ids
183
+ # only, so an alias key reaches no row. Name the canonical id rather than accept the key. `present` holds the
184
+ # keys the map already has: when the canonical id is one of them, the advice to write it is already followed.
185
+ def self.legacy_alias_problem(key, present)
186
+ canonical = Analysis::CheckRules::LEGACY_RULE_ALIASES[key]
187
+ return nil if canonical.nil?
188
+
189
+ reason = "is a legacy alias of `#{canonical}`; the override has no effect"
190
+ present.include?(canonical) ? reason : "#{reason}, so write `#{canonical}`"
191
+ end
192
+ private_class_method :legacy_alias_problem
193
+
194
+ def self.override_key_problem(key, plugin_ids)
195
+ return "is not a recognized rule id; the override has no effect" if key == "all"
196
+ return nil unless Array(Analysis::CheckRules.resolve_rule_token(key)).empty?
197
+
198
+ if key.start_with?(Analysis::CheckRules::PLUGIN_FAMILY_PREFIX)
199
+ return nil if plugin_ids.nil? || plausible_plugin_token?(key, plugin_ids)
200
+
201
+ "names no plugin `plugins:` configures; the override has no effect"
202
+ elsif key.start_with?("plugin_loader.")
203
+ "is not a recognized rule id; the override has no effect" unless PLUGIN_LOADER_ROWS.include?(key)
204
+ elsif inert_builtin_token?(key)
205
+ "is not a recognized rule id; the override has no effect"
206
+ elsif !non_check_engine_token?(key) && !severity_only_id?(key)
207
+ "is not a recognized rule id; the override has no effect " \
208
+ "(if this is a plugin's rule, write it as `plugin.<id>.<rule>`)"
166
209
  end
167
- disable + overrides
168
210
  end
211
+ private_class_method :override_key_problem
169
212
 
170
213
  # Issue #1953 — the run-level rows the diagnostic policy keeps on `severity_overrides:` and the
171
214
  # baseline. `disable:` does not reach them by any spelling, so naming one there is inert.
@@ -224,6 +267,19 @@ module Rigor
224
267
  end
225
268
  private_class_method :reserved_run_level_token?
226
269
 
270
+ # Bare ids that a `severity_overrides:` key re-stamps but a marker or `disable:` cannot suppress: the `--explain`
271
+ # `fallback` row and the `contribution_merge` conflict reasons (`FlowContribution::CONFLICT_VALID_REASONS`,
272
+ # kebab-cased). They are not in `CheckRules::NON_CHECK_DIAGNOSTIC_IDS`, which would silence the warnings for a
273
+ # `disable:` token or marker naming them. `spec/rigor/config_audit_spec.rb` pins this list's completeness.
274
+ CONFLICT_IDS = FlowContribution::CONFLICT_VALID_REASONS.map { |reason| reason.to_s.tr("_", "-").freeze }.freeze
275
+ SEVERITY_ONLY_IDS = (%w[fallback] + CONFLICT_IDS).freeze
276
+ private_constant :CONFLICT_IDS, :SEVERITY_ONLY_IDS
277
+
278
+ def self.severity_only_id?(key)
279
+ SEVERITY_ONLY_IDS.include?(key)
280
+ end
281
+ private_class_method :severity_only_id?
282
+
227
283
  def self.non_check_engine_token?(token)
228
284
  Analysis::CheckRules::NON_CHECK_DIAGNOSTIC_IDS.include?(token) ||
229
285
  Analysis::CheckRules::NON_CHECK_DIAGNOSTIC_FAMILIES.include?(token.split(".").first)
@@ -202,11 +202,17 @@ module Rigor
202
202
  # always wins) and *above* the profile table. Exact rule ids only — the overlay never carries family
203
203
  # wildcards. Empty while the overlay is unpopulated, so the default leaves resolution bit-for-bit
204
204
  # unchanged.
205
+ # @param qualified_rule — the row's `<source_family>.<rule>` id (`plugin_loader.load-error`); equal to `rule`
206
+ # for a built-in row. Issue #1985 — an override key may spell it.
207
+ # @param plugin_family — `plugin.<id>` for a row a plugin emitted, nil for every other row. Such a row answers
208
+ # to an override only by its `qualified_rule` or this whole-plugin key, never by its bare `rule` or a family
209
+ # wildcard: plugins share bare rules (`load-error`), and a bare key would re-stamp them all.
205
210
  # @return the resolved severity. Returns `:off` to mean "drop the diagnostic entirely".
206
- def resolve(rule:, authored_severity:, profile: DEFAULT_PROFILE, overrides: {}, bleeding_edge_overrides: {})
211
+ def resolve(rule:, authored_severity:, profile: DEFAULT_PROFILE, overrides: {}, bleeding_edge_overrides: {},
212
+ qualified_rule: rule, plugin_family: nil)
207
213
  return authored_severity if rule.nil?
208
214
 
209
- override = overrides[rule] || family_override(rule, overrides)
215
+ override = row_override(rule, qualified_rule, plugin_family, overrides)
210
216
  return override.to_sym if override
211
217
 
212
218
  bleeding = bleeding_edge_overrides[rule]
@@ -216,6 +222,18 @@ module Rigor
216
222
  profile_table.fetch(rule, authored_severity)
217
223
  end
218
224
 
225
+ # Issue #1985 — the `severity_overrides:` entry a row answers to, or nil. A plugin's row: its qualified id, then
226
+ # its `plugin.<id>`. Any other row: its qualified id (the plugin loader's
227
+ # `plugin_loader.load-error`), then its bare rule, then a family wildcard. The qualified key is the more specific
228
+ # one, so it wins over the bare key, as it does for a plugin's row.
229
+ def row_override(rule, qualified_rule, plugin_family, overrides)
230
+ return overrides[qualified_rule] || overrides[plugin_family] unless plugin_family.nil?
231
+
232
+ overrides[qualified_rule] || overrides[rule] || family_override(rule, overrides)
233
+ end
234
+
235
+ private_class_method :row_override
236
+
219
237
  # A family name has no `.` in it, so only a dot-free key can match one. `resolve` runs for every
220
238
  # diagnostic in a run's stream, and most configurations carry no such key (usually no overrides at all),
221
239
  # so the split below is skipped for them rather than allocating a name that nothing can match.