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
@@ -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,11 +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 rule-token check only flags a
30
- # token under a built-in family (`call`, `flow`, …) — a plugin- or `rbs_extended.*` rule id
31
- # (whose family Rigor cannot statically enumerate, since a `node_rule` block emits any
32
- # `rule:` string it likes) is left alone, so disabling a plugin rule is never mistaken for
33
- # a typo.
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.
34
37
  module ConfigAudit
35
38
  # One config-level finding. `kind` discriminates the source key (`:signature_path`,
36
39
  # `:bundled_plugin_signature_path`, `:library`, `:disabled_rule`, `:severity_override`,
@@ -146,31 +149,169 @@ module Rigor
146
149
  []
147
150
  end
148
151
 
149
- # `disable:` tokens and `severity_overrides:` keys that name no rule. Restricted to
150
- # tokens under a built-in family so a plugin rule id is never mis-flagged (see the module
151
- # docstring).
152
+ # `disable:` tokens that reach no row ({.disable_token_warning}) and `severity_overrides:` keys
153
+ # that reach none ({.override_key_warning}).
152
154
  def self.rule_token_warnings(configuration)
155
+ plugin_ids = configured_plugin_ids(configuration.plugins)
153
156
  disable = Array(configuration.disabled_rules).filter_map do |token|
154
- next unless inert_builtin_token?(token.to_s)
155
-
156
- Warning.new(
157
- kind: :disabled_rule,
158
- message: "disable: #{token.to_s.inspect} is not a recognized rule id; the suppression has no effect",
159
- fields: { "token" => token.to_s }
160
- )
157
+ disable_token_warning(token.to_s, plugin_ids)
161
158
  end
162
159
  overrides = configuration.severity_overrides.keys.filter_map do |key|
163
- next unless inert_builtin_token?(key.to_s)
164
-
165
- Warning.new(
166
- kind: :severity_override,
167
- message: "severity_overrides: #{key.to_s.inspect} is not a recognized rule id; the override has no effect",
168
- fields: { "token" => key.to_s }
169
- )
160
+ override_key_warning(key.to_s, plugin_ids)
170
161
  end
171
162
  disable + overrides
172
163
  end
173
164
 
