rigortype 0.1.15 → 0.1.16

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 (106) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +4 -2
  3. data/exe/rigor +19 -0
  4. data/lib/rigor/analysis/check_rules.rb +25 -1
  5. data/lib/rigor/analysis/diagnostic.rb +40 -0
  6. data/lib/rigor/analysis/runner.rb +61 -2
  7. data/lib/rigor/analysis/worker_session.rb +3 -2
  8. data/lib/rigor/cache/descriptor.rb +6 -2
  9. data/lib/rigor/cli/plugins_command.rb +51 -4
  10. data/lib/rigor/cli/plugins_renderer.rb +86 -1
  11. data/lib/rigor/cli.rb +135 -5
  12. data/lib/rigor/environment/rbs_loader.rb +259 -1
  13. data/lib/rigor/environment.rb +8 -2
  14. data/lib/rigor/inference/budget_trace.rb +137 -0
  15. data/lib/rigor/inference/expression_typer.rb +9 -2
  16. data/lib/rigor/inference/hkt_reducer.rb +2 -0
  17. data/lib/rigor/inference/method_dispatcher/overload_selector.rb +23 -6
  18. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +81 -14
  19. data/lib/rigor/inference/method_dispatcher.rb +57 -10
  20. data/lib/rigor/inference/precision_scanner.rb +60 -1
  21. data/lib/rigor/inference/scope_indexer.rb +127 -8
  22. data/lib/rigor/inference/statement_evaluator.rb +13 -8
  23. data/lib/rigor/inference/synthetic_method_index.rb +23 -4
  24. data/lib/rigor/inference/synthetic_method_scanner.rb +148 -14
  25. data/lib/rigor/plugin/additional_initializer.rb +108 -0
  26. data/lib/rigor/plugin/base.rb +321 -2
  27. data/lib/rigor/plugin/box.rb +64 -0
  28. data/lib/rigor/plugin/inflector.rb +121 -0
  29. data/lib/rigor/plugin/isolation.rb +191 -0
  30. data/lib/rigor/plugin/macro/nested_class_template.rb +140 -0
  31. data/lib/rigor/plugin/macro.rb +1 -0
  32. data/lib/rigor/plugin/manifest.rb +120 -23
  33. data/lib/rigor/plugin/node_context.rb +62 -0
  34. data/lib/rigor/plugin/registry.rb +10 -0
  35. data/lib/rigor/plugin.rb +3 -0
  36. data/lib/rigor/sig_gen/generator.rb +2 -3
  37. data/lib/rigor/sig_gen/observation_collector.rb +2 -2
  38. data/lib/rigor/source/literals.rb +118 -0
  39. data/lib/rigor/source/node_walker.rb +26 -0
  40. data/lib/rigor/source.rb +1 -0
  41. data/lib/rigor/type/combinator.rb +6 -1
  42. data/lib/rigor/type/union.rb +65 -1
  43. data/lib/rigor/version.rb +1 -1
  44. data/lib/rigor.rb +1 -0
  45. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/analyzer.rb +31 -53
  46. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable.rb +21 -23
  47. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/analyzer.rb +38 -59
  48. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_discoverer.rb +7 -13
  49. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer.rb +22 -33
  50. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/analyzer.rb +298 -413
  51. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +69 -71
  52. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/analyzer.rb +24 -34
  53. data/plugins/rigor-activejob/lib/rigor/plugin/activejob.rb +18 -16
  54. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/analyzer.rb +4 -46
  55. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +4 -4
  56. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_index.rb +1 -1
  57. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +17 -12
  58. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/analyzer.rb +2 -8
  59. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/attachment_discoverer.rb +2 -7
  60. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage.rb +2 -6
  61. data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema/schema_scanner.rb +4 -3
  62. data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation.rb +5 -1
  63. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/analyzer.rb +40 -45
  64. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_discoverer.rb +7 -17
  65. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot.rb +20 -42
  66. data/plugins/rigor-graphql/lib/rigor/plugin/graphql/type_scanner.rb +7 -4
  67. data/plugins/rigor-hanami/lib/rigor/plugin/hanami/action_checker.rb +4 -8
  68. data/plugins/rigor-mangrove/lib/rigor/plugin/mangrove.rb +188 -0
  69. data/plugins/rigor-mangrove/lib/rigor-mangrove.rb +3 -0
  70. data/plugins/rigor-minitest/lib/rigor/plugin/minitest/assertion_analyzer.rb +4 -0
  71. data/plugins/rigor-minitest/lib/rigor/plugin/minitest.rb +24 -8
  72. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/analyzer.rb +31 -48
  73. data/plugins/rigor-pundit/lib/rigor/plugin/pundit.rb +21 -23
  74. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/analyzer.rb +54 -82
  75. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +25 -25
  76. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/analyzer.rb +63 -147
  77. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/devise_routes.rb +4 -17
  78. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/routes_parser.rb +23 -114
  79. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +36 -31
  80. data/plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline.rb +0 -1
  81. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/let_scope_index.rb +6 -3
  82. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/scope_walker.rb +4 -2
  83. data/plugins/rigor-rspec/lib/rigor/plugin/rspec.rb +13 -12
  84. data/plugins/rigor-rspec-rails/lib/rigor/plugin/rspec_rails/have_http_status_analyzer.rb +28 -40
  85. data/plugins/rigor-rspec-rails/lib/rigor/plugin/rspec_rails/http_status_codes.rb +44 -47
  86. data/plugins/rigor-rspec-rails/lib/rigor/plugin/rspec_rails.rb +11 -10
  87. data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers/analyzer.rb +45 -87
  88. data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers.rb +11 -12
  89. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/analyzer.rb +29 -42
  90. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq.rb +20 -19
  91. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/catalog_walker.rb +73 -0
  92. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/type_translator.rb +43 -1
  93. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +21 -29
  94. data/plugins/rigor-statesman/lib/rigor/plugin/statesman.rb +36 -96
  95. data/sig/rigor/plugin/access_denied_error.rbs +3 -1
  96. data/sig/rigor/plugin/base.rbs +58 -3
  97. data/sig/rigor/plugin/io_boundary.rbs +3 -0
  98. data/sig/rigor/plugin/manifest.rbs +31 -1
  99. data/sig/rigor/source.rbs +12 -0
  100. data/sig/rigor.rbs +5 -0
  101. data/skills/rigor-plugin-author/SKILL.md +13 -9
  102. data/skills/rigor-plugin-author/references/01-plan-and-scaffold.md +6 -5
  103. data/skills/rigor-plugin-author/references/02-walker-and-types.md +159 -75
  104. data/skills/rigor-plugin-author/references/03-test-and-ship.md +3 -3
  105. metadata +52 -2
  106. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/inflector.rb +0 -114
