rigortype 0.1.14 → 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 (114) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +10 -2
  3. data/exe/rigor +19 -0
  4. data/lib/rigor/analysis/check_rules.rb +428 -6
  5. data/lib/rigor/analysis/diagnostic.rb +55 -3
  6. data/lib/rigor/analysis/rule_catalog.rb +80 -0
  7. data/lib/rigor/analysis/runner.rb +71 -2
  8. data/lib/rigor/analysis/worker_session.rb +3 -2
  9. data/lib/rigor/cache/descriptor.rb +6 -2
  10. data/lib/rigor/cli/plugin_command.rb +245 -0
  11. data/lib/rigor/cli/plugins_command.rb +51 -4
  12. data/lib/rigor/cli/plugins_renderer.rb +86 -1
  13. data/lib/rigor/cli.rb +143 -5
  14. data/lib/rigor/configuration/severity_profile.rb +9 -0
  15. data/lib/rigor/environment/rbs_loader.rb +259 -1
  16. data/lib/rigor/environment.rb +8 -2
  17. data/lib/rigor/inference/budget_trace.rb +137 -0
  18. data/lib/rigor/inference/expression_typer.rb +9 -2
  19. data/lib/rigor/inference/hkt_reducer.rb +2 -0
  20. data/lib/rigor/inference/method_dispatcher/overload_selector.rb +23 -6
  21. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +81 -14
  22. data/lib/rigor/inference/method_dispatcher.rb +57 -10
  23. data/lib/rigor/inference/precision_scanner.rb +60 -1
  24. data/lib/rigor/inference/scope_indexer.rb +184 -27
  25. data/lib/rigor/inference/statement_evaluator.rb +13 -8
  26. data/lib/rigor/inference/synthetic_method_index.rb +23 -4
  27. data/lib/rigor/inference/synthetic_method_scanner.rb +148 -14
  28. data/lib/rigor/plugin/additional_initializer.rb +108 -0
  29. data/lib/rigor/plugin/base.rb +321 -2
  30. data/lib/rigor/plugin/box.rb +64 -0
  31. data/lib/rigor/plugin/inflector.rb +121 -0
  32. data/lib/rigor/plugin/isolation.rb +191 -0
  33. data/lib/rigor/plugin/macro/nested_class_template.rb +140 -0
  34. data/lib/rigor/plugin/macro.rb +1 -0
  35. data/lib/rigor/plugin/manifest.rb +120 -23
  36. data/lib/rigor/plugin/node_context.rb +62 -0
  37. data/lib/rigor/plugin/registry.rb +10 -0
  38. data/lib/rigor/plugin.rb +3 -0
  39. data/lib/rigor/scope.rb +27 -1
  40. data/lib/rigor/sig_gen/generator.rb +2 -3
  41. data/lib/rigor/sig_gen/observation_collector.rb +2 -2
  42. data/lib/rigor/source/literals.rb +118 -0
  43. data/lib/rigor/source/node_walker.rb +26 -0
  44. data/lib/rigor/source.rb +1 -0
  45. data/lib/rigor/triage/catalogue.rb +71 -5
  46. data/lib/rigor/type/combinator.rb +6 -1
  47. data/lib/rigor/type/union.rb +65 -1
  48. data/lib/rigor/version.rb +1 -1
  49. data/lib/rigor.rb +1 -0
  50. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/analyzer.rb +31 -53
  51. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable.rb +21 -23
  52. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/analyzer.rb +38 -59
  53. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_discoverer.rb +7 -13
  54. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer.rb +22 -33
  55. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/analyzer.rb +298 -413
  56. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +69 -71
  57. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/analyzer.rb +24 -34
  58. data/plugins/rigor-activejob/lib/rigor/plugin/activejob.rb +18 -16
  59. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/analyzer.rb +4 -46
  60. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +4 -4
  61. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_index.rb +1 -1
  62. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +17 -12
  63. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/analyzer.rb +2 -8
  64. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/attachment_discoverer.rb +2 -7
  65. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage.rb +2 -6
  66. data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema/schema_scanner.rb +4 -3
  67. data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation.rb +5 -1
  68. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/analyzer.rb +40 -45
  69. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_discoverer.rb +7 -17
  70. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot.rb +20 -42
  71. data/plugins/rigor-graphql/lib/rigor/plugin/graphql/type_scanner.rb +7 -4
  72. data/plugins/rigor-hanami/lib/rigor/plugin/hanami/action_checker.rb +4 -8
  73. data/plugins/rigor-mangrove/lib/rigor/plugin/mangrove.rb +188 -0
  74. data/plugins/rigor-mangrove/lib/rigor-mangrove.rb +3 -0
  75. data/plugins/rigor-minitest/lib/rigor/plugin/minitest/assertion_analyzer.rb +4 -0
  76. data/plugins/rigor-minitest/lib/rigor/plugin/minitest.rb +24 -8
  77. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/analyzer.rb +31 -48
  78. data/plugins/rigor-pundit/lib/rigor/plugin/pundit.rb +21 -23
  79. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/analyzer.rb +54 -82
  80. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +25 -25
  81. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/analyzer.rb +63 -147
  82. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/devise_routes.rb +4 -17
  83. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/routes_parser.rb +23 -114
  84. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +36 -31
  85. data/plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline.rb +0 -1
  86. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/let_scope_index.rb +6 -3
  87. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/scope_walker.rb +4 -2
  88. data/plugins/rigor-rspec/lib/rigor/plugin/rspec.rb +13 -12
  89. data/plugins/rigor-rspec-rails/lib/rigor/plugin/rspec_rails/have_http_status_analyzer.rb +28 -40
  90. data/plugins/rigor-rspec-rails/lib/rigor/plugin/rspec_rails/http_status_codes.rb +44 -47
  91. data/plugins/rigor-rspec-rails/lib/rigor/plugin/rspec_rails.rb +11 -10
  92. data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers/analyzer.rb +45 -87
  93. data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers.rb +11 -12
  94. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/analyzer.rb +29 -42
  95. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq.rb +20 -19
  96. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/catalog_walker.rb +73 -0
  97. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/type_translator.rb +43 -1
  98. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +21 -29
  99. data/plugins/rigor-statesman/lib/rigor/plugin/statesman.rb +36 -96
  100. data/sig/rigor/plugin/access_denied_error.rbs +3 -1
  101. data/sig/rigor/plugin/base.rbs +58 -3
  102. data/sig/rigor/plugin/io_boundary.rbs +3 -0
  103. data/sig/rigor/plugin/manifest.rbs +31 -1
  104. data/sig/rigor/scope.rbs +3 -0
  105. data/sig/rigor/source.rbs +12 -0
  106. data/sig/rigor.rbs +5 -0
  107. data/skills/rigor-plugin-author/SKILL.md +33 -9
  108. data/skills/rigor-plugin-author/references/01-plan-and-scaffold.md +65 -26
  109. data/skills/rigor-plugin-author/references/02-walker-and-types.md +213 -80
  110. data/skills/rigor-plugin-author/references/03-test-and-ship.md +3 -3
  111. data/skills/rigor-project-init/SKILL.md +72 -7
  112. data/skills/rigor-project-init/references/03-baseline-and-bugs.md +233 -19
  113. metadata +53 -2
  114. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/inflector.rb +0 -114