165
+ # Issue #1985 — the plugin loader's own rows, which a `severity_overrides:` key names by their qualified
166
+ # id; they also answer to the bare `load-error` / `runtime-error`.
167
+ PLUGIN_LOADER_ROWS = %w[plugin_loader.load-error plugin_loader.runtime-error].freeze
168
+ private_constant :PLUGIN_LOADER_ROWS
169
+
170
+ # A warning for a `severity_overrides:` key that can reach no row, or nil. Mirrors {.disable_token_warning}
171
+ # (issue #1985): a plugin's row answers only to its qualified `plugin.<id>.<rule>` or `plugin.<id>`, so a
172
+ # `plugin.`-qualified key naming an unconfigured plugin, and any key outside the built-in vocabulary that is
173
+ # not a qualified plugin id (a plugin's bare rule included), is inert. A reserved run-level row is the home of
174
+ # this map, so naming one is never flagged; `all` is not a family wildcard here, so it is.
175
+ def self.override_key_warning(key, plugin_ids)
176
+ reason = override_key_problem(key, plugin_ids)
177
+ return nil if reason.nil?
178
+
179
+ Warning.new(kind: :severity_override, message: "severity_overrides: #{key.inspect} #{reason}",
180
+ fields: { "token" => key })
181
+ end
182
+
183
+ def self.override_key_problem(key, plugin_ids)
184
+ return "is not a recognized rule id; the override has no effect" if key == "all"
185
+ return nil unless Array(Analysis::CheckRules.resolve_rule_token(key)).empty?
186
+
187
+ if key.start_with?(Analysis::CheckRules::PLUGIN_FAMILY_PREFIX)
188
+ return nil if plugin_ids.nil? || plausible_plugin_token?(key, plugin_ids)
189
+
190
+ "names no plugin `plugins:` configures; the override has no effect"
191
+ elsif key.start_with?("plugin_loader.")
192
+ "is not a recognized rule id; the override has no effect" unless PLUGIN_LOADER_ROWS.include?(key)
193
+ elsif inert_builtin_token?(key)
194
+ "is not a recognized rule id; the override has no effect"
195
+ elsif !non_check_engine_token?(key) && !severity_only_id?(key)
196
+ "is not a recognized rule id; the override has no effect " \
197
+ "(if this is a plugin's rule, write it as `plugin.<id>.<rule>`)"
198
+ end
199
+ end
200
+ private_class_method :override_key_problem
201
+
202
+ # Issue #1953 — the run-level rows the diagnostic policy keeps on `severity_overrides:` and the
203
+ # baseline. `disable:` does not reach them by any spelling, so naming one there is inert.
204
+ RESERVED_RUN_LEVEL_FAMILIES = %w[plugin_loader plugin_trust rbs.coverage dynamic.dependency-source].freeze
205
+ RESERVED_RUN_LEVEL_IDS = %w[load-error runtime-error pool-degraded].freeze
206
+ private_constant :RESERVED_RUN_LEVEL_FAMILIES, :RESERVED_RUN_LEVEL_IDS
207
+
208
+ # A warning for a `disable:` token that can reach no row, or nil. Since issue #1953 the list's
209
+ # reach is exactly enumerable for three shapes, and those are what this flags:
210
+ #
211
+ # - a token naming a reserved run-level row ({RESERVED_RUN_LEVEL_FAMILIES} /
212
+ # {RESERVED_RUN_LEVEL_IDS}), which the list never reaches;
213
+ # - a `plugin.`-qualified token naming no plugin `plugins:` configures — checked only when
214
+ # every entry's manifest id is known without loading it ({.configured_plugin_ids});
215
+ # - any other token that is not `all`, a family, a legacy alias or a catalogue / `pre-eval.*`
216
+ # id. A bare id never reaches a plugin's row, so a plugin's bare rule is inert here too.
217
+ #
218
+ # A token under a non-check engine family (`rbs_extended.*`, `rbs.*`, `dynamic.*`) or a known
219
+ # non-catalogue engine id stays unflagged: under-warning is this module's FP-safe direction.
220
+ def self.disable_token_warning(token, plugin_ids)
221
+ return nil if effective_disable_token?(token)
222
+
223
+ reason = disable_token_problem(token, plugin_ids)
224
+ return nil if reason.nil?
225
+
226
+ Warning.new(kind: :disabled_rule, message: "disable: #{token.inspect} #{reason}",
227
+ fields: { "token" => token })
228
+ end
229
+
230
+ def self.disable_token_problem(token, plugin_ids)
231
+ if reserved_run_level_token?(token)
232
+ "names a run-level row, which `disable:` does not reach; use `severity_overrides:` or a baseline"
233
+ elsif token.start_with?(Analysis::CheckRules::PLUGIN_FAMILY_PREFIX)
234
+ unless plugin_ids.nil? || plausible_plugin_token?(token, plugin_ids)
235
+ "names no plugin `plugins:` configures; the suppression has no effect"
236
+ end
237
+ elsif inert_builtin_token?(token)
238
+ "is not a recognized rule id; the suppression has no effect"
239
+ elsif !non_check_engine_token?(token)
240
+ "is not a recognized rule id; the suppression has no effect " \
241
+ "(if this is a plugin's rule, write it as `plugin.<id>.<rule>`)"
242
+ end
243
+ end
244
+ private_class_method :disable_token_problem
245
+
246
+ def self.effective_disable_token?(token)
247
+ return true if token == "all"
248
+
249
+ !Array(Analysis::CheckRules.resolve_rule_token(token)).empty?
250
+ end
251
+ private_class_method :effective_disable_token?
252
+
253
+ def self.reserved_run_level_token?(token)
254
+ RESERVED_RUN_LEVEL_IDS.include?(token) ||
255
+ RESERVED_RUN_LEVEL_FAMILIES.any? { |family| token == family || token.start_with?("#{family}.") }
256
+ end
257
+ private_class_method :reserved_run_level_token?
258
+
259
+ # Bare ids that a `severity_overrides:` key re-stamps but a marker or `disable:` cannot suppress: the `--explain`
260
+ # `fallback` row and the `contribution_merge` conflict reasons (`FlowContribution::CONFLICT_VALID_REASONS`,
261
+ # kebab-cased). They are not in `CheckRules::NON_CHECK_DIAGNOSTIC_IDS`, which would silence the warnings for a
262
+ # `disable:` token or marker naming them. `spec/rigor/config_audit_spec.rb` pins this list's completeness.
263
+ CONFLICT_IDS = FlowContribution::CONFLICT_VALID_REASONS.map { |reason| reason.to_s.tr("_", "-").freeze }.freeze
264
+ SEVERITY_ONLY_IDS = (%w[fallback] + CONFLICT_IDS).freeze
265
+ private_constant :CONFLICT_IDS, :SEVERITY_ONLY_IDS
266
+
267
+ def self.severity_only_id?(key)
268
+ SEVERITY_ONLY_IDS.include?(key)
269
+ end
270
+ private_class_method :severity_only_id?
271
+
272
+ def self.non_check_engine_token?(token)
273
+ Analysis::CheckRules::NON_CHECK_DIAGNOSTIC_IDS.include?(token) ||
274
+ Analysis::CheckRules::NON_CHECK_DIAGNOSTIC_FAMILIES.include?(token.split(".").first)
275
+ end
276
+ private_class_method :non_check_engine_token?
277
+
278
+ # `plugin.<id>` or `plugin.<id>.<rule>` for a configured id. A manifest id may itself hold a
279
+ # dot, so the token is matched against each id rather than split.
280
+ def self.plausible_plugin_token?(token, plugin_ids)
281
+ rest = token.delete_prefix(Analysis::CheckRules::PLUGIN_FAMILY_PREFIX)
282
+ plugin_ids.any? { |id| rest == id || rest.start_with?("#{id}.") }
283
+ end
284
+ private_class_method :plausible_plugin_token?
285
+
286
+ # The manifest id of every `plugins:` entry, or nil when one cannot be known without loading
287
+ # the plugin. An explicit `id:` is the id; a bundled plugin's id is its gem name minus the
288
+ # `rigor-` prefix ({Plugin::FirstParty}); a third-party gem named without `id:` may register
289
+ # any id, so its presence turns the plugin-token check off. An `enabled: false` entry counts:
290
+ # the user has found the plugin, and under-warning is the FP-safe direction.
291
+ def self.configured_plugin_ids(plugins)
292
+ Array(plugins).each_with_object(Set.new) do |raw, ids|
293
+ entry = raw.is_a?(Hash) ? raw.to_h { |key, value| [key.to_s, value] } : { "gem" => raw.to_s }
294
+ if entry["id"]
295
+ ids << entry["id"].to_s
296
+ elsif bundled_plugin_gem?(entry["gem"].to_s)
297
+ ids << entry["gem"].to_s.delete_prefix(BUNDLED_GEM_PREFIX)
298
+ else
299
+ return nil
300
+ end
301
+ end
302
+ end
303
+ private_class_method :configured_plugin_ids
304
+
305
+ BUNDLED_GEM_PREFIX = "rigor-"
306
+ private_constant :BUNDLED_GEM_PREFIX
307
+
308
+ def self.bundled_plugin_gem?(gem)
309
+ return false unless gem.start_with?(BUNDLED_GEM_PREFIX)
310
+
311
+ File.file?(File.join(SignaturePathAudit::BUNDLED_PLUGINS_ROOT, gem, "lib", "#{gem}.rb"))
312
+ end
313
+ private_class_method :bundled_plugin_gem?
314
+
174
315
  # True when `token` looks like a built-in rule id but matches none — its first segment