@@ -115,14 +115,31 @@ module Rigor
115
115
  # overload-list position.
116
116
  overloads = ReceiverAffinity.reorder(overloads, self_type: self_type, environment: environment)
117
117
 
118
- match = run_selection_passes(
119
- overloads, arg_types: arg_types, self_type: self_type, instance_type: instance_type,
120
- type_vars: type_vars, block_required: block_required, param_overrides: param_overrides
121
- )
118
+ passes = lambda do |require_block|
119
+ run_selection_passes(
120
+ overloads, arg_types: arg_types, self_type: self_type, instance_type: instance_type,
121
+ type_vars: type_vars, block_required: require_block, param_overrides: param_overrides
122
+ )
123
+ end
124
+
125
+ match = passes.call(block_required)
122
126
  return match if match
123
- return overloads.find { |mt| overload_has_block?(mt) } if block_required
124
127
 
125
- # No block at the call site: prefer an overload that does
128
+ # A block at the call site that no block-declaring overload
129
+ # matched: Ruby ignores a block handed to a method that never
130
+ # yields it, so retry treating the block as ignorable rather
131
+ # than failing the dispatch. Without this, a block-bearing
132
+ # call to a method whose RBS declares no block (e.g.
133
+ # `define_command(:x) do … end` against
134
+ # `def define_command: (Symbol) -> Symbol`) degraded to
135
+ # `Dynamic[Top]` — and on a self-send suppressed the whole
136
+ # method's return type.
137
+ if block_required
138
+ match = passes.call(false)
139
+ return match if match
140
+ end
141
+
142
+ # No (usable) block at the call site: prefer an overload that does
126
143
  # not REQUIRE a block over `overloads.first`. Methods like
127
144
  # `Array#filter` / `Enumerable#map` declare the block-
128
145
  # bearing overload first (`() { ... } -> Array[Elem]`) and
@@ -64,6 +64,19 @@ module Rigor
64
64
  module RbsDispatch
65
65
  module_function
66
66
 
67
+ # ADR-43 — ancestor classes whose RBS is authoritative and
68
+ # COMPLETE, so a call a subclass makes that the ancestor's RBS
69
+ # does not declare is a genuine mistake rather than a gap.
70
+ # Membership unlocks inherited-method resolution (and thus
71
+ # `call.undefined-method`) for Ruby-source subclasses of these
72
+ # classes; every other RBS ancestor stays on the Dynamic
73
+ # fallback. Seeded with the plugin contract base — this repo
74
+ # owns both the class and `sig/rigor/plugin/base.rbs`, and the
75
+ # `lib` self-check keeps them in lock-step. NOT a place for
76
+ # third-party/core classes whose objects answer to methods
77
+ # their RBS omits (`ActionController::Base`, `Hash`, …).
78
+ ALLOWED_RBS_COMPLETE_ANCESTORS = ["Rigor::Plugin::Base"].freeze
79
+
67
80
  # @param receiver [Rigor::Type]
68
81
  # @param method_name [Symbol]
69
82
  # @param args [Array<Rigor::Type>]
@@ -94,8 +107,19 @@ module Rigor
94
107
  # @return [Rigor::Type, nil] inferred return type, or `nil`
95
108
  # when no rule resolves (no class name, no method, dispatch
96
109
  # on a Top/Dynamic[Top] receiver, etc.).
