rigortype 0.4.3 → 0.4.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 (172) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +5 -5
  3. data/data/core_overlay/ostruct.rbs +40 -0
  4. data/docs/handbook/11-sig-gen.md +104 -15
  5. data/docs/manual/02-cli-reference.md +43 -12
  6. data/docs/manual/03-configuration.md +20 -7
  7. data/docs/manual/04-diagnostics.md +56 -3
  8. data/docs/manual/05-inspecting-types.md +2 -2
  9. data/docs/manual/06-baseline.md +1 -1
  10. data/docs/manual/07-plugins.md +3 -2
  11. data/docs/manual/08-skills.md +7 -0
  12. data/docs/manual/10-mcp-server.md +40 -4
  13. data/docs/manual/18-removing-dead-code.md +23 -2
  14. data/docs/manual/plugins/rigor-rails-routes.md +2 -2
  15. data/docs/manual/plugins/rigor-rspec.md +101 -1
  16. data/docs/manual/plugins/rigor-shoulda-matchers.md +4 -1
  17. data/exe/rigor +12 -17
  18. data/lib/rigor/analysis/check_rules/clause_coverage.rb +134 -0
  19. data/lib/rigor/analysis/check_rules/lexical_method_sites.rb +111 -7
  20. data/lib/rigor/analysis/check_rules/rbs_receiver_surface.rb +190 -0
  21. data/lib/rigor/analysis/check_rules/rule_ids.rb +60 -1
  22. data/lib/rigor/analysis/check_rules/source_arity.rb +32 -3
  23. data/lib/rigor/analysis/check_rules/unreachable_clause_collector.rb +13 -4
  24. data/lib/rigor/analysis/check_rules.rb +274 -51
  25. data/lib/rigor/analysis/dependency_source_inference/walker.rb +2 -1
  26. data/lib/rigor/analysis/incremental.rb +61 -0
  27. data/lib/rigor/analysis/incremental_run_slot/write_guard.rb +36 -18
  28. data/lib/rigor/analysis/incremental_session.rb +270 -28
  29. data/lib/rigor/analysis/path_expansion.rb +3 -1
  30. data/lib/rigor/analysis/reachability/graph.rb +111 -14
  31. data/lib/rigor/analysis/reachability/scan.rb +305 -23
  32. data/lib/rigor/analysis/rule_catalog.rb +22 -4
  33. data/lib/rigor/analysis/run_cache_key.rb +31 -7
  34. data/lib/rigor/analysis/run_cache_probe.rb +5 -3
  35. data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +48 -12
  36. data/lib/rigor/analysis/runner/pool_coordinator.rb +179 -31
  37. data/lib/rigor/analysis/runner/project_pre_passes.rb +29 -4
  38. data/lib/rigor/analysis/runner.rb +102 -72
  39. data/lib/rigor/analysis/severity_stamp.rb +45 -1
  40. data/lib/rigor/analysis/worker_session.rb +69 -2
  41. data/lib/rigor/cache/descriptor.rb +79 -12
  42. data/lib/rigor/cache/engine_source.rb +3 -2
  43. data/lib/rigor/cache/incremental_snapshot.rb +39 -5
  44. data/lib/rigor/cache/rbs_descriptor.rb +2 -1
  45. data/lib/rigor/cache/store.rb +4 -3
  46. data/lib/rigor/cli/command.rb +3 -1
  47. data/lib/rigor/cli/docs_command.rb +3 -2
  48. data/lib/rigor/cli/doctor_command.rb +1 -1
  49. data/lib/rigor/cli/explain_command.rb +3 -2
  50. data/lib/rigor/cli/mcp_command.rb +1 -1
  51. data/lib/rigor/cli/plugin_command.rb +2 -1
  52. data/lib/rigor/cli/plugins_command.rb +2 -1
  53. data/lib/rigor/cli/sig_gen_command.rb +32 -11
  54. data/lib/rigor/cli/skill_command.rb +2 -1
  55. data/lib/rigor/cli/skill_describe.rb +3 -2
  56. data/lib/rigor/cli/unused_command.rb +10 -7
  57. data/lib/rigor/cli.rb +3 -2
  58. data/lib/rigor/config_audit.rb +163 -22
  59. data/lib/rigor/configuration/dependencies.rb +19 -11
  60. data/lib/rigor/configuration/severity_profile.rb +20 -2
  61. data/lib/rigor/configuration.rb +29 -3
  62. data/lib/rigor/effects/signature_sources.rb +3 -1
  63. data/lib/rigor/environment/bundle_sig_discovery.rb +3 -2
  64. data/lib/rigor/environment/gated_signature_guard.rb +2 -1
  65. data/lib/rigor/environment/gated_signature_plan.rb +2 -1
  66. data/lib/rigor/environment/installed_gem_set.rb +2 -1
  67. data/lib/rigor/environment/missing_gem_constant_index.rb +4 -3
  68. data/lib/rigor/environment/rbs_loader.rb +67 -11
  69. data/lib/rigor/inference/block_call_timing.rb +12 -1
  70. data/lib/rigor/inference/builtins/method_catalog.rb +2 -1
  71. data/lib/rigor/inference/captured_locals.rb +67 -13
  72. data/lib/rigor/inference/clause_widening.rb +90 -0
  73. data/lib/rigor/inference/definer_resolution.rb +101 -25
  74. data/lib/rigor/inference/expression_typer.rb +94 -9
  75. data/lib/rigor/inference/guard_rebinding.rb +7 -2
  76. data/lib/rigor/inference/intervening_ivar_reset.rb +303 -0
  77. data/lib/rigor/inference/last_line/self_evidence.rb +1 -1
  78. data/lib/rigor/inference/macro_block_self_type.rb +20 -6
  79. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +170 -35
  80. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +50 -2
  81. data/lib/rigor/inference/method_dispatcher.rb +67 -3
  82. data/lib/rigor/inference/module_function_state.rb +213 -51
  83. data/lib/rigor/inference/narrowing.rb +235 -75
  84. data/lib/rigor/inference/parameter_inference_collector.rb +4 -26
  85. data/lib/rigor/inference/project_patched_methods.rb +168 -4
  86. data/lib/rigor/inference/project_patched_mixins.rb +162 -0
  87. data/lib/rigor/inference/project_patched_scanner.rb +51 -12
  88. data/lib/rigor/inference/project_patched_visibility.rb +214 -0
  89. data/lib/rigor/inference/project_seed_tables.rb +120 -0
  90. data/lib/rigor/inference/refined_dispatch/constructed_class.rb +113 -0
  91. data/lib/rigor/inference/refined_dispatch/own_method.rb +41 -12
  92. data/lib/rigor/inference/reflective_ivar_write.rb +116 -0
  93. data/lib/rigor/inference/repeated_or_writes.rb +414 -1
  94. data/lib/rigor/inference/return_barrier.rb +16 -0
  95. data/lib/rigor/inference/scope_indexer.rb +1061 -122
  96. data/lib/rigor/inference/self_call_ivar_writes.rb +346 -0
  97. data/lib/rigor/inference/singleton_external_ahead.rb +64 -0
  98. data/lib/rigor/inference/singleton_hook_decline.rb +7 -0
  99. data/lib/rigor/inference/statement_evaluator.rb +512 -150
  100. data/lib/rigor/inference/unthreaded_rebinds.rb +3 -1
  101. data/lib/rigor/lens/anchor.rb +3 -3
  102. data/lib/rigor/lens/contract_reader.rb +60 -2
  103. data/lib/rigor/lens/document.rb +4 -3
  104. data/lib/rigor/lens/file_reader.rb +452 -73
  105. data/lib/rigor/lens/members.rb +81 -17
  106. data/lib/rigor/lens/run.rb +73 -4
  107. data/lib/rigor/lens.rb +2 -1
  108. data/lib/rigor/mcp/server.rb +63 -22
  109. data/lib/rigor/plugin/base.rb +3 -2
  110. data/lib/rigor/plugin/box_probe.rb +154 -7
  111. data/lib/rigor/plugin/io_boundary.rb +42 -6
  112. data/lib/rigor/plugin/isolation.rb +4 -3
  113. data/lib/rigor/project_environment.rb +6 -3
  114. data/lib/rigor/protection/discovery_seed.rb +9 -36
  115. data/lib/rigor/protection/mutation_cache.rb +8 -2
  116. data/lib/rigor/rooted_glob.rb +27 -0
  117. data/lib/rigor/scope/discovery_index.rb +5 -0
  118. data/lib/rigor/scope/resolution_chain/relevance.rb +14 -0
  119. data/lib/rigor/scope/resolution_chain.rb +40 -5
  120. data/lib/rigor/scope.rb +56 -8
  121. data/lib/rigor/sig_gen/alias_index.rb +2 -1
  122. data/lib/rigor/sig_gen/classification.rb +4 -1
  123. data/lib/rigor/sig_gen/generator.rb +167 -22
  124. data/lib/rigor/sig_gen/layout_index.rb +40 -1
  125. data/lib/rigor/sig_gen/method_candidate.rb +8 -0
  126. data/lib/rigor/sig_gen/observation_collector.rb +137 -11
  127. data/lib/rigor/sig_gen/path_mapper.rb +18 -2
  128. data/lib/rigor/sig_gen/renderer.rb +13 -155
  129. data/lib/rigor/sig_gen/skip_reason_catalog.rb +11 -0
  130. data/lib/rigor/sig_gen/write_reporter.rb +203 -0
  131. data/lib/rigor/sig_gen/write_result.rb +3 -1
  132. data/lib/rigor/sig_gen/writer.rb +50 -35
  133. data/lib/rigor/signature_path_audit.rb +4 -2
  134. data/lib/rigor/type/constant.rb +10 -1
  135. data/lib/rigor/version.rb +1 -1
  136. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_discoverer.rb +1 -1
  137. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_discoverer.rb +1 -1
  138. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_discoverer.rb +1 -1
  139. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/render_locals.rb +6 -1
  140. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/view_assigns.rb +1 -1
  141. data/plugins/rigor-active-model-serializers/lib/rigor/plugin/active_model_serializers/serializer_discoverer.rb +1 -1
  142. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/effects.rb +4 -3
  143. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_discoverer.rb +1 -1
  144. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +1 -1
  145. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/attachment_discoverer.rb +1 -1
  146. data/plugins/rigor-alba/lib/rigor/plugin/alba/resource_discoverer.rb +1 -1
  147. data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema.rb +5 -1
  148. data/plugins/rigor-dry-types/lib/rigor/plugin/dry_types.rb +1 -1
  149. data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation.rb +5 -1
  150. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_discoverer.rb +1 -1
  151. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/discoverer.rb +57 -6
  152. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/target_detector.rb +1 -1
  153. data/plugins/rigor-ffi/lib/rigor/plugin/ffi.rb +1 -1
  154. data/plugins/rigor-graphql/lib/rigor/plugin/graphql.rb +5 -1
  155. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/authorization_scan.rb +1 -1
  156. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/policy_discoverer.rb +1 -1
  157. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/locale_index.rb +7 -2
  158. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/locale_loader.rb +5 -5
  159. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +18 -17
  160. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +1 -1
  161. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/matcher_analyzer.rb +155 -12
  162. data/plugins/rigor-rspec/lib/rigor/plugin/rspec.rb +10 -8
  163. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/worker_discoverer.rb +1 -1
  164. data/plugins/rigor-sinatra/lib/rigor/plugin/sinatra.rb +4 -2
  165. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +2 -2
  166. data/plugins/rigor-typelizer/lib/rigor/plugin/typelizer/serializer_discoverer.rb +2 -2
  167. data/sig/rigor/plugin/io_boundary.rbs +5 -2
  168. data/sig/rigor/rooted_glob.rbs +7 -0
  169. data/sig/rigor/scope.rbs +4 -3
  170. data/skills/rigor-doctor/references/01-checks.md +23 -11
  171. data/skills/rigor-type-oracle/references/01-oracle-commands.md +2 -1
  172. metadata +16 -1