175
316
  # is a built-in family yet it is neither a bare family wildcard nor a known canonical id.
176
317
  # A token whose family is not built-in (a plugin / `rbs_extended.*` rule, or a bare
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "../configuration_error"
4
+
3
5
  module Rigor
4
6
  class Configuration
5
7
  # Parsed `dependencies:` section of `.rigor.yml`. Per
@@ -56,7 +58,7 @@ module Rigor
56
58
  # Hash with `source_inference:` and / or `budget_per_gem:` / `budget_overrun_strategy:` present.
57
59
  def self.from_h(data)
58
60
  return new([]) if data.nil?
59
- raise ArgumentError, "dependencies: must be a Hash, got #{data.inspect}" unless data.is_a?(Hash)
61
+ raise ConfigurationError, "dependencies: must be a Hash, got #{data.inspect}" unless data.is_a?(Hash)
60
62
 
61
63
  raw_entries = coerce_source_inference(data["source_inference"])
62
64
  entries, warnings = dedupe_entries(raw_entries)
@@ -139,7 +141,7 @@ module Rigor
139
141
  return [] if value.nil? || value == false
140
142
 
141
143
  unless value.is_a?(Array)
142
- raise ArgumentError,
144
+ raise ConfigurationError,
143
145
  "dependencies.source_inference: must be a list of entries " \
