rigortype 0.1.18 → 0.1.19

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 (89) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +159 -224
  3. data/lib/rigor/analysis/check_rules/always_truthy_condition_collector.rb +9 -3
  4. data/lib/rigor/analysis/check_rules/dead_assignment_collector.rb +25 -0
  5. data/lib/rigor/analysis/check_rules/ivar_write_collector.rb +29 -0
  6. data/lib/rigor/analysis/check_rules/main_pass_collector.rb +54 -0
  7. data/lib/rigor/analysis/check_rules/rule_walk.rb +169 -23
  8. data/lib/rigor/analysis/check_rules/unreachable_clause_collector.rb +9 -3
  9. data/lib/rigor/analysis/check_rules.rb +266 -63
  10. data/lib/rigor/analysis/diagnostic.rb +8 -0
  11. data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +2 -1
  12. data/lib/rigor/analysis/runner/project_pre_passes.rb +4 -1
  13. data/lib/rigor/analysis/runner.rb +58 -21
  14. data/lib/rigor/analysis/worker_session.rb +21 -11
  15. data/lib/rigor/bleeding_edge.rb +123 -0
  16. data/lib/rigor/cache/descriptor.rb +86 -8
  17. data/lib/rigor/cache/rbs_descriptor.rb +2 -1
  18. data/lib/rigor/cli/annotate_command.rb +100 -15
  19. data/lib/rigor/cli/check_command.rb +3 -0
  20. data/lib/rigor/cli/plugins_command.rb +2 -4
  21. data/lib/rigor/cli/plugins_renderer.rb +0 -2
  22. data/lib/rigor/cli/show_bleedingedge_command.rb +114 -0
  23. data/lib/rigor/cli/triage_command.rb +6 -3
  24. data/lib/rigor/cli/triage_renderer.rb +15 -1
  25. data/lib/rigor/cli.rb +9 -1
  26. data/lib/rigor/configuration/severity_profile.rb +13 -1
  27. data/lib/rigor/configuration.rb +57 -1
  28. data/lib/rigor/environment/rbs_loader.rb +25 -0
  29. data/lib/rigor/inference/body_fixpoint.rb +89 -0
  30. data/lib/rigor/inference/budget_trace.rb +29 -2
  31. data/lib/rigor/inference/expression_typer.rb +1052 -43
  32. data/lib/rigor/inference/macro_block_self_type.rb +2 -2
  33. data/lib/rigor/inference/method_dispatcher/array_to_h_folding.rb +60 -0
  34. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +54 -14
  35. data/lib/rigor/inference/method_dispatcher/reduce_folding.rb +281 -0
  36. data/lib/rigor/inference/method_dispatcher/regexp_folding.rb +71 -0
  37. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +148 -10
  38. data/lib/rigor/inference/method_dispatcher.rb +72 -1
  39. data/lib/rigor/inference/method_parameter_binder.rb +56 -2
  40. data/lib/rigor/inference/multi_target_binder.rb +46 -3
  41. data/lib/rigor/inference/mutation_widening.rb +142 -0
  42. data/lib/rigor/inference/narrowing.rb +270 -37
  43. data/lib/rigor/inference/scope_indexer.rb +696 -25
  44. data/lib/rigor/inference/statement_evaluator.rb +963 -16
  45. data/lib/rigor/inference/synthetic_method_scanner.rb +1 -1
  46. data/lib/rigor/plugin/base.rb +235 -79
  47. data/lib/rigor/plugin/macro/block_as_method.rb +22 -21
  48. data/lib/rigor/plugin/macro/nested_class_template.rb +9 -7
  49. data/lib/rigor/plugin/macro.rb +2 -3
  50. data/lib/rigor/plugin/manifest.rb +4 -24
  51. data/lib/rigor/plugin/node_rule_walk.rb +59 -14
  52. data/lib/rigor/plugin/registry.rb +12 -11
  53. data/lib/rigor/scope/discovery_index.rb +2 -0
  54. data/lib/rigor/scope.rb +132 -6
  55. data/lib/rigor/sig_gen/generator.rb +8 -0
  56. data/lib/rigor/triage/catalogue.rb +4 -19
  57. data/lib/rigor/triage.rb +69 -1
  58. data/lib/rigor/type/combinator.rb +29 -0
  59. data/lib/rigor/version.rb +1 -1
  60. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable.rb +13 -29
  61. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer.rb +13 -32
  62. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +27 -90
  63. data/plugins/rigor-activejob/lib/rigor/plugin/activejob.rb +13 -30
  64. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +20 -19
  65. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage.rb +10 -8
  66. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot.rb +11 -40
  67. data/plugins/rigor-mangrove/lib/rigor/plugin/mangrove.rb +1 -1
  68. data/plugins/rigor-pundit/lib/rigor/plugin/pundit.rb +10 -21
  69. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +21 -34
  70. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +11 -18
  71. data/plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline.rb +0 -1
  72. data/plugins/rigor-rspec/lib/rigor/plugin/rspec.rb +2 -13
  73. data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers.rb +3 -23
  74. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq.rb +8 -21
  75. data/plugins/rigor-sinatra/lib/rigor/plugin/sinatra.rb +1 -1
  76. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +25 -0
  77. data/sig/rigor/analysis/fact_store.rbs +3 -0
  78. data/sig/rigor/inference/builtins/method_catalog.rbs +1 -1
  79. data/sig/rigor/plugin/base.rbs +5 -2
  80. data/sig/rigor/plugin/manifest.rbs +1 -2
  81. data/sig/rigor/scope.rbs +10 -1
  82. data/sig/rigor/type.rbs +1 -0
  83. data/sig/rigor.rbs +1 -1
  84. data/skills/rigor-baseline-reduce/references/01-classify.md +27 -0
  85. data/skills/rigor-plugin-author/SKILL.md +6 -4
  86. data/skills/rigor-plugin-author/references/02-walker-and-types.md +22 -17
  87. data/skills/rigor-project-init/references/03-baseline-and-bugs.md +18 -1
  88. metadata +7 -2
  89. data/lib/rigor/plugin/macro/external_file.rb +0 -143