@@ -0,0 +1,137 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rigor
4
+ module Inference
5
+ # Opt-in counters for the hard-coded inference cutoffs — the
6
+ # "budget" guards that silently return `Dynamic[top]` / `nil` /
7
+ # a fallback bound rather than emitting a diagnostic. These are
8
+ # the *operative* cutoffs in the engine today (the configurable
9
+ # `budgets:` table in docs/type-specification/inference-budgets.md
10
+ # is not yet wired); counting how often each fires on a real
11
+ # project is the only way to see where inference actually stops.
12
+ #
13
+ # Three categories, one per guard site:
14
+ #
15
+ # - {RECURSION_GUARD} — `ExpressionTyper#infer_user_method_return`
16
+ # detected a `(receiver, method)` cycle and returned
17
+ # `Dynamic[top]` (the de-facto recursion-depth budget, effective
18
+ # depth 1).
19
+ # - {ANCESTOR_WALK_LIMIT} — `resolve_user_def_through_ancestors`
20
+ # hit the 100-node BFS cap and gave up resolving the self-call.
21
+ # - {HKT_FUEL_EXHAUSTED} — `HktReducer` ran out of its reduction
22
+ # fuel budget and unwound to `app.bound`.
23
+ #
24
+ # Enabled only when `RIGOR_BUDGET_TRACE` is set (to any non-empty
25
+ # value) in the environment, or via {enable!} in tests. When
26
+ # disabled, {hit} is a single boolean check and returns
27
+ # immediately, so normal runs pay nothing.
28
+ #
29
+ # Counters are process-global (Mutex-guarded) so they aggregate
30
+ # across threads, but they do NOT cross `fork` boundaries — run
31
+ # `rigor check --workers 0` to keep all inference in one process
32
+ # when collecting a trace.
33
+ module BudgetTrace
34
+ RECURSION_GUARD = :recursion_guard
35
+ ANCESTOR_WALK_LIMIT = :ancestor_walk_limit
36
+ HKT_FUEL_EXHAUSTED = :hkt_fuel_exhausted
37
+
38
+ CATEGORIES = [RECURSION_GUARD, ANCESTOR_WALK_LIMIT, HKT_FUEL_EXHAUSTED].freeze
39
+
40
+ # Distribution (histogram) categories — read-only observations of
41
+ # a value's size at a site, used to choose budget defaults from an
42
+ # observed tail rather than a guess (ADR-41 WD3 / Slice 2a). No cap
43
+ # is enforced; these only record. `UNION_ARITY` is the member count
44
+ # of every `Type::Union` that `Combinator.union` produces — the
45
+ # distribution the `union_size` budget default should be set from.
46
+ UNION_ARITY = :union_arity
47
+
48
+ DISTRIBUTION_CATEGORIES = [UNION_ARITY].freeze
49
+
50
+ @enabled = !ENV["RIGOR_BUDGET_TRACE"].to_s.empty?
51
+ @mutex = Mutex.new
52
+ @counts = Hash.new(0)
53
+ @distributions = Hash.new { |h, k| h[k] = Hash.new(0) }
54
+
55
+ module_function
56
+
57
+ def enabled?
58
+ @enabled
59
+ end
60
+
61
+ # Test / programmatic toggles. Production enablement is the
62
+ # `RIGOR_BUDGET_TRACE` env var read once at load time.
63
+ def enable!
64
+ @enabled = true
65
+ end
66
+
67
+ def disable!
68
+ @enabled = false
69
+ end
70
+
71
+ # Records one firing of `category`. No-op (one boolean check)
72
+ # when tracing is disabled.
73
+ def hit(category)
74
+ return unless @enabled
75
+
76
+ @mutex.synchronize { @counts[category] += 1 }
77
+ end
78
+
79
+ # Frozen snapshot of the current counts, every known category
80
+ # present (zero-filled) so consumers can render a stable table.
81
+ def snapshot
82
+ @mutex.synchronize do
83
+ CATEGORIES.to_h { |category| [category, @counts[category]] }.freeze
84
+ end
85
+ end
86
+
87
+ # Records one observation of `value` (an Integer size) into
88
+ # `category`'s histogram. No-op (one boolean check) when disabled.
89
+ def observe(category, value)
90
+ return unless @enabled
91
+
92
+ @mutex.synchronize { @distributions[category][value] += 1 }
93
+ end
94
+
95
+ # Frozen `{value => count}` histogram for a distribution category.
96
+ def distribution(category)
97
+ @mutex.synchronize { @distributions[category].dup.freeze }
98
+ end
99
+
100
+ # Summary of a distribution category: total observation count, max
101
+ # observed value, selected percentiles, and how many observations
102
+ # met or exceeded each threshold in `over`. Percentiles use the
103
+ # nearest-rank method over the expanded sample.
104
+ def summarize(category, over: [])
105
+ hist = distribution(category)
106
+ total = hist.values.sum
107
+ return { count: 0, max: 0, percentiles: {}, over: over.to_h { |t| [t, 0] } } if total.zero?
108
+
109
+ sorted = hist.keys.sort
110
+ { count: total,
111
+ max: sorted.last,
112
+ percentiles: { p50: percentile(hist, total, 0.50), p90: percentile(hist, total, 0.90),
113
+ p99: percentile(hist, total, 0.99) },
114
+ over: over.to_h { |t| [t, hist.sum { |value, n| value >= t ? n : 0 }] } }
115
+ end
116
+
117
+ # Nearest-rank percentile over a `{value => count}` histogram
118
+ # without materialising the full sample.
119
+ def percentile(hist, total, fraction)
120
+ rank = (fraction * total).ceil
121
+ cumulative = 0
122
+ hist.keys.sort.each do |value|
123
+ cumulative += hist[value]
124
+ return value if cumulative >= rank
125
+ end
126
+ hist.keys.max
127
+ end
128
+
129
+ def reset
130
+ @mutex.synchronize do
131
+ @counts.clear
132
+ @distributions.clear
133
+ end
134
+ end
135
+ end
136
+ end
137
+ end
@@ -5,6 +5,7 @@ require "prism"
5
5
  require_relative "../type"
