rigortype 0.1.17 → 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 (125) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +159 -222
  3. data/lib/rigor/analysis/check_rules/always_truthy_condition_collector.rb +24 -1
  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 +213 -0
  8. data/lib/rigor/analysis/check_rules/unreachable_clause_collector.rb +24 -1
  9. data/lib/rigor/analysis/check_rules.rb +275 -44
  10. data/lib/rigor/analysis/diagnostic.rb +8 -0
  11. data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +581 -0
  12. data/lib/rigor/analysis/runner/pool_coordinator.rb +569 -0
  13. data/lib/rigor/analysis/runner/project_pre_passes.rb +321 -0
  14. data/lib/rigor/analysis/runner/run_snapshots.rb +46 -0
  15. data/lib/rigor/analysis/runner.rb +207 -1200
  16. data/lib/rigor/analysis/worker_session.rb +60 -11
  17. data/lib/rigor/bleeding_edge.rb +123 -0
  18. data/lib/rigor/cache/descriptor.rb +86 -8
  19. data/lib/rigor/cache/incremental_snapshot.rb +10 -4
  20. data/lib/rigor/cache/rbs_cache_producer.rb +5 -1
  21. data/lib/rigor/cache/rbs_descriptor.rb +2 -1
  22. data/lib/rigor/cache/store.rb +46 -13
  23. data/lib/rigor/cli/annotate_command.rb +100 -15
  24. data/lib/rigor/cli/check_command.rb +708 -0
  25. data/lib/rigor/cli/ci_detector.rb +94 -0
  26. data/lib/rigor/cli/diagnostic_formats.rb +345 -0
  27. data/lib/rigor/cli/plugins_command.rb +2 -4
  28. data/lib/rigor/cli/plugins_renderer.rb +0 -2
  29. data/lib/rigor/cli/prism_colorizer.rb +10 -3
  30. data/lib/rigor/cli/show_bleedingedge_command.rb +114 -0
  31. data/lib/rigor/cli/trace_command.rb +143 -0
  32. data/lib/rigor/cli/trace_renderer.rb +310 -0
  33. data/lib/rigor/cli/triage_command.rb +6 -3
  34. data/lib/rigor/cli/triage_renderer.rb +15 -1
  35. data/lib/rigor/cli.rb +21 -612
  36. data/lib/rigor/configuration/severity_profile.rb +13 -1
  37. data/lib/rigor/configuration.rb +66 -7
  38. data/lib/rigor/environment/rbs_loader.rb +78 -68
  39. data/lib/rigor/environment.rb +1 -1
  40. data/lib/rigor/inference/acceptance.rb +10 -0
  41. data/lib/rigor/inference/body_fixpoint.rb +89 -0
  42. data/lib/rigor/inference/budget_trace.rb +29 -2
  43. data/lib/rigor/inference/expression_typer.rb +1080 -105
  44. data/lib/rigor/inference/flow_tracer.rb +180 -0
  45. data/lib/rigor/inference/macro_block_self_type.rb +11 -12
  46. data/lib/rigor/inference/method_dispatcher/array_to_h_folding.rb +60 -0
  47. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +54 -14
  48. data/lib/rigor/inference/method_dispatcher/overload_selector.rb +33 -1
  49. data/lib/rigor/inference/method_dispatcher/reduce_folding.rb +281 -0
  50. data/lib/rigor/inference/method_dispatcher/regexp_folding.rb +71 -0
  51. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +148 -10
  52. data/lib/rigor/inference/method_dispatcher.rb +187 -55
  53. data/lib/rigor/inference/method_parameter_binder.rb +56 -2
  54. data/lib/rigor/inference/multi_target_binder.rb +46 -3
  55. data/lib/rigor/inference/mutation_widening.rb +142 -0
  56. data/lib/rigor/inference/narrowing.rb +330 -37
  57. data/lib/rigor/inference/scope_indexer.rb +770 -39
  58. data/lib/rigor/inference/statement_evaluator.rb +998 -68
  59. data/lib/rigor/inference/synthetic_method_scanner.rb +1 -1
  60. data/lib/rigor/plugin/additional_initializer.rb +61 -38
  61. data/lib/rigor/plugin/base.rb +517 -120
  62. data/lib/rigor/plugin/macro/block_as_method.rb +22 -21
  63. data/lib/rigor/plugin/macro/nested_class_template.rb +9 -7
  64. data/lib/rigor/plugin/macro.rb +2 -3
  65. data/lib/rigor/plugin/manifest.rb +4 -24
  66. data/lib/rigor/plugin/node_rule_walk.rb +192 -0
  67. data/lib/rigor/plugin/registry.rb +264 -35
  68. data/lib/rigor/plugin.rb +1 -0
  69. data/lib/rigor/rbs_extended/conformance_checker.rb +86 -1
  70. data/lib/rigor/scope/discovery_index.rb +60 -0
  71. data/lib/rigor/scope.rb +199 -204
  72. data/lib/rigor/sig_gen/generator.rb +8 -0
  73. data/lib/rigor/sig_gen/observation_collector.rb +6 -6
  74. data/lib/rigor/source/literals.rb +14 -0
  75. data/lib/rigor/triage/catalogue.rb +4 -19
  76. data/lib/rigor/triage.rb +69 -1
  77. data/lib/rigor/type/combinator.rb +34 -0
  78. data/lib/rigor/version.rb +1 -1
  79. data/lib/rigor.rb +0 -1
  80. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable.rb +13 -29
  81. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer.rb +13 -32
  82. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/analyzer.rb +1 -2
  83. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +27 -90
  84. data/plugins/rigor-activejob/lib/rigor/plugin/activejob.rb +13 -30
  85. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +2 -4
  86. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +90 -51
  87. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/analyzer.rb +3 -3
  88. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage.rb +25 -29
  89. data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext.rb +1 -1
  90. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_discoverer.rb +1 -2
  91. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot.rb +11 -40
  92. data/plugins/rigor-graphql/lib/rigor/plugin/graphql/type_scanner.rb +2 -2
  93. data/plugins/rigor-mangrove/lib/rigor/plugin/mangrove.rb +1 -1
  94. data/plugins/rigor-pundit/lib/rigor/plugin/pundit.rb +10 -21
  95. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +21 -34
  96. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +11 -18
  97. data/plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline.rb +0 -1
  98. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/let_scope_index.rb +12 -2
  99. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/matcher_analyzer.rb +1 -1
  100. data/plugins/rigor-rspec/lib/rigor/plugin/rspec.rb +37 -31
  101. data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers.rb +3 -23
  102. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq.rb +8 -21
  103. data/plugins/rigor-sinatra/lib/rigor/plugin/sinatra.rb +1 -1
  104. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/absurd_recognizer.rb +8 -29
  105. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/catalog.rb +17 -1
  106. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/sigil_detector.rb +2 -2
  107. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +108 -36
  108. data/sig/rigor/analysis/fact_store.rbs +3 -0
  109. data/sig/rigor/environment.rbs +0 -2
  110. data/sig/rigor/inference/builtins/method_catalog.rbs +1 -1
  111. data/sig/rigor/inference.rbs +5 -0
  112. data/sig/rigor/plugin/base.rbs +6 -4
  113. data/sig/rigor/plugin/manifest.rbs +1 -2
  114. data/sig/rigor/scope.rbs +50 -29
  115. data/sig/rigor/source.rbs +1 -0
  116. data/sig/rigor/type.rbs +1 -0
  117. data/sig/rigor.rbs +1 -1
  118. data/skills/rigor-baseline-reduce/references/01-classify.md +27 -0
  119. data/skills/rigor-ci-setup/SKILL.md +319 -0
  120. data/skills/rigor-plugin-author/SKILL.md +6 -4
  121. data/skills/rigor-plugin-author/references/02-walker-and-types.md +22 -17
  122. data/skills/rigor-project-init/references/03-baseline-and-bugs.md +18 -1
  123. metadata +21 -3
  124. data/lib/rigor/cache/rbs_instance_definitions.rb +0 -66
  125. data/lib/rigor/plugin/macro/external_file.rb +0 -143