97
- def try_dispatch(receiver:, method_name:, args:, environment:, block_type: nil, self_type_override: nil,
98
- public_only: false)
110
+ # @param scope [Rigor::Scope, nil] when supplied, enables
111
+ # ADR-43 RBS-complete-ancestor resolution: a call on a
112
+ # Ruby-source subclass not known to RBS, whose discovered
113
+ # superclass chain reaches an allow-listed RBS-complete
114
+ # ancestor (e.g. `Rigor::Plugin::Base`), resolves against
115
+ # that ancestor's RBS. `nil` (the default for every caller
116
+ # that does not thread a scope) keeps the legacy behaviour —
117
+ # such an inherited call stays unresolved and degrades to
118
+ # `Dynamic[Top]`, which is the false-positive-safe default
119
+ # for the open hierarchies (`< ActionController::Base`, …)
120
+ # the allow-list deliberately excludes.
121
+ def try_dispatch(receiver:, method_name:, args:, environment:, block_type: nil, self_type_override: nil, # rubocop:disable Metrics/ParameterLists
122
+ public_only: false, scope: nil)
99
123
  return nil if environment.nil?
100
124
  return nil unless environment.rbs_loader
101
125
 
@@ -106,7 +130,8 @@ module Rigor
106
130
  environment: environment,
107
131
  block_type: block_type,
108
132
  self_type_override: self_type_override,
109
- public_only: public_only
133
+ public_only: public_only,
134
+ scope: scope
110
135
  )
111
136
  end
112
137
 
@@ -148,37 +173,37 @@ module Rigor
148
173
  class << self
149
174
  private
150
175
 
151
- def dispatch_for(receiver:, method_name:, args:, environment:, block_type:, self_type_override: nil,
152
- public_only: false)
176
+ def dispatch_for(receiver:, method_name:, args:, environment:, block_type:, self_type_override: nil, # rubocop:disable Metrics/ParameterLists
177
+ public_only: false, scope: nil)
153
178
  args ||= []
154
179
  case receiver
155
180
  when Type::Union
156
181
  dispatch_union(receiver, method_name, args, environment, block_type, self_type_override,
157
- public_only: public_only)
182
+ public_only: public_only, scope: scope)
158
183
  else
159
184
  dispatch_one(receiver, method_name, args, environment, block_type, self_type_override,
160
- public_only: public_only)
185
+ public_only: public_only, scope: scope)
161
186
  end
162
187
  end
163
188
 
164
- def dispatch_union(receiver, method_name, args, environment, block_type, self_type_override = nil,
165
- public_only: false)
189
+ def dispatch_union(receiver, method_name, args, environment, block_type, self_type_override = nil, # rubocop:disable Metrics/ParameterLists
190
+ public_only: false, scope: nil)
166
191
  results = receiver.members.map do |member|
167
192
  dispatch_one(member, method_name, args, environment, block_type, self_type_override,
168
- public_only: public_only)
193
+ public_only: public_only, scope: scope)
169
194
  end
170
195
  return nil if results.any?(&:nil?)
171
196
 
172
197
  Type::Combinator.union(*results)
173
198
  end
174
199
 
175
- def dispatch_one(receiver, method_name, args, environment, block_type, self_type_override = nil,
176
- public_only: false)
200
+ def dispatch_one(receiver, method_name, args, environment, block_type, self_type_override = nil, # rubocop:disable Metrics/ParameterLists
201
+ public_only: false, scope: nil)
177
202
  descriptor = receiver_descriptor(receiver)
178
203
  return nil unless descriptor
179
204
 
180
205
  class_name, kind, receiver_args = descriptor
181
- method_definition = lookup_method(environment, class_name, kind, method_name)
206
+ method_definition = lookup_method(environment, class_name, kind, method_name, scope)
182
207
  return nil unless method_definition
183
208
  return nil if public_only && method_private?(method_definition)
184
209
 
@@ -267,7 +292,26 @@ module Rigor
267
292
  method_definition.accessibility == :private
268
293
  end
269
294
 
270
- def lookup_method(environment, class_name, kind, method_name)
295
+ def lookup_method(environment, class_name, kind, method_name, scope = nil)
296
+ direct = lookup_method_on(environment, class_name, kind, method_name)
297
+ return direct if direct
298
+
299
+ # ADR-43 — scoped inherited-method resolution. The direct
300
+ # lookup misses when `class_name` is a Ruby-source subclass
301
+ # absent from RBS (so no ancestor walk runs). If its
302
+ # discovered superclass chain reaches an allow-listed
303
+ # RBS-complete ancestor, resolve the method there so
304
+ # inherited contract calls (`self.manifest` on a plugin)
305
+ # resolve and the normal call rules apply. Bounded to the
306
+ # allow-list, so open hierarchies stay on the Dynamic
307
+ # fallback (no false positive on `< ActionController::Base`).
308
+ ancestor = allowed_rbs_complete_ancestor(environment, class_name, scope)
309
+ return nil unless ancestor
310
+
311
+ lookup_method_on(environment, ancestor, kind, method_name)
312
+ end
313
+
314
+ def lookup_method_on(environment, class_name, kind, method_name)
271
315
  case kind
272
316
  when :instance