6
6
  require_relative "../ast"
7
7
  require_relative "block_parameter_binder"
8
+ require_relative "budget_trace"
8
9
  require_relative "fallback"
9
10
  require_relative "indexed_narrowing"
10
11
  require_relative "macro_block_self_type"
@@ -1358,7 +1359,10 @@ module Rigor
1358
1359
 
1359
1360
  seen[current] = true
1360
1361
  visited += 1
1361
- return nil if visited > ANCESTOR_WALK_LIMIT
1362
+ if visited > ANCESTOR_WALK_LIMIT
1363
+ BudgetTrace.hit(BudgetTrace::ANCESTOR_WALK_LIMIT)
1364
+ return nil
1365
+ end
1362
1366
 
1363
1367
  found = scope.user_def_for(current, method_name)
1364
1368
  return found if found
@@ -1432,7 +1436,10 @@ module Rigor
1432
1436
  # carrier for top-level / DSL-block defs) printable.
1433
1437
  signature = [receiver.describe(:short), def_node.name]
1434
1438
  stack = (Thread.current[INFERENCE_GUARD_KEY] ||= [])
1435
- return Type::Combinator.untyped if stack.include?(signature)
1439
+ if stack.include?(signature)
1440
+ BudgetTrace.hit(BudgetTrace::RECURSION_GUARD)
1441
+ return Type::Combinator.untyped
1442
+ end
1436
1443
 
1437
1444
  stack.push(signature)
1438
1445
  begin
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "hkt_body"
4
+ require_relative "budget_trace"
4
5
 
5
6
  module Rigor
6
7
  module Inference
@@ -71,6 +72,7 @@ module Rigor
71
72
  walk(definition.body_tree, bindings: bindings_for(definition, app.args), state: state) || app.bound
72
73
  end
73
74
  rescue FuelExhausted
75
+ BudgetTrace.hit(BudgetTrace::HKT_FUEL_EXHAUSTED)
74
76
  app.bound
75
77
  end
76
78
  end
@@ -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