@@ -12,6 +12,7 @@ require_relative "check_rules/always_truthy_condition_collector"
12
12
  require_relative "check_rules/unreachable_clause_collector"
13
13
  require_relative "check_rules/dead_assignment_collector"
14
14
  require_relative "check_rules/ivar_write_collector"
15
+ require_relative "check_rules/main_pass_collector"
15
16
  require_relative "check_rules/self_closedness_scanner"
16
17
 
17
18
  module Rigor
@@ -163,59 +164,149 @@ module Rigor
163
164
  # @param root [Prism::Node]
164
165
  # @param scope_index [Hash{Prism::Node => Rigor::Scope}]
165
166
  # @return [Array<Rigor::Analysis::Diagnostic>]
166
- def diagnose(path:, root:, scope_index:, self_call_misses: [], comments: [], disabled_rules: [])
167
- diagnostics = []
168
- Source::NodeWalker.each(root) do |node|
169
- case node
170
- when Prism::CallNode
171
- diagnostics.concat(call_node_diagnostics(path, node, scope_index))
172
- when Prism::DefNode
173
- return_diagnostic = return_type_mismatch_diagnostic(path, node, scope_index)
174
- diagnostics << return_diagnostic if return_diagnostic
175
- override_vis = override_visibility_diagnostic(path, node, scope_index)
176
- diagnostics << override_vis if override_vis
177
- override_return = override_return_widened_diagnostic(path, node, scope_index)
178
- diagnostics << override_return if override_return
179
- override_param = override_param_narrowed_diagnostic(path, node, scope_index)
180
- diagnostics << override_param if override_param
181
- when Prism::IfNode, Prism::UnlessNode
182
- unreachable = unreachable_branch_diagnostic(path, node, scope_index)
183
- diagnostics << unreachable if unreachable
184
- end
185
- end
167
+ #
168
+ # ADR-53 B4 — when `node_collectors` is supplied, the converged
169
+ # {Plugin::NodeRuleWalk} traversal has already populated the built-in
170
+ # collectors (including the main pass) in one shared walk with the
171
+ # plugin node-rules, so they are consumed as-is. When it is nil (a
172
+ # direct caller with no plugin walk, e.g. a unit test), the standalone
173
+ # {RuleWalk} walk runs here instead, so `diagnose` stays correct
174
+ # without the converged path.
175
+ def diagnose(path:, root:, scope_index:, self_call_misses: [], comments: [], disabled_rules: [],
176
+ node_collectors: nil)
177
+ collectors = node_collectors || run_node_collectors(path, root, scope_index)
178
+ diagnostics = collectors[:main_pass].results.dup
186
179
  diagnostics.concat(self_undefined_method_diagnostics(path, self_call_misses, root, scope_index))