273
317
  Rigor::Reflection.instance_method_definition(class_name, method_name, environment: environment)
@@ -276,6 +320,29 @@ module Rigor
276
320
  end
277
321
  end
278
322
 
323
+ # The first allow-listed, RBS-complete ancestor reachable from
324
+ # `class_name` through `scope.discovered_superclasses`, or nil.
325
+ # Returns nil when no scope is threaded, when `class_name` is
326
+ # itself RBS-known (the direct lookup already had authority),
327
+ # or when the discovered chain reaches no allow-listed class.
328
+ # The walk carries a visited set so a malformed cyclic
329
+ # `A < B < A` source cannot loop.
330
+ def allowed_rbs_complete_ancestor(environment, class_name, scope)
331
+ return nil if scope.nil?
332
+ return nil if Rigor::Reflection.rbs_class_known?(class_name, environment: environment)
333
+
334
+ supers = scope.discovered_superclasses
335
+ seen = {}
336
+ current = supers[class_name.to_s]
337
+ until current.nil? || seen[current]
338
+ return current if ALLOWED_RBS_COMPLETE_ANCESTORS.include?(current)
339
+
340
+ seen[current] = true
341
+ current = supers[current]
342
+ end
343
+ nil
344
+ end
345
+
279
346
  # Slice 4 phase 2d substitution map. Zips the class's
280
347
  # declared type-parameter names against the receiver's
281
348
  # `type_args`. Returns an empty hash when either side is
@@ -71,7 +71,7 @@ module Rigor
71
71
  # @param environment [Rigor::Environment, nil] required for
72
72
  # RBS-backed dispatch; when nil only constant folding can fire.
73
73
  # @return [Rigor::Type, nil] inferred result type, or `nil` for "no rule".