@@ -7,10 +7,12 @@ require_relative "../source/node_walker"
7
7
  require_relative "../type"
8
8
  require_relative "diagnostic"
9
9
  require_relative "dependency_recorder"
10
+ require_relative "check_rules/rule_walk"
10
11
  require_relative "check_rules/always_truthy_condition_collector"
11
12
  require_relative "check_rules/unreachable_clause_collector"
12
13
  require_relative "check_rules/dead_assignment_collector"
13
14
  require_relative "check_rules/ivar_write_collector"
15
+ require_relative "check_rules/main_pass_collector"
14
16
  require_relative "check_rules/self_closedness_scanner"
15
17
 
16
18
  module Rigor
@@ -162,32 +164,149 @@ module Rigor
162
164
  # @param root [Prism::Node]
163
165
  # @param scope_index [Hash{Prism::Node => Rigor::Scope}]
164
166
  # @return [Array<Rigor::Analysis::Diagnostic>]
165
- def diagnose(path:, root:, scope_index:, self_call_misses: [], comments: [], disabled_rules: [])
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
179
+ diagnostics.concat(self_undefined_method_diagnostics(path, self_call_misses, 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))
184
+ filter_suppressed(diagnostics, comments: comments, disabled_rules: disabled_rules)
185
+ end
186
+
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
273
+ end
274
+
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
281
+
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)
166
290
  diagnostics = []