187
- always_truthy_results, unreachable_clause_results = flow_collector_results(root, scope_index)
188
- diagnostics.concat(always_truthy_condition_diagnostics(path, always_truthy_results))
189
- diagnostics.concat(unreachable_clause_diagnostics(path, unreachable_clause_results))
190
- diagnostics.concat(ivar_write_mismatch_diagnostics(path, root, scope_index))
191
- diagnostics.concat(dead_assignment_diagnostics(path, root, scope_index))
180
+ diagnostics.concat(always_truthy_condition_diagnostics(path, collectors[:always_truthy].results))
181
+ diagnostics.concat(unreachable_clause_diagnostics(path, collectors[:unreachable_clauses].results))
182
+ diagnostics.concat(ivar_write_mismatch_diagnostics(path, collectors[:ivar_writes].results))
183
+ diagnostics.concat(dead_assignment_diagnostics(path, collectors[:dead_assignments].results))
192
184
  filter_suppressed(diagnostics, comments: comments, disabled_rules: disabled_rules)
193
185
  end
194
186
 
195
- # ADR-53 Track B (slice B2) both flow collectors ride one
196
- # {RuleWalk} traversal instead of walking the file once each.
197
- # Under `RIGOR_SHADOW_RULE_WALK=1` the legacy per-collector walks
198
- # also run as the oracle and any divergence aborts the run — the
199
- # corpus-scale half of the equivalence harness (the curated half
200
- # is `rule_walk_equivalence_spec`).
201
- def flow_collector_results(root, scope_index)
202
- always_truthy = AlwaysTruthyConditionCollector.new(scope_index)
203
- unreachable_clauses = UnreachableClauseCollector.new(scope_index)
204
- RuleWalk.run(root, [always_truthy, unreachable_clauses])
205
- if ENV["RIGOR_SHADOW_RULE_WALK"]
206
- shadow_verify_flow_collectors(root, scope_index, always_truthy.results, unreachable_clauses.results)
207
- end
208
- [always_truthy.results, unreachable_clauses.results]
187
+ # The verbatim per-node dispatch of the former inline main pass
188
+ # (`diagnose`'s `Source::NodeWalker.each` `case`), now invoked by
189
+ # {MainPassCollector} on the shared {RuleWalk}. Returns the
190
+ # diagnostics for one node, in the same emission order as before.
191
+ def main_pass_node_diagnostics(path, node, scope_index)
192
+ case node
193
+ when Prism::CallNode
194
+ call_node_diagnostics(path, node, scope_index)
195
+ when Prism::DefNode
196
+ [
197
+ return_type_mismatch_diagnostic(path, node, scope_index),
198
+ override_visibility_diagnostic(path, node, scope_index),
199
+ override_return_widened_diagnostic(path, node, scope_index),
200
+ override_param_narrowed_diagnostic(path, node, scope_index)
201
+ ].compact
202
+ when Prism::IfNode, Prism::UnlessNode
203
+ [unreachable_branch_diagnostic(path, node, scope_index)].compact
204
+ else
205
+ []
206
+ end
207
+ end
208
+
209
+ # Constructs the fresh, unpopulated built-in collector set keyed by
210
+ # role, including the main pass. Split out so the converged walk
211
+ # (ADR-53 B4) can build the collectors, drive them via a
212
+ # {RuleWalk::CollectorDriver} inside the single {Plugin::NodeRuleWalk}
213
+ # traversal, and hand the populated set back to {.diagnose} as
214
+ # `node_collectors:`. The main pass needs `path` because its per-node
215
+ # diagnostics carry it (ADR-53 B3c hosts it on the same walk).
216
+ def build_node_collectors(path, scope_index)
217
+ {
218
+ main_pass: MainPassCollector.new(->(node) { main_pass_node_diagnostics(path, node, scope_index) }),
219
+ always_truthy: AlwaysTruthyConditionCollector.new(scope_index),
220
+ unreachable_clauses: UnreachableClauseCollector.new(scope_index),
221
+ ivar_writes: IvarWriteCollector.new(scope_index),
222
+ dead_assignments: DeadAssignmentCollector.new(scope_index)
223
+ }
224
+ end
225
+
226
+ # A {RuleWalk::CollectorDriver} over a built-in collector set, for a
227
+ # foreign traversal to drive (ADR-53 B4). The driver visits each node
228
+ # and derives child contexts exactly as the standalone {RuleWalk}
229
+ # walk would.
230
+ def node_collector_driver(collectors)
231
+ RuleWalk::CollectorDriver.new(collectors.values)
232
+ end
233
+
234
+ # ADR-53 Track B — the {RuleWalk}-hosted built-in collectors (the main
235
+ # pass and the four fact collectors) all ride one traversal of the
236
+ # file instead of one walk each. Returns the populated collectors
237
+ # keyed by role so the caller can build the diagnostics from each
238
+ # collector's `results`. Used on the standalone path (no converged
239
+ # plugin walk); the converged path populates the same collector set
240
+ # via {.node_collector_driver} instead.
241
+ #
242
+ # Under `RIGOR_SHADOW_RULE_WALK=1` each hosted collector's legacy
243
+ # single-collector `#collect` walk also runs as the oracle and any
244
+ # divergence aborts the run — the corpus-scale half of the
245
+ # equivalence harness (the curated half is `rule_walk_equivalence_spec`).
246
+ def run_node_collectors(path, root, scope_index)
247
+ collectors = build_node_collectors(path, scope_index)
248
+ RuleWalk.run(root, collectors.values)
249
+ shadow_verify_node_collectors(path, root, scope_index, collectors) if ENV["RIGOR_SHADOW_RULE_WALK"]
250
+ collectors
251
+ end
252
+
253
+ def shadow_verify_node_collectors(path, root, scope_index, collectors)
254
+ divergences = collectors.filter_map do |role, collector|
255
+ legacy = oracle_results(role, collector, path, root, scope_index)
256
+ next if comparable(legacy) == comparable(collector.results)
257
+
258
+ "#{role} legacy=#{legacy.size} walk=#{collector.results.size}"
259
+ end
260
+ return if divergences.empty?
261
+
262
+ raise "RIGOR_SHADOW_RULE_WALK divergence: #{divergences.join('; ')}"
263
+ end
264
+
265
+ # Normalises a collector's result for value comparison. The fact
266
+ # collectors return `Data` / Hash structures that already compare by
267
+ # value; the main pass returns {Diagnostic} objects (plain objects
268
+ # with identity `==`), so serialise those to hashes first.
269
+ def comparable(results)
270
+ return results.map(&:to_h) if results.is_a?(Array) && results.first.is_a?(Diagnostic)
271
+
272
+ results
209
273
  end