74
- def dispatch(receiver_type:, method_name:, arg_types:, # rubocop:disable Metrics/MethodLength
74
+ def dispatch(receiver_type:, method_name:, arg_types:, # rubocop:disable Metrics/MethodLength, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
75
75
  block_type: nil, environment: nil,
76
76
  call_node: nil, scope: nil)
77
77
  return nil if receiver_type.nil?
@@ -94,7 +94,7 @@ module Rigor
94
94
  # consults the registry when both `call_node` and `scope`
95
95
  # are supplied — the dispatcher's own internal callers
96
96
  # (per-element block fold, etc.) skip this tier.
97
- plugin_result = try_plugin_contribution(call_node, scope)
97
+ plugin_result = try_plugin_contribution(call_node, scope, receiver_type)
98
98
  return plugin_result if plugin_result
99
99
 
100
100
  # ADR-20 slice 3 — Rigor-bundled HKT-builtin return-
@@ -125,7 +125,7 @@ module Rigor
125
125
 
126
126
  rbs_result = RbsDispatch.try_dispatch(
127
127
  receiver: receiver_type, method_name: method_name, args: arg_types,
128
- environment: environment, block_type: block_type
128
+ environment: environment, block_type: block_type, scope: scope
129
129
  )
130
130
  if rbs_result
131
131
  record_boundary_cross_if_applicable(receiver_type, method_name, rbs_result, environment)
@@ -187,6 +187,19 @@ module Rigor
187
187
  discovered_result = try_discovered_method(receiver_type, method_name, scope)
188
188
  return discovered_result if discovered_result
189
189
 
190
+ # ADR-5 robustness — synthesized-stub-type tier. When the
191
+ # receiver is a type Rigor invented to make an otherwise-
192
+ # unbuildable project signature resolve (a missing-namespace
193
+ # module, or a stub for a referenced-but-undeclared type like
194
+ # an unavailable `DRb::DRbServer`), the stub carries no methods,
195
+ # so an unresolved call against it would otherwise mis-fire
196
+ # `call.undefined-method`. Resolve it to `Dynamic[Top]` instead
197
+ # — the same no-false-positive contract as the dependency-
198
+ # source tier. Sits below every real resolution tier so a
199
+ # genuine signature always wins.
200
+ stub_result = try_synthesized_stub_type(receiver_type, environment)
201
+ return stub_result if stub_result
202
+
190
203
  # Slice 7 phase 10 — user-class ancestor fallback. When
191
204
  # the receiver is `Nominal[T]` or `Singleton[T]` for a
192
205
  # class not in the RBS environment (typically a
@@ -253,6 +266,31 @@ module Rigor
253
266
  end
254
267
  end
255
268
 
269
+ # ADR-5 robustness — returns `Dynamic[Top]` when the receiver is
270
+ # an instance or singleton of a type Rigor synthesized (a
271
+ # missing-namespace module or a referenced-type stub). The stub
272
+ # has no methods, so the call would otherwise reach the
273
+ # user-class fallback and surface `call.undefined-method`; the
274
+ # honest answer for a type Rigor invented is "unknown shape",
275
+ # i.e. `Dynamic[Top]`. Returns nil (declines) for any real type.
276
+ def try_synthesized_stub_type(receiver_type, environment)
277
+ return nil if environment.nil?
278
+
279
+ loader = environment.rbs_loader
280
+ return nil if loader.nil? || !loader.respond_to?(:synthesized_type_names)
281
+
282
+ names = loader.synthesized_type_names
283
+ return nil if names.empty?
284
+
285
+ class_name =
286
+ case receiver_type
287
+ when Type::Nominal, Type::Singleton then receiver_type.class_name.to_s.sub(/\A::/, "")
288
+ end
289
+ return nil unless class_name && names.include?(class_name)
290
+
291
+ Type::Combinator.untyped
292
+ end
293
+
256
294
  # ADR-2 § "Flow Contribution Bundle" / v0.1.1 Track 2
257
295
  # slice 7. Walks every loaded plugin's
258
296
  # `#flow_contribution_for(call_node:, scope:)` hook,
@@ -340,13 +378,13 @@ module Rigor
340
378
  end
341
379
  end
342
380
 
343
- def try_plugin_contribution(call_node, scope)
381
+ def try_plugin_contribution(call_node, scope, receiver_type)
344
382
  return nil if call_node.nil? || scope.nil?
345
383
 
346
384
  registry = scope.environment&.plugin_registry
347
385
  return nil if registry.nil? || registry.empty?
348
386
 
349
- contributions = collect_plugin_contributions(registry, call_node, scope)
387
+ contributions = collect_plugin_contributions(registry, call_node, scope, receiver_type)
350
388
  return nil if contributions.empty?
351
389
 
352
390
  FlowContribution::Merger.merge(contributions).return_type
@@ -622,12 +660,21 @@ module Rigor
622
660
  end
623
661
  end
624
662
 
625
- def collect_plugin_contributions(registry, call_node, scope)
626
- registry.plugins.filter_map do |plugin|
627
- contribution = plugin.flow_contribution_for(call_node: call_node, scope: scope)
628
- contribution.is_a?(FlowContribution) ? contribution : nil
663
+ # ADR-37 slice 2 — gathers each plugin's return-type contribution
664
+ # from BOTH the narrow `dynamic_return` DSL (receiver-gated, wrapped
665
+ # as a return-only `FlowContribution`) and the legacy
666
+ # `flow_contribution_for` escape valve, so migrated and unmigrated
667
+ # plugins compose through the same merger.
668
+ def collect_plugin_contributions(registry, call_node, scope, receiver_type)
669
+ registry.plugins.flat_map do |plugin|
670
+ contributions = []
671
+ legacy = plugin.flow_contribution_for(call_node: call_node, scope: scope)
672
+ contributions << legacy if legacy.is_a?(FlowContribution)
673
+ dynamic = plugin.dynamic_return_type(call_node: call_node, scope: scope, receiver_type: receiver_type)
674
+ contributions << FlowContribution.new(return_type: dynamic) if dynamic
675
+ contributions
629
676
  rescue StandardError
630
- nil
677
+ []
631
678
  end
632
679
  end
633
680
 
@@ -10,7 +10,11 @@ module Rigor
10
10
  # engine recognises an AST node class (that is `CoverageScanner`'s job),
11
11
  # but whether the type it produces carries useful static information.
12
12
  #
13
- # Each visited node is classified into one of eight precision tiers:
13
+ # Each visited *expression* node is classified into one of eight
14
+ # precision tiers (non-expression syntax nodes — argument /
15
+ # parameter lists, parameter declarations, hash pairs, statement
16
+ # wrappers, clause headers — are skipped; see
17
+ # {NON_EXPRESSION_NODE_TYPES}):
14
18
  #
15
19
  # :constant — Constant[T]: literal value known exactly
16
20
  # :nominal — Nominal/Singleton: class identity known
@@ -33,6 +37,59 @@ module Rigor
33
37
  dynamic_specific dynamic_top top
34
38
  ].freeze
35
39
 
40
+ # Prism node classes that do not denote a value-producing
41
+ # expression, so typing them is meaningless — they have no
42
+ # runtime value to carry a type. Counting them (they always fall
43
+ # to the `dynamic_top` fallback) silently diluted the precision
44
+ # ratio: on a real survey target (shugo/textbringer) they were
45
+ # ~49% of every "opaque" node, dragging the headline number ~13
46
+ # points below the true expression-level precision. We exclude
47
+ # them from BOTH numerator and denominator so the ratio measures
48
+ # what it claims to — the type quality of actual expressions.
49
+ #
50
+ # The set is deliberately CONSERVATIVE: only nodes that are
51
+ # unambiguously non-expressions in Ruby's grammar are listed —
52
+ # argument / parameter list containers and the parameter
53
+ # declarations inside them; the `key => value` pair node (its key
54
+ # and value are themselves walked and counted); the program /
55
+ # statements sequence wrappers (their value is the last child,
56
+ # already counted — listing them avoids double-counting); and the
57
+ # clause-header nodes whose body, not the header, carries the
58
+ # value. Anything that *could* be a value expression (`BlockNode`,
59
+ # `BeginNode`, `ImplicitNode`, `ParenthesesNode`, splats, …) is
60
+ # left in so a genuine inference gap stays visible.
61
+ #
62
+ # Compared by class NAME so a Prism version that lacks one of the
63
+ # newer node classes does not break loading.
64
+ NON_EXPRESSION_NODE_TYPES = %w[
65
+ Prism::ProgramNode
66
+ Prism::StatementsNode
67
+ Prism::ArgumentsNode
68
+ Prism::BlockArgumentNode
69
+ Prism::ParametersNode
70
+ Prism::BlockParametersNode
71
+ Prism::NumberedParametersNode
72
+ Prism::ItParametersNode
73
+ Prism::KeywordHashNode
74
+ Prism::RequiredParameterNode
75
+ Prism::OptionalParameterNode
76
+ Prism::RestParameterNode
77
+ Prism::KeywordRestParameterNode
78
+ Prism::BlockParameterNode
79
+ Prism::RequiredKeywordParameterNode
80
+ Prism::OptionalKeywordParameterNode
81
+ Prism::ForwardingParameterNode
82
+ Prism::NoKeywordsParameterNode
83
+ Prism::ImplicitRestNode
84
+ Prism::AssocNode
85
+ Prism::AssocSplatNode
86
+ Prism::WhenNode
87
+ Prism::InNode
88
+ Prism::ElseNode
89
+ Prism::EnsureNode
90
+ Prism::RescueNode
91
+ ].to_set.freeze
92
+
36
93
  TIER_RANK = TIERS.each_with_index.to_h.freeze