@@ -0,0 +1,190 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../reflection"
4
+ require_relative "../dependency_recorder"
5
+ require_relative "../../inference/singleton_hook_decline"
6
+
7
+ module Rigor
8
+ module Analysis
9
+ module CheckRules
10
+ # Issue #1722 — what the project may give an instance of an RBS-declared class beyond the class's RBS, asked
11
+ # by `call.undefined-method` once the RBS lookup and the project's own tables for the class itself have come
12
+ # back "absent". Three places the earlier probes do not look:
13
+ #
14
+ # - **A `method_missing` on an RBS ancestor.** `class Integer; def method_missing(*)` (or one on `Numeric`,
15
+ # `Comparable`, `Object`, `Kernel`, `BasicObject`, or a project module mixed into any of them) answers
16
+ # every name Ruby dispatches to an Integer. `respond_to_missing?` is not read: Ruby dispatches to
17
+ # `method_missing` whether or not it is defined.
18
+ # - **A project mixin of the class or an RBS ancestor.** `Integer.include(Ext)` in another file, or
19
+ # `Comparable.include(Ext)` / `module Comparable; def zzz`, gives every Integer `zzz`. The earlier probes
20
+ # read the class's own chain through `discovered_methods`, which holds no plain `def` written in another
21
+ # file than the one being checked; this reads the def table too for a project module, and walks the
22
+ # ancestors RBS lists after the class. A cross-file `def` on an RBS-declared class or module itself stays
23
+ # ADR-17's monkey-patch report ({.mixin_defines?}). A mixin with an open surface — a project module with a
24
+ # recorded `included` / `extended` / … hook, a `"*"` mixin or `ActiveSupport::Concern`, or one neither RBS nor
25
+ # the project declares — answers any name, except on `Object`, `Kernel` and `BasicObject`: there only a
26
+ # module that defines the name answers, #1697's reading (`Inference::ObjectMixins`) of a module mixed into
27
+ # every object.
28
+ # - **A project subclass** ({.subclass_may_define?}). A value typed by its class's name through a signature
29
+ # (`#: () -> String`) may be an instance of any subclass, so a method a project subclass defines may be
30
+ # the one that runs.
31
+ #
32
+ # Each ancestor's name is filed as an ADR-46 class dependency, so a file that later reopens one re-checks
33
+ # the call, and the subclass walk files a `subclass_of` edge per class it reads.
34
+ module RbsReceiverSurface
35
+ MISSING_HOOK = :method_missing
36
+ # The roots whose project mixins `Inference::ObjectMixins` answers for.
37
+ OBJECT_ROOTS = %w[Object Kernel BasicObject].to_set.freeze
38
+ private_constant :MISSING_HOOK, :OBJECT_ROOTS
39
+
40
+ module_function
41
+
42
+ # Whether the project may answer `method_name` on an instance of `class_name` through one of its RBS
43
+ # ancestors.
44
+ def ancestor_may_answer?(scope, class_name, method_name)
45
+ ancestor_names(scope, class_name).any? do |name|
46
+ DependencyRecorder.read_last_segment(:class, name) if DependencyRecorder.active?
47
+ next false unless scope.known_user_class?(name)
48
+
49
+ chain_answers?(scope, name, method_name, open_mixins: !OBJECT_ROOTS.include?(name))
50
+ end
51
+ end
52
+
53
+ # Whether the singleton side of `class_name` may reach a project `method_missing`: a `def self.method_missing`
54
+ # on the class or an RBS superclass, or an instance `method_missing` on `Class`, `Module`, `Object`,
55
+ # `Kernel` or `BasicObject` (a class object is an instance of each).
56
+ def singleton_hook?(scope, class_name)
57
+ loader = scope.environment&.rbs_loader
58
+ pairs = loader ? loader.singleton_ancestors_for(class_name.to_s) : []
59
+ pairs = [[:singleton, class_name.to_s]] if pairs.empty?
60
+ pairs.any? do |kind, name|
61
+ DependencyRecorder.read_last_segment(:class, name) if DependencyRecorder.active?
62
+ if kind == :singleton
63
+ !scope.singleton_def_for(name, MISSING_HOOK).nil? ||
64
+ scope.discovered_method?(name, MISSING_HOOK, :singleton)
65
+ else
66
+ scope.known_user_class?(name) && hook_on_chain?(scope, name)
67
+ end
68
+ end
69
+ end
70
+
71
+ # Whether a project subclass of `class_name`, at any depth, defines `method_name` (through its own
72
+ # ancestry, which reaches its project mixins) or a `method_missing`. Its parent is read as written and
73
+ # resolved the way the class header resolves it: the first project class the spelling can denote, else the
74
+ # top-level name, which is the RBS class.
75
+ def subclass_may_define?(scope, class_name, method_name)
76
+ each_subclass(scope, class_name.to_s) do |subclass|
77
+ return true if project_defines?(scope, subclass, method_name) ||
78
+ project_defines?(scope, subclass, MISSING_HOOK) ||
79
+ !scope.user_def_through_ancestors(subclass, method_name).first.nil? ||
80
+ scope.discovered_method_through_ancestors?(subclass, method_name, :instance)
81
+ end
82
+ false
83
+ end
84
+
85
+ def ancestor_names(scope, class_name)
86
+ loader = scope.environment&.rbs_loader
87
+ names = loader ? loader.ancestor_names_for(class_name.to_s) : []
88
+ names.empty? ? [class_name.to_s] : names
89
+ end
90
+
91
+ # A search of `name`'s project chain for an entry that may answer: a project entry that defines
92
+ # `method_name` or a `method_missing`, or an external mixin RBS declares `method_name` on. When
93
+ # `open_mixins` (every ancestor but `Object`, `Kernel` and `BasicObject`, #1697), also a mixin whose surface
94
+ # is open: a project module SingletonHookDecline reads as hook-capable, or an external neither RBS nor the
95
+ # project declares. A chain cut at its budget may hold an answer past the cut.
96
+ def chain_answers?(scope, name, method_name, open_mixins:)
97
+ chain = Scope::ResolutionChain.for(scope, name, :instance, :methods)
98
+ found = chain.search(scope, side: :instance) do |entry|
99
+ if entry.external?
100
+ external_may_answer?(scope, entry, method_name, open_mixins)
101
+ else
102
+ project_defines?(scope, entry.name, MISSING_HOOK) ||
103
+ mixin_defines?(scope, entry, method_name, open_mixins)
104
+ end
105
+ end
106
+ found || chain.truncated?
107
+ end
108
+
109
+ # The singleton side's question of an instance chain: does a project entry on it define `method_missing`?
110
+ def hook_on_chain?(scope, name)
111
+ chain = Scope::ResolutionChain.for(scope, name, :instance, :methods)
112
+ found = chain.search(scope, side: :instance) do |entry|
113
+ !entry.external? && project_defines?(scope, entry.name, MISSING_HOOK)
114
+ end
115
+ found || chain.truncated?
116
+ end
117
+
118
+ def project_defines?(scope, owner, method_name)
119
+ !scope.user_def_for(owner, method_name).nil? || scope.discovered_method?(owner, method_name, :instance)
120
+ end
121
+
122
+ # ADR-17 — a `def` the project writes on a class or module RBS declares (`class String; def x`, `module
123
+ # Comparable; def x`) in another file than the call is a monkey-patch Rigor does not apply cross-file, and the
124
+ # rule reports it with the `pre_eval:` advice, so on such an entry only what `discovered_methods` carries
125
+ # answers. A project module's own `def` answers from any file.
126
+ #
127
+ # When `open_mixins`, a project module {Inference::SingletonHookDecline} reads as hook-capable — one that
128
+ # records an `included` / `extended` / … hook (`def self.included(base) = base.class_eval { def x }`), lists
129
+ # a `"*"` mixin, or extends `ActiveSupport::Concern` — may answer any name.
130
+ def mixin_defines?(scope, entry, method_name, open_mixins)
131
+ owner = entry.name
132
+ if Rigor::Reflection.rbs_class_known?(owner, scope: scope)
133
+ scope.discovered_method?(owner, method_name, :instance)
134
+ else
135
+ project_defines?(scope, owner, method_name) ||
136
+ (open_mixins && Inference::SingletonHookDecline.entry_hook_capable?(scope, entry))
137
+ end
138
+ end
139
+
140
+ # An external the project declares (a module with no `def`, which the chain does not hold as a project entry)
141
+ # is known, and answers only through RBS.
142
+ def external_may_answer?(scope, entry, method_name, open_mixins)
143
+ candidates = entry.candidates
144
+ return open_mixins if candidates.empty?
145
+ return true if candidates.any? do |candidate|
146
+ Rigor::Reflection.instance_method_definition(candidate, method_name, scope: scope)
147
+ end
148
+
149
+ open_mixins && candidates.none? { |candidate| declared?(scope, candidate) }
150
+ end
151
+
152
+ def declared?(scope, name)
153
+ scope.discovered_classes.key?(name) || Rigor::Reflection.rbs_class_known?(name, scope: scope)
154
+ end
155
+
156
+ def each_subclass(scope, class_name)
157
+ children = children_by_parent(scope)
158
+ queue = children.fetch(class_name, []).dup
159
+ seen = {}
160
+ record_subclasses_read(class_name)
161
+ until queue.empty?
162
+ subclass = queue.shift
163
+ next if seen[subclass]
164
+
165
+ seen[subclass] = true
166
+ record_subclasses_read(subclass)
167
+ yield subclass
168
+ queue.concat(children.fetch(subclass, []))
169
+ end
170
+ end
171
+
172
+ def children_by_parent(scope)
173
+ scope.discovered_superclasses.each_with_object({}) do |(child, raw_parent), children|
174
+ next if raw_parent.nil?
175
+
176
+ candidates = scope.ancestor_name_candidates(child, raw_parent.to_s)
177
+ parent = candidates.find { |name| scope.known_user_class?(name) } || candidates.last
178
+ (children[parent] ||= []) << child if parent
179
+ end
180
+ end
181
+
182
+ def record_subclasses_read(class_name)
183
+ return unless DependencyRecorder.active?
184
+
185
+ DependencyRecorder.read_last_segment(:subclass_of, class_name)
186
+ end
187
+ end
188
+ end
189
+ end
190
+ end
@@ -168,11 +168,70 @@ module Rigor
168
168
  # Bare (dot-less) diagnostic ids the engine emits outside the catalogue (see the `rule:` literals in