210
274
 
211
- def shadow_verify_flow_collectors(root, scope_index, always_truthy_results, unreachable_clause_results)
212
- legacy_always = AlwaysTruthyConditionCollector.new(scope_index).collect(root)
213
- legacy_clauses = UnreachableClauseCollector.new(scope_index).collect(root)
214
- return if legacy_always == always_truthy_results && legacy_clauses == unreachable_clause_results
275
+ # The oracle each hosted collector's walk result is checked against.
276
+ # The fact collectors re-run their legacy single-collector `#collect`
277
+ # walk; the main pass re-runs the former inline `Source::NodeWalker`
278
+ # `case` (`main_pass_oracle`) since its diagnostics are the result.
279
+ def oracle_results(role, collector, path, root, scope_index)
280
+ return main_pass_oracle(path, root, scope_index) if role == :main_pass
215
281
 
216
- raise "RIGOR_SHADOW_RULE_WALK divergence: always-truthy legacy=#{legacy_always.size} " \
217
- "walk=#{always_truthy_results.size}; unreachable-clause legacy=#{legacy_clauses.size} " \
218
- "walk=#{unreachable_clause_results.size}"
282
+ collector.class.new(scope_index).collect(root)
283
+ end
284
+
285
+ # The former inline main pass, kept as the shadow oracle: walks the
286
+ # tree with `Source::NodeWalker.each` and accumulates the same
287
+ # per-node diagnostics in the same order {MainPassCollector} now
288
+ # produces them on the shared walk.
289
+ def main_pass_oracle(path, root, scope_index)
290
+ diagnostics = []
291
+ Source::NodeWalker.each(root) do |node|
292
+ diagnostics.concat(main_pass_node_diagnostics(path, node, scope_index))
293
+ end
294
+ diagnostics
295
+ end
296
+
297
+ # ADR-53 B4 — corpus-scale oracle for the CONVERGED walk: the
298
+ # collectors (including the main pass, ADR-53 B3c) were populated by
299
+ # the {Plugin::NodeRuleWalk} traversal, not by `RuleWalk.run`, so
300
+ # re-run each collector's legacy oracle (the fact collectors'
301
+ # `#collect` walk, the main pass's inline `Source::NodeWalker` `case`)
302
+ # and assert the converged walk produced byte-identical results. Same
303
+ # divergence contract as {.shadow_verify_node_collectors}; nil
304
+ # collectors (caller without built-in collection) is a no-op. `path`
305
+ # is threaded because the main pass's oracle carries it.
306
+ def shadow_verify_converged_collectors(path, root, scope_index, collectors)
307
+ return if collectors.nil?
308
+
309
+ shadow_verify_node_collectors(path, root, scope_index, collectors)
219
310
  end