144
146
  "(or false / omitted to disable), got #{value.inspect}"
145
147
  end
@@ -149,7 +151,7 @@ module Rigor
149
151
 
150
152
  def coerce_entry(raw)
151
153
  unless raw.is_a?(Hash)
152
- raise ArgumentError,
154
+ raise ConfigurationError,
153
155
  "dependencies.source_inference[] entry must be a Hash, got #{raw.inspect}"
154
156
  end
155
157
 
@@ -162,7 +164,7 @@ module Rigor
162
164
 
163
165
  def coerce_gem(value)
164
166
  unless value.is_a?(String) && !value.empty?
165
- raise ArgumentError,
167
+ raise ConfigurationError,
166
168
  "dependencies.source_inference[].gem must be a non-empty String, got #{value.inspect}"
167
169
  end
168
170
 
@@ -170,28 +172,34 @@ module Rigor
170
172
  end
171
173
 
172
174
  def coerce_mode(value)
173
- mode = (value || "when_missing").to_sym
175
+ mode = config_symbol(value || "when_missing")
174
176
  return mode if VALID_MODES.include?(mode)
175
177
 
176
- raise ArgumentError,
178
+ raise ConfigurationError,
177
179
  "dependencies.source_inference[].mode must be one of " \
178
180
  "#{VALID_MODES.inspect}, got #{value.inspect}"
179
181
  end
180
182
 
183
+ # The Symbol a String / Symbol value names, or nil for any other YAML shape (a number, a list, a mapping), which
184
+ # the caller then reports as not one of its valid values rather than crashing on `#to_sym`.
185
+ def config_symbol(value)
186
+ value.to_sym if value.is_a?(String) || value.is_a?(Symbol)
187
+ end
188
+
181
189
  def coerce_roots(raw)
182
190
  roots = Array(raw.fetch("roots", DEFAULT_ROOTS)).map(&:to_s).freeze
183
191
  return roots unless roots.empty?
184
192
 
185
- raise ArgumentError,
193
+ raise ConfigurationError,
186
194
  "dependencies.source_inference[].roots must not be empty when supplied " \
187
195
  "(omit the key to fall back to the default #{DEFAULT_ROOTS.inspect})"
188
196
  end
189
197
 
190
198
  def coerce_budget_overrun_strategy(value)
191
- symbol = value.to_sym
199
+ symbol = config_symbol(value)
192
200
  return symbol if VALID_BUDGET_OVERRUN_STRATEGIES.include?(symbol)
193
201
 
194
- raise ArgumentError,
202
+ raise ConfigurationError,
195
203
  "dependencies.budget_overrun_strategy must be one of " \
196
204
  "#{VALID_BUDGET_OVERRUN_STRATEGIES.inspect}, got #{value.inspect}"
197
205
  end
@@ -201,13 +209,13 @@ module Rigor
201
209
  # MIN_BUDGET_PER_GEM .. MAX_BUDGET_PER_GEM (i.e. 0.25× – 4× of the default).
202
210
  def coerce_budget_per_gem(value)
203
211
  unless value.is_a?(Integer)
204
- raise ArgumentError,
212
+ raise ConfigurationError,
205
213
  "dependencies.budget_per_gem must be an Integer, " \
206
214
  "got #{value.inspect}"
207
215
  end
208
216
 
209
217
  unless value.between?(MIN_BUDGET_PER_GEM, MAX_BUDGET_PER_GEM)