167
291
  Source::NodeWalker.each(root) do |node|
168
- case node
169
- when Prism::CallNode
170
- diagnostics.concat(call_node_diagnostics(path, node, scope_index))
171
- when Prism::DefNode
172
- return_diagnostic = return_type_mismatch_diagnostic(path, node, scope_index)
173
- diagnostics << return_diagnostic if return_diagnostic
174
- override_vis = override_visibility_diagnostic(path, node, scope_index)
175
- diagnostics << override_vis if override_vis
176
- override_return = override_return_widened_diagnostic(path, node, scope_index)
177
- diagnostics << override_return if override_return
178
- override_param = override_param_narrowed_diagnostic(path, node, scope_index)
179
- diagnostics << override_param if override_param
180
- when Prism::IfNode, Prism::UnlessNode
181
- unreachable = unreachable_branch_diagnostic(path, node, scope_index)
182
- diagnostics << unreachable if unreachable
183
- end
292
+ diagnostics.concat(main_pass_node_diagnostics(path, node, scope_index))
184
293
  end
185
- diagnostics.concat(self_undefined_method_diagnostics(path, self_call_misses, root, scope_index))
186
- diagnostics.concat(always_truthy_condition_diagnostics(path, root, scope_index))
187
- diagnostics.concat(unreachable_clause_diagnostics(path, root, scope_index))
188
- diagnostics.concat(ivar_write_mismatch_diagnostics(path, root, scope_index))
189
- diagnostics.concat(dead_assignment_diagnostics(path, root, scope_index))
190
- filter_suppressed(diagnostics, comments: comments, disabled_rules: disabled_rules)
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)
191
310
  end
192
311
 
193
312
  def call_node_diagnostics(path, node, scope_index)
@@ -224,8 +343,8 @@ module Rigor
224
343
  # Class-level ivars (`@x = 1` outside any def, in the
225
344
  # class body) are also skipped — they're a separate
226
345
  # surface (`Module#@var`) the engine doesn't yet model.
227
- def ivar_write_mismatch_diagnostics(path, root, scope_index)
228
- 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|
229
348
  writes_by_ivar.flat_map do |ivar_name, writes|
230
349
  ivar_mismatch_diagnostics_for(path, class_name, ivar_name, writes)
231
350
  end
@@ -238,8 +357,8 @@ module Rigor
238
357
  # read in the same body. The
239
358
  # `Analysis::CheckRules::DeadAssignmentCollector` describes