220
311
 
221
312
  def call_node_diagnostics(path, node, scope_index)
@@ -252,8 +343,8 @@ module Rigor
252
343
  # Class-level ivars (`@x = 1` outside any def, in the
253
344
  # class body) are also skipped — they're a separate
254
345
  # surface (`Module#@var`) the engine doesn't yet model.
255
- def ivar_write_mismatch_diagnostics(path, root, scope_index)
256
- IvarWriteCollector.new(scope_index).collect(root).flat_map do |class_name, writes_by_ivar|
346
+ def ivar_write_mismatch_diagnostics(path, ivar_writes)
347
+ ivar_writes.flat_map do |class_name, writes_by_ivar|
257
348
  writes_by_ivar.flat_map do |ivar_name, writes|
258
349
  ivar_mismatch_diagnostics_for(path, class_name, ivar_name, writes)
259
350
  end
@@ -266,8 +357,8 @@ module Rigor
266
357
  # read in the same body. The
267
358
  # `Analysis::CheckRules::DeadAssignmentCollector` describes
268
359
  # the conservative envelope.
269
- def dead_assignment_diagnostics(path, root, scope_index)
270
- DeadAssignmentCollector.new(scope_index).collect(root).map do |result|
360
+ def dead_assignment_diagnostics(path, dead_assignments)
361
+ dead_assignments.map do |result|
271
362
  build_dead_assignment_diagnostic(path, result[:write_node], result[:def_node])
272
363
  end
273
364
  end
@@ -421,7 +512,18 @@ module Rigor
421
512
  scope = scope_index[call_node]
422
513
  return nil if scope.nil?
423
514
 
424
- receiver_type = scope.type_of(call_node.receiver)
515
+ # N3 — a safe-navigation call (`recv&.m`) never dispatches on the
516
+ # nil edge of its receiver: at runtime it short-circuits to nil.
517
+ # A receiver that types as exactly `nil` yields nil with no call at
518
+ # all, so it is silent (no dead-code diagnostic — `&.` is the
519
+ # nil-skip operator by design, and frightening working
520
+ # `@x = nil; @x&.m` code would breach FP discipline). A nil-bearing
521
+ # *union* receiver is left to flow through unchanged: a `T | nil`
522
+ # union has no single concrete class, so the rule already bails
523
+ # below — preserving that keeps `&.` from newly firing on the
524
+ # non-nil constituent (which, for a cross-file project def, would
525
+ # be a working-code false positive).
526
+ receiver_type = safe_navigation_receiver(call_node, scope)
425
527
  class_name = concrete_class_name(receiver_type)
426
528
  return nil if class_name.nil?
427
529
 
@@ -742,7 +844,7 @@ module Rigor
742
844
  # by `undefined_method_diagnostic`; it returns nil
743
845
  # when the call's receiver / RBS coverage / call shape
744
846
  # disqualifies the rule.
745
- # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
847
+ # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity, Metrics/AbcSize
746
848
  def wrong_arity_diagnostic(path, call_node, scope_index)
747
849
  return nil if call_node.receiver.nil?
748
850
  return nil unless plain_positional_call?(call_node)
@@ -755,6 +857,15 @@ module Rigor
755
857
  return nil if class_name.nil?
756
858
 
757
859
  kind = receiver_type.is_a?(Type::Singleton) ? :singleton : :instance
860
+ # `Struct.new(:a, :b).new(...)` chained: the inner
861
+ # `Struct.new(...)` is an anonymous Struct *subclass* whose
862
+ # `.new` accepts any positional arity (one slot per member,
863
+ # all defaulting to nil) — including zero. The receiver types
864
+ # as `Singleton[Struct]` (so the call-site `.new` dispatches,
865
+ # per the dispatcher's `struct_new_lift`), but validating that
866
+ # `.new` against the real `Struct.new(*Symbol)` signature is a
867
+ # false positive. Skip arity-checking the chained position.
868
+ return nil if anonymous_struct_new_call?(call_node, class_name, kind)
758
869
  return nil if scope.discovered_method?(class_name, call_node.name, kind)
759
870
 
760
871
  return nil unless Rigor::Reflection.rbs_class_known?(class_name, scope: scope)
@@ -772,7 +883,25 @@ module Rigor
772
883
 
773
884
  build_arity_diagnostic(path, call_node, class_name, min, max, actual)
774
885
  end