210
- raise ArgumentError,
218
+ raise ConfigurationError,
211
219
  "dependencies.budget_per_gem must be in the range " \
212
220
  "#{MIN_BUDGET_PER_GEM}..#{MAX_BUDGET_PER_GEM}, " \
213
221
  "got #{value.inspect}"
@@ -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.
@@ -8,6 +8,7 @@ require_relative "configuration/dependencies"
8
8
  require_relative "configuration/severity_profile"
9
9
  require_relative "config_path"
10
10
  require_relative "configuration_error"
11
+ require_relative "rooted_glob"
11
12
  require_relative "effects/entry_points"
12
13
  require_relative "effects/label"
13
14
  require_relative "effects/method_key"
@@ -995,9 +996,34 @@ module Rigor
995
996
  # this entry" so a templated `**` pattern in a fresh project doesn't generate an error per match-less
996
997
  # pattern.
997
998
  def expand_pre_eval_entries(entries)
998
- entries.flat_map do |entry|
999
- glob_pattern?(entry) ? Dir.glob(entry, sort: true) : [entry]
1000
- end.uniq.freeze
999
+ entries.flat_map { |entry| expand_pre_eval_entry(entry) }.uniq.freeze
1000
+ end
1001
+
1002
+ # #1956 — a relative entry arrives joined onto the config file's directory, so the metacharacters the
1003
+ # check above sees may be that directory's own (`ds [x]/`). The leading run of segments that name existing
1004
+ # directories is read literally and only the rest is globbed under it: a literal entry under such a
1005
+ # directory stays a literal path, and a pattern matches what it matches under a plain directory.
1006
+ def expand_pre_eval_entry(entry)
1007
+ return [entry] unless glob_pattern?(entry)
1008
+
1009
+ base, pattern = literal_directory_prefix(entry)
1010
+ return [entry] unless glob_pattern?(pattern)
1011
+
1012
+ base ? RootedGlob.glob(base, pattern) : Dir.glob(entry, sort: true)
1013
+ end
1014
+
1015
+ def literal_directory_prefix(entry)
1016
+ segments = entry.split("/")
1017
+ count = (1...segments.size).take_while { |n| File.directory?(join_segments(segments.take(n))) }.size
1018
+ return [nil, entry] if count.zero?
1019
+
1020
+ [join_segments(segments.take(count)), segments.drop(count).join("/")]
1021
+ end
1022
+
1023
+ # `"/a".split("/")` leads with an empty segment, which spells the filesystem root.
1024
+ def join_segments(segments)
1025
+ path = segments.join("/")
1026
+ path.empty? ? "/" : path
1001
1027
  end
1002
1028
 
1003
1029
  def glob_pattern?(path)
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "../rooted_glob"
4
+
3
5
  module Rigor
4
6
  module Effects
5
7
  # Everything an effect envelope may be written in, as `[buffer name, RBS source]` pairs
@@ -55,7 +57,7 @@ module Rigor
55
57
  # @param signature_paths — the configured roots; empty / nil takes the default.
56
58
  # @param virtual_rbs — the loader's virtual entries.
57
59
  def collect(signature_paths:, virtual_rbs: nil)
58
- files = roots(signature_paths).flat_map { |root| Dir.glob(File.join(root.to_s, "**", "*.rbs")).sort }
60
+ files = roots(signature_paths).flat_map { |root| RootedGlob.glob(root, "**/*.rbs").sort }
59
61
  sources = files.filter_map do |path|
60
62
  [path, File.read(path)]
61
63
  rescue StandardError
@@ -3,6 +3,7 @@
3
3
  require "yaml"
4
4
 
5
5
  require_relative "../config_path"
6
+ require_relative "../rooted_glob"
6
7
 
7
8
  module Rigor
8
9
  class Environment
@@ -91,8 +92,8 @@ module Rigor
91
92
  # all (`Bundler::Source::Path#path` is the user's own `path:` directory, used in place), so there is
92
93
  # no bundle-relative location to walk. A `path:`-sourced gem's `sig/` is only reachable by adding it
93
94
  # directly to the project's `signature_paths:` — which already works today and needs no discovery.