169
169
  # `Analysis::Runner` / `Runner::DiagnosticAggregator`). A token equal to one of these is treated as
170
170
  # known by `suppression.unknown-rule` even though it carries no family prefix; extend the list when
171
- # the runner grows a new bare id.
171
+ # the runner grows a new bare id. Ids a marker or `disable:` cannot suppress (the `--explain`
172
+ # `fallback` row, the `contribution_merge` conflict reasons) stay out: they are
173
+ # `ConfigAudit::SEVERITY_ONLY_IDS`, known to the `severity_overrides:` audit alone.
172
174
  NON_CHECK_DIAGNOSTIC_IDS = %w[
173
175
  configuration-error load-error pool-degraded runtime-error source-rbs-synthesis-failed
174
176
  source-rbs-annotation-not-honoured
175
177
  ].freeze
178
+
179
+ # Resolves a user-supplied rule token (`undefined-method`, `call.undefined-method`, or the family wildcard
180
+ # `call`) to the set of canonical rule identifiers it disables. Returns `nil` for `"all"` (the existing
181
+ # wildcard meaning "every rule"), or for unknown tokens.
182
+ def self.resolve_rule_token(token)
183
+ return nil if token == "all"
184
+ return [LEGACY_RULE_ALIASES.fetch(token)] if LEGACY_RULE_ALIASES.key?(token)
185
+ return PRE_EVAL_RULES if token == "pre-eval"
186
+ return ALL_RULES.select { |r| r.start_with?("#{token}.") } if RULE_FAMILIES.include?(token)
187
+
188
+ ALL_RULES.include?(token) ? [token] : []
189
+ end
190
+
191
+ # Expands a list of user-supplied rule tokens into the canonical-id set per ADR-8 § "Backward
192
+ # compatibility". `disabled_rules` accepts unprefixed legacy names (`undefined-method`), canonical names
193
+ # (`call.undefined-method`), family wildcards (`call`) and the `all` sentinel.
194
+ def self.expand_rule_tokens(tokens)
195
+ Array(tokens).each_with_object(Set.new) do |token, set|
196
+ set.merge(expand_token(token.to_s))
197
+ end
198
+ end
199
+
200
+ def self.expand_token(token)
201
+ return ["all"] if token == "all"
202
+
203
+ resolved = resolve_rule_token(token)
204
+ resolved.nil? || resolved.empty? ? [token] : resolved
205
+ end
206
+
207
+ # Whether the `.rigor.yml` `disable:` list, expanded by {.expand_rule_tokens}, drops a diagnostic whose rule
208
+ # id is `rule`: the `all` sentinel drops every rule-bearing row, any other entry its exact id. A nil rule (a
209
+ # parse, path or internal error) is never dropped.
210
+ def self.config_disabled?(rule, disabled)
211
+ return false if rule.nil?
212
+
213
+ disabled.include?("all") || disabled.include?(rule)
214
+ end
215
+
216
+ # The source-family prefix the runner stamps on every plugin-emitted row (`plugin.<id>`).
217
+ PLUGIN_FAMILY_PREFIX = "plugin."
218
+
219
+ # Issue #1953 — whether a suppression token set drops a plugin's own row. Such a row answers only to its
220
+ # qualified id: the exact `plugin.<id>.<rule>` (its `qualified_rule`) or the whole plugin's `plugin.<id>`
221
+ # (its `source_family`). Both compare whole strings against the row's own fields, so `plugin.<id>` never
222
+ # reaches a plugin whose id merely starts with `<id>.`. Ids and rules may both hold dots, so plugin `a` with
223
+ # rule `b.c` and plugin `a.b` with rule `c` share `plugin.a.b.c`, and that exact token drops both. A bare rule
224
+ # and `all` do not reach these rows: many plugins share a bare rule (`load-error`, `unknown-column`), and a
225
+ # bare match would hide another plugin's findings. Every other row (built-in, `plugin_loader`, `plugin_trust`)
226
+ # answers false.
227
+ def self.plugin_row_disabled?(diagnostic, tokens)
228
+ return false if diagnostic.rule.nil?
229
+
230
+ family = diagnostic.source_family.to_s
231
+ return false unless family.start_with?(PLUGIN_FAMILY_PREFIX)
232
+
233
+ tokens.include?(family) || tokens.include?(diagnostic.qualified_rule)
234
+ end
176
235
  end