775
- # rubocop:enable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
886
+ # rubocop:enable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity, Metrics/AbcSize
887
+
888
+ # True for the outer `.new` of a chained `Struct.new(...).new`:
889
+ # `class_name`/`kind` already pin the receiver to
890
+ # `Singleton[Struct]`, and the receiver node is itself a
891
+ # `Struct.new` (or `::Struct.new`) call — the anonymous subclass.
892
+ def anonymous_struct_new_call?(call_node, class_name, kind)
893
+ return false unless class_name == "Struct" && kind == :singleton
894
+ return false unless call_node.name == :new
895
+
896
+ receiver = call_node.receiver
897
+ return false unless receiver.is_a?(Prism::CallNode) && receiver.name == :new
898
+
899
+ inner = receiver.receiver
900
+ return true if inner.is_a?(Prism::ConstantReadNode) && inner.name == :Struct
901
+
902
+ # `::Struct.new(...).new` — a top-level constant path.
903
+ inner.is_a?(Prism::ConstantPathNode) && inner.parent.nil? && inner.name == :Struct
904
+ end
776
905
 
777
906
  def plain_positional_call?(call_node)
778
907
  arguments = call_node.arguments
@@ -864,6 +993,17 @@ module Rigor
864
993
  scope = scope_index[call_node]
865
994
  return nil if scope.nil?
866
995
 
996
+ # ADR-58 WD1 — a receiver whose `nil` constituent is purely
997
+ # declaration-sourced (the class-ivar index seed of a ctor
998
+ # `@x = nil` written in another method, possibly copied into a
999
+ # local via `r = @right`) does not fire by default: the working
1000
+ # program's cross-method invariant is assumed per the robustness
1001
+ # principle. The nil stays in the displayed type; only its use as
1002
+ # diagnostic fuel is withheld. Any flow-live touch (method-local
1003
+ # nil write, failed-guard narrowing) drops the mark upstream, so
1004
+ # flow-observed nil keeps firing exactly as before.
1005
+ return nil if scope.declaration_sourced?(:local, call_node.receiver.name)
1006
+
867
1007
  receiver_type = scope.type_of(call_node.receiver)
868
1008
  return nil unless receiver_type.is_a?(Type::Union)
869
1009
 
@@ -883,6 +1023,21 @@ module Rigor
883
1023
  union.members.any? { |member| nil_member?(member) }
884
1024
  end
885
1025
 
1026
+ # The receiver type the `call.undefined-method` existence check
1027
+ # should reason about. For a safe-navigation call whose receiver
1028
+ # types as exactly `nil`, this is `Type::Bot` — the call is
1029
+ # statically skipped at runtime, and `concrete_class_name(Bot)` is
1030
+ # nil so the rule bails (silent). Every other receiver (including a
1031
+ # `T | nil` union, which already has no single concrete class) flows
1032
+ # through unchanged.
1033
+ def safe_navigation_receiver(call_node, scope)
1034
+ receiver_type = scope.type_of(call_node.receiver)
1035
+ return receiver_type unless call_node.safe_navigation?
1036
+ return receiver_type unless nil_member?(receiver_type)
1037
+
1038
+ Type::Combinator.bot
1039
+ end
1040
+
886
1041
  def nil_member?(member)
887
1042
  (member.is_a?(Type::Constant) && member.value.nil?) ||
888
1043
  (member.is_a?(Type::Nominal) && member.class_name == "NilClass")
@@ -1048,7 +1203,8 @@ module Rigor
1048
1203
  rule: RULE_NIL_RECEIVER,
1049
1204
  path: path,
1050
1205
  message: "possible nil receiver: `#{call_node.name}' is undefined on NilClass",
1051
- severity: :error
1206
+ severity: :error,
1207
+ method_name: call_node.name.to_s
1052
1208
  )
1053
1209
  end
1054
1210
 
@@ -1236,7 +1392,9 @@ module Rigor
1236
1392
  rule: RULE_VISIBILITY_MISMATCH,
1237
1393
  path: path,
1238
1394
  message: "private method `#{call_node.name}' called on #{receiver_type.class_name} receiver",
1239
- severity: :error
1395
+ severity: :error,
1396
+ receiver_type: receiver_type.class_name,
1397
+ method_name: call_node.name.to_s
1240
1398
  )
1241
1399
  end
1242
1400
 
@@ -1435,12 +1593,49 @@ module Rigor
1435
1593
  arg_type = scope.type_of(arg)
1436
1594
  next if arg_type.is_a?(Type::Dynamic) || arg_type.is_a?(Type::Top)
1437
1595
 