94
- all = (Dir.glob(resolved.join("ruby", "*", "gems", "*", "sig")) +
95
- Dir.glob(resolved.join("ruby", "*", "bundler", "gems", "*", "sig"))).map { |d| Pathname.new(d) }
95
+ all = (RootedGlob.glob(resolved, "ruby/*/gems/*/sig") +
96
+ RootedGlob.glob(resolved, "ruby/*/bundler/gems/*/sig")).map { |d| Pathname.new(d) }
96
97
  filtered = all.reject { |sig_dir| skip_gems.include?(gem_name_from_sig_path(sig_dir)) }
97
98
  return filtered if locked_gems.nil? || locked_gems.empty?
98
99
 
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "rbs"
4
+ require_relative "../rooted_glob"
4
5
 
5
6
  module Rigor
6
7
  class Environment
@@ -15,7 +16,7 @@ module Rigor
15
16
  # @param dir — the vendored directory (Pathname).
16
17
  # @return the absolute-less names (`"Prime::PseudoPrimeGenerator"`) of every class and module it declares.
17
18
  def type_names(dir)
18
- Dir.glob(File.join(dir.to_s, "*.rbs")).flat_map do |file|
19
+ RootedGlob.glob(dir, "*.rbs").flat_map do |file|
19
20
  _, _, decls = RBS::Parser.parse_signature(File.read(file))
20
21
  names(decls, nil)
21
22
  rescue RBS::BaseError, SystemCallError
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "rbs"
4
+ require_relative "../rooted_glob"
4
5
 
5
6
  module Rigor
6
7
  class Environment
@@ -132,7 +133,7 @@ module Rigor
132
133
  end
133
134
 
134
135
  def signature_files(roots)
135
- roots.flat_map { |root| Dir.glob(File.join(root.to_s, "*.rbs")).sort }
136
+ roots.flat_map { |root| RootedGlob.glob(root, "*.rbs").sort }
136
137
  end
137
138
 
138
139
  def shell_buffer
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "lockfile_resolver"
4
+ require_relative "../rooted_glob"
4
5
 
5
6
  module Rigor
6
7
  class Environment
@@ -47,7 +48,7 @@ module Rigor
47
48
  base = Pathname.new(bundle_path)
48
49
  return {} unless base.directory?
49
50
 
50
- Dir.glob(base.join("ruby", "*", "gems", "*")).each_with_object({}) do |dir, acc|
51
+ RootedGlob.glob(base, "ruby/*/gems/*").each_with_object({}) do |dir, acc|
51
52
  name, version, platform = split_install_dirname(File.basename(dir))
52
53
  next unless name
53
54
 
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "prism"
4
+ require_relative "../rooted_glob"
4
5
 
5
6
  module Rigor
6
7
  class Environment
@@ -71,7 +72,7 @@ module Rigor
71
72
  base = Pathname.new(bundle_path)
72
73
  return {} unless base.directory?
73
74
 
74
- Dir.glob(base.join("ruby", "*", "gems", "*")).each_with_object({}) do |dir, acc|
75
+ RootedGlob.glob(base, "ruby/*/gems/*").each_with_object({}) do |dir, acc|
75
76
  acc[File.basename(dir)] ||= dir
76
77
  end
77
78
  end
@@ -114,7 +115,7 @@ module Rigor
114
115
  base = Pathname.new(bundle_path)
115
116
  return {} unless base.directory?
116
117
 
117
- Dir.glob(base.join("ruby", "*", "bundler", "gems", "*")).each_with_object({}) do |dir, acc|
118
+ RootedGlob.glob(base, "ruby/*/bundler/gems/*").each_with_object({}) do |dir, acc|
118
119
  name = gemspec_gem_name(dir)
119
120
  acc[name] ||= dir if name
120
121
  end
@@ -124,7 +125,7 @@ module Rigor
124
125
  # exactly one. Bundler requires a git-sourced gem to ship one, so "not exactly one" means this is not
125
126
  # the shape we think it is — and a guess from the directory basename is what this exists to avoid.
126
127
  def gemspec_gem_name(dir)