177
236
  end
178
237
  end
@@ -101,6 +101,19 @@ module Rigor
101
101
  # {#decline_unless_recorded_nowhere}): it settles by definitions, as an `ABSENT` read does.
102
102
  return settle_by_walk if @unknown
103
103
 
104
+ # Issue #1833 — a level the `:arity` resolver took to be the project's on a mark alone (a namespace
105
+ # candidate a marks-only file rewrites) stands only while that mark does, and no definition appearing or
106
+ # vanishing can overturn it. Only such levels file the edge: the walk's other mark reads would re-check
107
+ # the call whenever an unrelated namespace reuses an ancestor's last segment.
108
+ @levels.each do |level|
109
+ ([level.class_name] + level.modules).each do |name|
110
+ next if @scope.known_user_class?(name)
111
+ next unless Scope::DiscoveryIndex.declares_surface?(@scope.discovered_parameter_envelopes[name])
112
+
113
+ Analysis::DependencyRecorder.read_last_segment(:mark, name)
114
+ end
115
+ end
116
+
104
117
  @owner_entries.each do |name, kind|
105
118
  if kind == :singleton
106
119
  @scope.user_singleton_def_site_for(name, @method_name)
@@ -121,7 +134,8 @@ module Rigor
121
134
  # Stage 2, for the class {#owner_envelope} last answered.