1438
- result = Inference::Acceptance.accepts(param_type, arg_type, mode: :gradual)
1439
- return { node: arg, name: param.name, expected: param_type, actual: arg_type } if result.no?
1596
+ next unless argument_genuinely_mismatches?(arg, arg_type, param_type, scope)
1597
+
1598
+ return { node: arg, name: param.name, expected: param_type, actual: arg_type }
1440
1599
  end
1441
1600
  nil
1442
1601
  end
1443
1602
 
1603
+ # The parameter rejects the argument AND the rejection is not a
1604
+ # withheld declaration-sourced-nil case.
1605
+ def argument_genuinely_mismatches?(arg, arg_type, param_type, scope)
1606
+ return false unless Inference::Acceptance.accepts(param_type, arg_type, mode: :gradual).no?
1607
+
1608
+ # ADR-58 (N2 extension) — the same declaration-sourced-nil-is-not-
1609
+ # diagnostic-fuel criterion that governs `possible-nil-receiver`
1610
+ # applies here. When the only reason the argument is rejected is a
1611
+ # *declaration-sourced* nil constituent (the class-ivar index seed
1612
+ # of a ctor `@x = nil` / a non-definitely-assigned ivar read), and
1613
+ # the argument type with that nil removed WOULD be accepted, the
1614
+ # working program's cross-method invariant is assumed and we do not
1615
+ # fire. Flow-live nil (a method-local `@x = nil` write, a failed-
1616
+ # guard narrowing) drops the provenance mark upstream and still
1617
+ # fires. The argument's type is unchanged — only the firing
1618
+ # decision is gated.
1619
+ !declaration_sourced_nil_only_mismatch?(arg, arg_type, param_type, scope)
1620
+ end
1621
+
1622
+ # True when `arg` is a declaration-sourced ivar read whose rejection is
1623
+ # caused solely by its nil constituent: stripping nil from the argument
1624
+ # type yields a type the parameter accepts (gradual mode). Mirrors the
1625
+ # `possible-nil-receiver` WD1 gate, keyed on the ivar provenance mark
1626
+ # rather than a local copy.
1627
+ def declaration_sourced_nil_only_mismatch?(arg, arg_type, param_type, scope)
1628
+ return false unless arg.is_a?(Prism::InstanceVariableReadNode)
1629
+ return false unless scope.declaration_sourced?(:ivar, arg.name)
1630
+ return false unless arg_type.is_a?(Type::Union)
1631
+ return false unless union_contains_nil?(arg_type)
1632
+
1633
+ non_nil = Type::Combinator.union(*arg_type.members.reject { |m| nil_member?(m) })
1634
+ return false if non_nil.is_a?(Type::Bot)
1635
+
1636
+ Inference::Acceptance.accepts(param_type, non_nil, mode: :gradual).yes?
1637
+ end
1638
+
1444
1639
  def argument_check_eligible?(function)
1445
1640
  # See `arity_eligible?`: `UntypedFunction` lacks
1446
1641
  # the per-arity accessors. Treat it as ineligible
@@ -1471,7 +1666,9 @@ module Rigor
1471
1666
  rule: RULE_ARGUMENT_TYPE,
1472
1667
  path: path,
1473
1668
  message: message,
1474
- severity: :error
1669
+ severity: :error,
1670
+ receiver_type: class_name,
1671
+ method_name: call_node.name.to_s
1475
1672
  )
1476
1673
  end
1477
1674
 
@@ -1484,7 +1681,9 @@ module Rigor
1484
1681
  rule: RULE_WRONG_ARITY,
1485
1682
  path: path,
1486
1683
  message: message,
1487
- severity: :error
1684
+ severity: :error,
1685
+ receiver_type: class_name,
1686
+ method_name: call_node.name.to_s
1488
1687
  )
1489
1688
  end
1490
1689
 
@@ -1657,7 +1856,8 @@ module Rigor
1657
1856
  path: path,
1658
1857
  message: "return-type mismatch on `#{def_node.name}': " \
1659
1858
  "declared #{declared.describe(:short)}, inferred #{inferred.describe(:short)}",
1660
- severity: severity
1859
+ severity: severity,
1860
+ method_name: def_node.name.to_s
1661
1861
  )
1662
1862
  end
1663
1863
 
@@ -1809,7 +2009,8 @@ module Rigor
1809
2009
  message: "visibility of `#{def_node.name}' reduced from #{parent_visibility} to " \
1810
2010
  "#{override_visibility} (overrides #{parent_class}##{def_node.name}); " \
1811
2011
  "breaks substitutability",
1812
- severity: :warning
2012
+ severity: :warning,
2013
+ method_name: def_node.name.to_s
1813
2014
  )
1814
2015
  end