127
- specs = Dir.glob(File.join(dir, "*.gemspec"))
128
+ specs = RootedGlob.glob(dir, "*.gemspec")
128
129
  return nil unless specs.size == 1
129
130
 
130
131
  File.basename(specs.first, ".gemspec")
@@ -10,6 +10,7 @@ require_relative "member_consistency"
10
10
  require_relative "required_features"
11
11
  require_relative "gated_signature_guard"
12
12
  require_relative "gated_signature_plan"
13
+ require_relative "../rooted_glob"
13
14
 
14
15
  module Rigor
15
16
  class Environment
@@ -66,6 +67,15 @@ module Rigor
66
67
  QUARANTINE_WARN_LIMIT = 10
67
68
 
68
69
  class << self
70
+ # Issue #1902 — the fixed total order that picks ONE detail tuple for a class out of several: its two
71
+ # sides in {#report_definition_build_failure}, or a fresh and a replayed record in
72
+ # {Analysis::Runner::PoolCoordinator}. Content-only (the tuple carries no side), so both places choose
73
+ # the same tuple from the same candidates, whatever order those candidates arrived in.
74
+ def definition_build_detail_order(detail)
75
+ _, error_class, member, buffers, unresolved = detail
76
+ [error_class.to_s, member.to_s, Array(buffers).map(&:to_s), Array(unresolved).map(&:to_s)]
77
+ end
78
+
69
79
  def default
70
80
  @default ||= new.freeze
71
81
  end
@@ -540,7 +550,7 @@ module Rigor
540
550
  path = Pathname(path) unless path.is_a?(Pathname)
541
551
  next [] unless path.directory?
542
552
 
543
- Dir.glob(path.join("**", "*.rbs")).map { |p| File.expand_path(p) }
553
+ RootedGlob.glob(path, "**/*.rbs").map { |p| File.expand_path(p) }
544
554
  end.to_set
545
555
  end
546
556
 
@@ -1907,6 +1917,25 @@ module Rigor
1907
1917
  (@state[:definition_build_failures] || []).dup.freeze
1908
1918
  end
1909
1919
 
1920
+ # Issue #1929 — re-ask for `class_name`'s definition on behalf of a PREVIOUS run's analysis, which
1921
+ # demanded it and found it failing. An `--incremental` recheck replays that run's failures, and one
1922
+ # whose conflicting buffer is a file the recheck re-synthesized cannot be replayed as it stands (the
1923
+ # edit may have fixed it), while the file that demanded the class may sit outside the recheck's
1924
+ # closure and never ask again. Re-asking here re-derives the answer over the current signatures: a
1925
+ # class that still fails is reported with its current tuple, one that now builds is not.
1926
+ #
1927
+ # A real demand, not {#during_internal_demand}: it stands in for the analysis's own, which is what
1928
+ # #696 binds the reported set to. Both sides are asked, since the replayed tuple does not record which
1929
+ # side the analysis demanded ({#report_definition_build_failure} builds both anyway).
1930
+ #
1931
+ # @return the class's current tuple, or nil when it now builds.
1932
+ def redemand_definition(class_name)
1933
+ key = class_name.to_s.delete_prefix("::")
1934
+ instance_definition(key)
1935
+ singleton_definition(key)
1936
+ definition_build_failures.find { |failure| failure.first == key }
1937
+ end
1938
+
1910
1939
  # Virtual (inline-synthesized) contributions dropped by the collision quarantine
1911
1940
  # ({.resolve_quarantining_virtual_collisions}): buffer names absent from the built env even though the
1912
1941
  # entry's content is non-empty and parses (a parse failure is the synthesizer's own WD6 skip, reported
@@ -2471,6 +2500,18 @@ module Rigor
2471
2500
  # fail-soft: a broken RBS environment yields no entries.
2472
2501
  end
2473
2502
 
2503
+ # ADR-113 — the RBS declaration of the constant `name` (qualified) as `[type, buffer name]`, or nil when no
2504
+ # signature declares it. `rigor lens` reports the declared type with the provenance the buffer names.
2505
+ def constant_declaration(name)
2506
+ rbs_name = parse_type_name(name)
2507
+ entry = rbs_name && env&.constant_decls&.[](rbs_name)
2508
+ return nil unless entry
2509
+
2510
+ [entry.decl.type.to_s, entry.decl.location&.buffer&.name.to_s]
2511
+ rescue ::RBS::BaseError
2512
+ nil
2513
+ end
2514
+
2474
2515
  # Slice A constant-value lookup. Returns the translated `Rigor::Type` for a non-class constant