122
135
  def authoritative?(class_name)
123
136
  !load_order_dependent?(class_name) && !object_extension_may_shadow? &&
124
- @levels.all? { |level| clean_level?(level) && public_at?(level.class_name) } &&
137
+ @levels.all? { |level| clean_level?(level) && (@kind == :singleton || public_at?(level.class_name)) } &&
138
+ !(@kind == :singleton && singleton_private?(class_name)) &&
125
139
  chain_free_of_hooks?(class_name) &&
126
140
  subclasses_agree?(class_name, @envelope)
127
141
  end
@@ -245,8 +259,10 @@ module Rigor
245
259
  end
246
260
 
247
261
  # A module some method body hands to `obj.extend` sits ahead of the object's class, so if it defines
248
- # the name at all, that object answers with the module's definition.
262
+ # the name at all, that object answers with the module's definition. Every bucket is read, so the answer
263
+ # records `mark:*`, which an object-extended mark appearing or vanishing anywhere fires (issue #1833).
249
264
  def object_extension_may_shadow?
265
+ Analysis::DependencyRecorder.read_name(:mark, "*") if Analysis::DependencyRecorder.active?
250
266
  @scope.discovered_parameter_envelopes.any? do |_name, bucket|
251
267
  bucket.key?(Scope::DiscoveryIndex::ENVELOPE_OBJECT_EXTENDED_MARK) && bucket.key?([:instance, @method_name])