1815
2016
 
@@ -1902,7 +2103,8 @@ module Rigor
1902
2103
  message: "return type of `#{def_node.name}' widened from #{parent_return.describe(:short)} " \
1903
2104
  "to #{override_return.describe(:short)} (overrides #{parent_class}##{def_node.name}); " \
1904
2105
  "breaks substitutability",
1905
- severity: :warning
2106
+ severity: :warning,
2107
+ method_name: def_node.name.to_s
1906
2108
  )
1907
2109
  end
1908
2110
 
@@ -2006,7 +2208,8 @@ module Rigor
2006
2208
  message: "parameter #{index + 1} of `#{def_node.name}' narrowed from " \
2007
2209
  "#{parent_param.describe(:short)} to #{override_param.describe(:short)} " \
2008
2210
  "(overrides #{parent_class}##{def_node.name}); breaks substitutability",
2009
- severity: :warning
2211
+ severity: :warning,
2212
+ method_name: def_node.name.to_s
2010
2213
  )
2011
2214
  end
2012
2215
  end
@@ -132,6 +132,12 @@ module Rigor
132
132
  "#{source_family}.#{rule}"
133
133
  end
134
134
 
135
+ # `--format json` serialisation. The structured `receiver_type`
136
+ # / `method_name` / `project_definition_site` fields are emitted
137
+ # only when populated, so a consumer (`jq`, `rigor triage`, an AI
138
+ # agent) can group a `rigor check --format json` stream by the
139
+ # called class / method without parsing the human-readable
140
+ # `message` — the message wording is presentation, not contract.
135
141
  def to_h
136
142
  base = {
137
143
  "path" => path,
@@ -142,6 +148,8 @@ module Rigor
142
148
  "source_family" => source_family.to_s,
143
149
  "message" => message
144
150
  }
151
+ base["receiver_type"] = receiver_type if receiver_type
152
+ base["method_name"] = method_name if method_name
145
153
  base["project_definition_site"] = project_definition_site if project_definition_site
146
154
  base
147
155
  end
@@ -529,7 +529,8 @@ module Rigor
529
529
  rule: diagnostic.rule,
530
530
  authored_severity: diagnostic.severity,
531
531
  profile: @configuration.severity_profile,
532
- overrides: @configuration.severity_overrides
532
+ overrides: @configuration.severity_overrides,
533
+ bleeding_edge_overrides: @configuration.bleeding_edge_severity_overrides
533
534
  )
534
535
  return nil if resolved == :off
535
536
  return diagnostic if resolved == diagnostic.severity
@@ -38,6 +38,7 @@ module Rigor
38
38
  :pre_eval_diagnostics_from_scanner,
39
39
  :discovered_classes,
40
40
  :discovered_def_nodes,
41
+ :discovered_singleton_def_nodes,
41
42
  :discovered_def_sources,
42
43
  :discovered_superclasses,
43
44
  :discovered_includes,
@@ -64,7 +65,7 @@ module Rigor
64
65
  # results bundled in {Result} in the order the downstream `#run`
65
66
  # body expects. Extracted so `#prepare_project_scan` and the
66
67
  # prebuilt-less `#run` path share one implementation.
67
- def run(expansion:) # rubocop:disable Metrics/MethodLength
68
+ def run(expansion:) # rubocop:disable Metrics/MethodLength, Metrics/AbcSize
68
69
  plugin_registry = load_plugins
69
70
  dependency_source_index = DependencySourceInference::Builder.build(@configuration.dependencies)
70
71
  # ADR-18 slice 3 — plugin prepare MUST run before the
@@ -131,6 +132,7 @@ module Rigor
131
132
  pre_eval_diagnostics_from_scanner: pre_eval_diagnostics_from_scanner,
132
133
  discovered_classes: discovered_classes,
133
134
  discovered_def_nodes: def_index.fetch(:def_nodes),
135
+ discovered_singleton_def_nodes: def_index.fetch(:singleton_def_nodes),
134
136
  discovered_def_sources: def_index.fetch(:def_sources),
135
137
  discovered_superclasses: def_index.fetch(:superclasses),
136
138
  discovered_includes: def_index.fetch(:includes),
@@ -173,6 +175,7 @@ module Rigor
173
175
  pre_eval_diagnostics_from_scanner: scan.pre_eval_diagnostics,
174
176
  discovered_classes: nil,
175
177
  discovered_def_nodes: nil,
178
+ discovered_singleton_def_nodes: nil,
176
179
  discovered_def_sources: nil,
177
180
  discovered_superclasses: nil,
178
181
  discovered_includes: nil,