240
359
  # the conservative envelope.
241
- def dead_assignment_diagnostics(path, root, scope_index)
242
- DeadAssignmentCollector.new(scope_index).collect(root).map do |result|
360
+ def dead_assignment_diagnostics(path, dead_assignments)
361
+ dead_assignments.map do |result|
243
362
  build_dead_assignment_diagnostic(path, result[:write_node], result[:def_node])
244
363
  end
245
364
  end
@@ -251,8 +370,8 @@ module Rigor
251
370
  # predicate skip envelope (see
252
371
  # `Analysis::CheckRules::AlwaysTruthyConditionCollector`
253
372
  # for the full triage rationale).
254
- def always_truthy_condition_diagnostics(path, root, scope_index)
255
- AlwaysTruthyConditionCollector.new(scope_index).collect(root).map do |result|
373
+ def always_truthy_condition_diagnostics(path, results)
374
+ results.map do |result|
256
375
  build_always_truthy_condition_diagnostic(path, result.node, result.polarity)
257
376
  end
258
377
  end
@@ -261,8 +380,8 @@ module Rigor
261
380
  # the flow engine's narrowing proves can never match (its narrowed
262
381
  # subject is `bot`). The squiggle lands on the dead clause's body,
263
382
  # mirroring `flow.unreachable-branch`.
264
- def unreachable_clause_diagnostics(path, root, scope_index)
265
- UnreachableClauseCollector.new(scope_index).collect(root).map do |result|
383
+ def unreachable_clause_diagnostics(path, results)
384
+ results.map do |result|
266
385
  build_unreachable_clause_diagnostic(path, result)
267
386
  end
268
387
  end
@@ -393,7 +512,18 @@ module Rigor
393
512
  scope = scope_index[call_node]
394
513
  return nil if scope.nil?
395
514
 
396
- 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)
397
527
  class_name = concrete_class_name(receiver_type)
398
528
  return nil if class_name.nil?
399
529
 
@@ -714,7 +844,7 @@ module Rigor
714
844
  # by `undefined_method_diagnostic`; it returns nil
715
845
  # when the call's receiver / RBS coverage / call shape
716
846
  # disqualifies the rule.
717
- # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
847
+ # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity, Metrics/AbcSize
718
848
  def wrong_arity_diagnostic(path, call_node, scope_index)
719
849
  return nil if call_node.receiver.nil?
720
850
  return nil unless plain_positional_call?(call_node)
@@ -727,6 +857,15 @@ module Rigor
727
857
  return nil if class_name.nil?
728
858
 
729
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)
730
869
  return nil if scope.discovered_method?(class_name, call_node.name, kind)
731
870
 
732
871
  return nil unless Rigor::Reflection.rbs_class_known?(class_name, scope: scope)
@@ -744,7 +883,25 @@ module Rigor
744
883
 
745
884
  build_arity_diagnostic(path, call_node, class_name, min, max, actual)
746
885
  end
747
- # 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
748
905
 
749
906
  def plain_positional_call?(call_node)
750
907
  arguments = call_node.arguments
@@ -836,6 +993,17 @@ module Rigor
836
993
  scope = scope_index[call_node]
837
994
  return nil if scope.nil?
838
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
+
839
1007
  receiver_type = scope.type_of(call_node.receiver)
840
1008
  return nil unless receiver_type.is_a?(Type::Union)
841
1009
 
@@ -855,6 +1023,21 @@ module Rigor
855
1023
  union.members.any? { |member| nil_member?(member) }
856
1024
  end
857
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
+
858
1041
  def nil_member?(member)
859
1042
  (member.is_a?(Type::Constant) && member.value.nil?) ||
860
1043
  (member.is_a?(Type::Nominal) && member.class_name == "NilClass")
@@ -1020,7 +1203,8 @@ module Rigor
1020
1203
  rule: RULE_NIL_RECEIVER,
1021
1204
  path: path,
1022
1205
  message: "possible nil receiver: `#{call_node.name}' is undefined on NilClass",