252
268
  end
@@ -291,7 +307,9 @@ module Rigor
291
307
  end
292
308
 
293
309
  # A bucket of class marks alone (issue #1820: `x.singleton_class.prepend(Comparable)`) does not make a name
294
- # the project's.
310
+ # the project's. The bucket is read directly rather than through `parameter_envelopes_of`: a `mark:<last>`
311
+ # edge recorded here fires on marks a correctly-fitting call does not depend on, so the dependency on
312
+ # a mark that could flip this resolution is kept at `settle_by_definitions` instead (issue #1833).
295
313
  def project_class?(name)
296
314
  @scope.known_user_class?(name) ||
297
315
  Scope::DiscoveryIndex.declares_surface?(@scope.discovered_parameter_envelopes[name])
@@ -348,6 +366,17 @@ module Rigor
348
366
  false
349
367
  end
350
368
 
369
+ # Issue #2059 — a call Ruby rejects as private on the singleton side (`private_class_method :two`, a private
370
+ # method of a module that extends itself) is `def.method-visibility-mismatch`'s, which reads the same definer;
371
+ # the arity is not checked. A read that declines leaves the arity to the other conditions.
372
+ def singleton_private?(class_name)
373
+ case Inference::DefinerResolution.resolve(@scope, class_name, @method_name, :singleton, question: :visibility)
374
+ in Inference::DefinerResolution::Known(answer:, owner: _owner) then answer == :private
375
+ in Inference::DefinerResolution::UNKNOWN then false
376
+ in Inference::DefinerResolution::ABSENT then false # rubocop:disable Lint/DuplicateBranch
377
+ end
378
+ end
379
+
351
380
  def public_at?(class_name)