37
94
  private_constant :TIER_RANK
38
95
 
@@ -87,6 +144,8 @@ module Rigor
87
144
  total = 0
88
145
 
89
146
  Source::NodeWalker.each(root) do |node|
147
+ next if NON_EXPRESSION_NODE_TYPES.include?(node.class.name)
148
+
90
149
  type = scope_index[node].type_of(node)
91
150
  tier = classify(type)
92
151
  tier_counts[tier] += 1
@@ -107,7 +107,14 @@ module Rigor
107
107
  # introduced method names. `rigor check` consults the
108
108
  # table to suppress false positives for methods the
109
109
  # user has defined but no RBS sig describes.
110
- discovered_methods = build_discovered_methods(root)
110
+ # Merged UNDER any cross-file pre-pass seed (like the def-node
111
+ # / include tables below) so a method `def`/`attr_reader`-
112
+ # declared in one file suppresses a false `undefined-method`
113
+ # for a call in another — `rigor check` seeds the project-wide
114
+ # table via `Runner#seed_project_scope`.
115
+ discovered_methods = deep_merge_class_methods(
116
+ default_scope.discovered_methods, build_discovered_methods(root)
117
+ )
111
118
  seeded_scope = seeded_scope.with_discovered_methods(discovered_methods)
112
119
 
113
120
  # v0.0.2 #5 + ADR-24 slice 2 — record per-instance-method
@@ -383,7 +390,7 @@ module Rigor
383
390
  # class body has been walked, using `init_writes` as
384
391
  # the soundness gate (an ivar written in `initialize`
385
392
  # is initialised before any other method body runs).
386
- collect_read_before_write_evidence(def_node, class_name, read_before_write, init_writes)
393
+ collect_read_before_write_evidence(def_node, class_name, read_before_write, init_writes, default_scope)
387
394
  end
388
395
 
389
396
  # Walks the method body in AST (== execution) order
@@ -394,14 +401,21 @@ module Rigor
394
401
  # `init_writes` instead — used by the finalisation step
395
402
  # to suppress nil contribution for ivars the constructor
396
403
  # guarantees are initialised.
397
- def collect_read_before_write_evidence(def_node, class_name, read_before_write, init_writes)
404
+ def collect_read_before_write_evidence(def_node, class_name, read_before_write, init_writes, default_scope = nil)
398
405
  return if read_before_write.nil? || init_writes.nil?
399
406
 
400
407
  seen_writes = Set.new
401
408
  read_first = Set.new
402
409
  detect_read_before_write(def_node.body, seen_writes, read_first)
403
410
 
404
- if def_node.name == :initialize
411
+ # ADR-38 `initialize` is the built-in initializer gate;
412
+ # a plugin may declare additional `def`-form initializer
413
+ # methods (minitest `setup`, Rails `after_initialize`, DI
414
+ # setters) on a constrained class. Both fold their writes
415
+ # into `init_writes`, suppressing the read-before-write nil
416
+ # contribution for sibling readers.
417
+ if def_node.name == :initialize ||
418
+ additional_initializer?(class_name, def_node.name, default_scope)
405
419
  init_set = (init_writes[class_name] ||= Set.new)
406
420
  seen_writes.each { |name| init_set << name }
407
421
  return
@@ -413,6 +427,43 @@ module Rigor
413
427
  read_first.each { |name| rbw_set << name }
414
428
  end
415
429
 