1023
- severity: :error
1206
+ severity: :error,
1207
+ method_name: call_node.name.to_s
1024
1208
  )
1025
1209
  end
1026
1210
 
@@ -1208,7 +1392,9 @@ module Rigor
1208
1392
  rule: RULE_VISIBILITY_MISMATCH,
1209
1393
  path: path,
1210
1394
  message: "private method `#{call_node.name}' called on #{receiver_type.class_name} receiver",
1211
- severity: :error
1395
+ severity: :error,
1396
+ receiver_type: receiver_type.class_name,
1397
+ method_name: call_node.name.to_s
1212
1398
  )
1213
1399
  end
1214
1400
 
@@ -1407,12 +1593,49 @@ module Rigor
1407
1593
  arg_type = scope.type_of(arg)
1408
1594
  next if arg_type.is_a?(Type::Dynamic) || arg_type.is_a?(Type::Top)
1409
1595
 
1410
- result = Inference::Acceptance.accepts(param_type, arg_type, mode: :gradual)
1411
- 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 }
1412
1599
  end
1413
1600
  nil
1414
1601
  end
1415
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
+
1416
1639
  def argument_check_eligible?(function)
1417
1640
  # See `arity_eligible?`: `UntypedFunction` lacks
1418
1641
  # the per-arity accessors. Treat it as ineligible
@@ -1443,7 +1666,9 @@ module Rigor
1443
1666
  rule: RULE_ARGUMENT_TYPE,
1444
1667
  path: path,
1445
1668
  message: message,
1446
- severity: :error
1669
+ severity: :error,
1670
+ receiver_type: class_name,
1671
+ method_name: call_node.name.to_s
1447
1672
  )
1448
1673
  end
1449
1674
 
@@ -1456,7 +1681,9 @@ module Rigor
1456
1681
  rule: RULE_WRONG_ARITY,
1457
1682
  path: path,
1458
1683
  message: message,
1459
- severity: :error
1684
+ severity: :error,
1685
+ receiver_type: class_name,
1686
+ method_name: call_node.name.to_s
1460
1687
  )
1461
1688
  end
1462
1689
 
@@ -1629,7 +1856,8 @@ module Rigor
1629
1856
  path: path,
1630
1857
  message: "return-type mismatch on `#{def_node.name}': " \
1631
1858
  "declared #{declared.describe(:short)}, inferred #{inferred.describe(:short)}",
1632
- severity: severity
1859
+ severity: severity,
1860
+ method_name: def_node.name.to_s
1633
1861
  )
1634
1862
  end
1635
1863
 
@@ -1781,7 +2009,8 @@ module Rigor
1781
2009
  message: "visibility of `#{def_node.name}' reduced from #{parent_visibility} to " \
1782
2010
  "#{override_visibility} (overrides #{parent_class}##{def_node.name}); " \
1783
2011
  "breaks substitutability",
1784
- severity: :warning
2012
+ severity: :warning,
2013
+ method_name: def_node.name.to_s
1785
2014
  )
1786
2015
  end
1787
2016
 
@@ -1874,7 +2103,8 @@ module Rigor
1874
2103
  message: "return type of `#{def_node.name}' widened from #{parent_return.describe(:short)} " \
1875
2104
  "to #{override_return.describe(:short)} (overrides #{parent_class}##{def_node.name}); " \
1876
2105
  "breaks substitutability",
1877
- severity: :warning
2106
+ severity: :warning,
2107
+ method_name: def_node.name.to_s
1878
2108
  )
1879
2109
  end
1880
2110
 
@@ -1978,7 +2208,8 @@ module Rigor
1978
2208
  message: "parameter #{index + 1} of `#{def_node.name}' narrowed from " \
1979
2209
  "#{parent_param.describe(:short)} to #{override_param.describe(:short)} " \
1980
2210
  "(overrides #{parent_class}##{def_node.name}); breaks substitutability",
1981
- severity: :warning
2211
+ severity: :warning,
2212
+ method_name: def_node.name.to_s
1982
2213
  )
1983
2214
  end
1984
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