352
381
  visibility = @scope.discovered_method_visibility(class_name, @method_name)
353
382
  visibility.nil? || visibility == :public
@@ -5,6 +5,7 @@ require "prism"
5
5
  require_relative "../../source/node_children"
6
6
  require_relative "../../inference/narrowing"
7
7
  require_relative "inferred_param_guard"
8
+ require_relative "clause_coverage"
8
9
 
9
10
  module Rigor
10
11
  module Analysis
@@ -36,6 +37,10 @@ module Rigor
36
37
  # is code evidence that the subject can hold such an object (`io = STDOUT; case io when StringIO`), and Rigor's
37
38
  # reading of the subject's type is what the evidence contradicts. The body scope stays `Bot`, so no call on the
38
39
  # subject in it is checked. A subject that is a literal carrier (`x = 1; case x when String`) is still reported.
40
+ #
41
+ # Issue #1485 — the same holds for a `:prior_exhaustion` clause: on a subject that is not a literal carrier it is
42
+ # reported only when every class it names is at or below a class an earlier clause names ({ClauseCoverage}), so
43
+ # `io = STDOUT; case io when IO … when StringIO` is quiet and `when IO … when File` still reports.
39
44
  class UnreachableClauseCollector
40
45
  LOOP_OR_BLOCK_NODE_CLASSES = [
41
46
  Prism::WhileNode, Prism::UntilNode, Prism::ForNode, Prism::BlockNode
@@ -124,11 +129,13 @@ module Rigor
124
129
  return if entry_type.nil? || entry_type.is_a?(Type::Bot) || entry_type.is_a?(Type::Dynamic)
125
130
 
126
131
  keyword = node.is_a?(Prism::CaseMatchNode) ? "in" : "when"
132
+ coverage = ClauseCoverage.new(scope, entry_type)
127
133
  node.conditions.each do |clause|
128
134
  case clause
129
- when Prism::WhenNode then collect_when(clause, subject.name)
130
- when Prism::InNode then collect_in(clause, subject.name)
135
+ when Prism::WhenNode then collect_when(clause, subject.name, coverage)
136
+ when Prism::InNode then collect_in(clause, subject.name, coverage)
131
137
  end
138
+ coverage.record(clause)
132
139
  end
133
140
  collect_else(node.else_clause, subject.name, keyword)
134
141
  end
@@ -138,7 +145,7 @@ module Rigor
138
145
  scope&.local(subject_name)
139
146
  end
140
147
 
141
- def collect_when(clause, subject_name)
148
+ def collect_when(clause, subject_name, coverage)
142
149
  return if clause.statements.nil? # empty body → no useful location
143
150
  return unless all_constant_conditions?(clause)
144
151
 
@@ -149,6 +156,7 @@ module Rigor
149
156
  kind = when_clause_kind(clause, subject_name)
150
157
  return if kind == :disjoint &&
151
158
  disjoint_nominal_subject?(@scope_index[clause.conditions.first], subject_name, clause.conditions)
159
+ return if kind == :prior_exhaustion && !coverage.proven?(clause)
152
160
 
153
161
  @results << Result.new(
154
162
  clause: clause, body: clause.statements, subject_name: subject_name,
@@ -187,7 +195,7 @@ module Rigor
187
195
  # (prior-exhaustion — true for ANY pattern downstream of a covering set). Non-bare patterns are not
188
196
  # narrowed, so their body is `bot` ONLY under prior-exhaustion; they never produce a spurious
189
197
  # `:disjoint`.
190
- def collect_in(clause, subject_name)
198
+ def collect_in(clause, subject_name, coverage)
191
199
  return if clause.statements.nil?
192
200
 
193
201
  scope = @scope_index[clause]
@@ -196,6 +204,7 @@ module Rigor
196
204
 
197
205
  kind = in_clause_kind(clause, subject_name)
198
206
  return if kind == :disjoint && disjoint_nominal_pattern?(clause, subject_name)
207
+ return if kind == :prior_exhaustion && !coverage.proven?(clause)
199
208
 
200
209
  @results << Result.new(
201
210
  clause: clause, body: clause.statements, subject_name: subject_name,