2475
2516
  # declaration (`BUCKETS: Array[Symbol]`, `DEFAULT_PATH: String`, ...) or `nil` when no constant entry
2476
2517
  # exists for `name` in the loaded RBS environment. Callers MUST treat the return value as authoritative
@@ -2904,15 +2945,16 @@ module Rigor
2904
2945
  #
2905
2946
  # So the rescue only remembers, keyed by the `::`-stripped name (the eager walk spells it `::Acme`,
2906
2947
  # a dispatch site spells it `Acme`, and they are one class — the {#synthesized_namespaces}
2907
- # convention), and {#report_definition_build_failure} decides. First writer wins: the instance and
2908
- # singleton sides fail for the same underlying collision, and the first is the one a caller hit.
2948
+ # convention), and {#report_definition_build_failure} decides. Every side's detail is kept, not the
2949
+ # first one written (issue #1902): the instance and singleton sides can fail for DIFFERENT reasons
2950
+ # (a duplicated `def self.s` and a duplicated `def i` on one class), and which side a run demands first
2951
+ # depends on which files it analysed, so first-writer-wins made the reported tuple — and with it the
2952
+ # message's "First failure", advice and file list — differ between a cold run and a warm recheck.
2909
2953
  def store_definition_build_detail(class_name, error)
2910
2954
  key = class_name.to_s.delete_prefix("::")
2911
- details = (@state[:definition_build_details] ||= {})
2912
- return if details.key?(key)
2913
-
2914
- details[key] = [key, error.class.name.to_s, definition_build_member(error),
2915
- definition_build_conflict_buffers(error), unresolved_type_name_detail(error)].freeze
2955
+ details = ((@state[:definition_build_details] ||= {})[key] ||= [])
2956
+ details << [key, error.class.name.to_s, definition_build_member(error),
2957
+ definition_build_conflict_buffers(error), unresolved_type_name_detail(error)].freeze
2916
2958
  end
2917
2959
 
2918
2960
  # Issue #997 — the `RBS::NoTypeFoundError` twin of {#definition_build_member}: `nil` for every other
@@ -2987,14 +3029,28 @@ module Rigor
2987
3029
  return if details.nil?
2988
3030
 
2989
3031
  key = class_name.to_s.delete_prefix("::")
2990
- detail = details[key]
2991
- return if detail.nil?
3032
+ return if details[key].nil?
2992
3033
 
2993
3034
  reported = (@state[:definition_build_reported] ||= {})
2994
3035
  return if reported[key]
2995
3036
 
2996
3037
  reported[key] = true
2997
- (@state[:definition_build_failures] ||= []) << detail
3038
+ attempt_both_definition_sides(class_name)
3039
+ canonical = details[key].min_by { |candidate| self.class.definition_build_detail_order(candidate) }
3040
+ (@state[:definition_build_failures] ||= []) << canonical
3041
+ end
3042
+
3043
+ # Issue #1902 — a reported class's tuple is chosen from BOTH sides' failures, so the side this demand
3044
+ # did not ask for is built here too. Without it, the candidate set would be whatever sides the run
3045
+ # happened to demand, and a recheck whose closure touches only `Berry.s` would report the singleton
3046
+ # side's reason where the full run, which also demanded `Berry.new.i`, reported the instance side's.
3047
+ # An internal demand: it decides WHICH reason is shown for a class the analysis already demanded, never
3048
+ # WHETHER a class is reported, so the set #696 binds to the analysis's own demand is unchanged.
3049
+ def attempt_both_definition_sides(class_name)
3050
+ during_internal_demand do
3051
+ instance_definition(class_name)
3052
+ singleton_definition(class_name)
3053
+ end
2998
3054
  end
2999
3055
 
3000
3056
  # Marks a definition demand that is RIGOR'S OWN, not the analysed program's, so