430
+ # ADR-38 — true when a loaded plugin declares `method_name` an
431
+ # additional initializer for `class_name` (or an ancestor).
432
+ # Reads the plugin registry off the pre-pass scope's
433
+ # environment; the receiver-constraint match reuses
434
+ # `Environment#class_ordering` (the same mechanism ADR-16
435
+ # Tier A's `MacroBlockSelfType` uses). The whole lookup is
436
+ # wrapped so any resolution failure degrades to "no match" —
437
+ # since the gate only ever SUPPRESSES a nil contribution, a
438
+ # missed match is false-positive-safe (it merely leaves the
439
+ # existing nil widening in place).
440
+ def additional_initializer?(class_name, method_name, default_scope)
441
+ return false if class_name.nil? || default_scope.nil?
442
+
443
+ environment = default_scope.environment
444
+ registry = environment&.plugin_registry
445
+ return false if registry.nil?
446
+ return false if registry.respond_to?(:empty?) && registry.empty?
447
+ return false unless registry.respond_to?(:additional_initializers)
448
+
449
+ registry.additional_initializers.any? do |entry|
450
+ entry.covers_method?(method_name) &&
451
+ class_matches_constraint?(class_name, entry.receiver_constraint, environment)
452
+ end
453
+ rescue StandardError
454
+ false
455
+ end
456
+
457
+ def class_matches_constraint?(class_name, constraint, environment)
458
+ return true if class_name == constraint
459
+ return false if environment.nil?
460
+
461
+ ordering = environment.class_ordering(class_name, constraint)
462
+ %i[equal subclass].include?(ordering)
463
+ rescue StandardError
464
+ false
465
+ end
466
+
416
467
  IVAR_WRITE_NODES = [
417
468
  Prism::InstanceVariableWriteNode,
418
469
  Prism::InstanceVariableOrWriteNode,
@@ -802,7 +853,19 @@ module Rigor
802
853
  accumulator.transform_values(&:freeze).freeze
803
854
  end
804
855
 
805
- # rubocop:disable Metrics/CyclomaticComplexity, Metrics/MethodLength
856
+ # Merges two `class_name => { method => kind }` tables, unioning
857
+ # the per-class method maps (so a seeded cross-file table and the
858
+ # current file's table combine instead of clobbering).
859
+ def deep_merge_class_methods(base, overlay)
860
+ return overlay if base.nil? || base.empty?
861
+ return base if overlay.empty?
862
+
863
+ base.merge(overlay) do |_class_name, base_methods, overlay_methods|
864
+ base_methods.merge(overlay_methods)
865
+ end
866
+ end
867
+
868
+ # rubocop:disable Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/AbcSize
806
869
  def walk_methods(node, qualified_prefix, in_singleton_class, accumulator)
807
870
  return unless node.is_a?(Prism::Node)
808
871
 
@@ -836,6 +899,10 @@ module Rigor
836
899
  return
837
900
  when Prism::CallNode
838
901
  record_define_method(node, qualified_prefix, in_singleton_class, accumulator) if node.name == :define_method
902
+ if ATTR_MACROS.include?(node.name)
903
+ record_attr_methods(node, qualified_prefix, in_singleton_class,
904
+ accumulator)
905
+ end
839
906
  end
840
907
 
841
908
  node.compact_child_nodes.each do |child|
@@ -866,7 +933,7 @@ module Rigor
866
933
  end
867
934
  end
868
935
  end
869
- # rubocop:enable Metrics/CyclomaticComplexity, Metrics/MethodLength
936
+ # rubocop:enable Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/AbcSize
870
937
 
871
938
  # v0.1.2 — when a `Const = Data.define(*sym) do ... end`
872
939
  # / `Const = Struct.new(*sym) do ... end` constant write
@@ -1307,6 +1374,35 @@ module Rigor
1307
1374
  accumulator[class_name][method_name] = in_singleton_class ? :singleton : :instance
1308
1375
  end
1309
1376
 
1377
+ # The `attr_*` accessor macros that introduce methods Rigor must
1378
+ # treat as source-declared. Without this, a class that defines an
1379
+ # accessor with `attr_reader :x` AND carries RBS that omits `x`
1380
+ # (a common gap — the project ships an incomplete `sig/`) fires a
1381
+ # false `call.undefined-method` on `obj.x`, because the
1382
+ # undefined-method rule only suppressed `def` / `define_method` /
1383
+ # `alias_method`-discovered methods. `attr_reader` defines
1384
+ # readers, `attr_writer` writers (`x=`), `attr_accessor` both.
1385
+ ATTR_MACROS = %i[attr_reader attr_writer attr_accessor].freeze
1386
+
1387
+ def record_attr_methods(call_node, qualified_prefix, in_singleton_class, accumulator)
1388
+ return if qualified_prefix.empty?
1389
+ return unless call_node.receiver.nil? # only the implicit-self macro defines on the lexical class
1390
+ return if call_node.arguments.nil?
1391
+
1392
+ kind = in_singleton_class ? :singleton : :instance
1393
+ reader = call_node.name != :attr_writer
1394
+ writer = call_node.name != :attr_reader
1395
+ class_name = qualified_prefix.join("::")
1396
+ call_node.arguments.arguments.each do |arg|
1397
+ base = literal_method_name(arg)
1398
+ next if base.nil?
1399
+
1400
+ accumulator[class_name] ||= {}
1401
+ accumulator[class_name][base] = kind if reader
1402
+ accumulator[class_name][:"#{base}="] = kind if writer
1403
+ end
1404
+ end
1405
+
1310
1406
  def literal_method_name(node)
1311
1407
  return nil unless node.is_a?(Prism::SymbolNode) || node.is_a?(Prism::StringNode)
1312
1408
 
@@ -1386,7 +1482,7 @@ module Rigor
1386
1482
  # @return [Hash{Symbol => Hash}]
1387
1483
  # `{ def_nodes:, def_sources:, superclasses:, includes: }`
1388
1484
  def discovered_def_index_for_paths(paths, buffer: nil)
1389
- acc = { def_nodes: {}, def_sources: {}, superclasses: {}, includes: {}, method_visibilities: {} }
1485
+ acc = { def_nodes: {}, def_sources: {}, superclasses: {}, includes: {}, method_visibilities: {}, methods: {} }
1390
1486
  paths.each do |path|
1391
1487
  physical = buffer ? buffer.resolve(path) : path
1392
1488
  root = Prism.parse(File.read(physical), filepath: path).value
@@ -1396,10 +1492,30 @@ module Rigor
1396
1492
  # analyzer surfaces the parse error separately.
1397
1493
  next
1398
1494
  end
1399
- %i[def_nodes def_sources includes method_visibilities].each { |key| acc[key].each_value(&:freeze) }
1495
+ # Cross-file method suppression is for the project's OWN
1496
+ # accessors (attr_* / define_method / alias) — NOT for plain
1497
+ # `def`s. A cross-file `def` on a class is exactly the ADR-17
1498
+ # monkey-patch case the undefined-method rule deliberately
1499
+ # surfaces (fire + def-site annotation, nudging `pre_eval:`),
1500
+ # so dropping the `def`-declared names keeps that contract
1501
+ # intact while still letting `attr_reader :x` in one file
1502
+ # suppress a false undefined-method for `obj.x` in another.
1503
+ acc[:methods] = subtract_def_methods(acc[:methods], acc[:def_nodes])
1504
+ %i[def_nodes def_sources includes method_visibilities methods].each { |key| acc[key].each_value(&:freeze) }
1400
1505
  acc.transform_values(&:freeze)
1401
1506
  end
1402
1507
 
1508
+ # Removes, per class, the method names that have a project `def`
1509
+ # node, leaving only accessor/alias/define_method-introduced
1510
+ # methods in the cross-file suppression table.
1511
+ def subtract_def_methods(methods, def_nodes)
1512
+ methods.each_with_object({}) do |(class_name, table), out|
1513
+ defs = def_nodes[class_name] || {}
1514
+ kept = table.reject { |method_name, _kind| defs.key?(method_name) }
1515
+ out[class_name] = kept unless kept.empty?
1516
+ end
1517
+ end
1518
+
1403
1519
  # Folds one file's class-keyed indexes into the cross-file
1404
1520
  # accumulator. `method_visibilities` (ADR-35) is collected here so
1405
1521
  # the override-visibility-reduced rule can read an ancestor's
@@ -1413,6 +1529,9 @@ module Rigor
1413
1529
  build_discovered_method_visibilities(root).each do |class_name, table|
1414
1530
  (acc[:method_visibilities][class_name] ||= {}).merge!(table)
1415
1531
  end
1532
+ build_discovered_methods(root).each do |class_name, table|
1533
+ (acc[:methods][class_name] ||= {}).merge!(table)
1534
+ end
1416
1535
  end
1417
1536
 
1418
1537
  # Merges one file's `class → method → DefNode` map into the
@@ -1371,16 +1371,21 @@ module Rigor
1371
1371
  end
1372
1372
  end
1373
1373
 
1374
- # Walks the registry and collects each plugin's
1375
- # `flow_contribution_for` result, swallowing per-plugin
1376
- # exceptions so a buggy plugin can't abort the assertion
1377
- # path. Mirrors `MethodDispatcher.collect_plugin_contributions`
1378
- # exactly the two paths consume the same hook.
1374
+ # ADR-37 slice 2 gathers each plugin's post-return narrowing from
1375
+ # BOTH the narrow `type_specifier` DSL (method-gated, wrapped as a
1376
+ # facts-only `FlowContribution`) and the legacy
1377
+ # `flow_contribution_for` escape valve, swallowing per-plugin
1378
+ # exceptions so a buggy plugin can't abort the assertion path.
1379
1379
  def collect_plugin_contributions(registry, call_node, current_scope)
1380
- registry.plugins.filter_map do |plugin|
1381
- plugin.flow_contribution_for(call_node: call_node, scope: current_scope)
1380
+ registry.plugins.flat_map do |plugin|
1381
+ contributions = []
1382
+ legacy = plugin.flow_contribution_for(call_node: call_node, scope: current_scope)
1383
+ contributions << legacy if legacy.is_a?(Rigor::FlowContribution)
1384
+ facts = plugin.type_specifier_facts(call_node: call_node, scope: current_scope)
1385
+ contributions << Rigor::FlowContribution.new(post_return_facts: facts) if facts && !facts.empty?
1386
+ contributions
1382
1387
  rescue StandardError
1383
- nil
1388
+ []
1384
1389
  end
1385
1390
  end
1386
1391