rigortype 0.1.2 → 0.1.3

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.
@@ -24,8 +24,10 @@ module Rigor
24
24
  class Descriptor # rubocop:disable Metrics/ClassLength
25
25
  # Bumped on incompatible schema changes. The storage layer
26
26
  # mixes this into the cache key, so a bump implicitly
27
- # invalidates every cached value.
28
- SCHEMA_VERSION = 1
27
+ # invalidates every cached value. v2 added the
28
+ # `dependencies` slot for ADR-10 per-gem-version cache slice
29
+ # invalidation.
30
+ SCHEMA_VERSION = 2
29
31
 
30
32
  # Per-slot entry value objects. Constructors validate enums /
31
33
  # required fields and freeze the resulting struct so no caller
@@ -134,6 +136,54 @@ module Rigor
134
136
  end
135
137
  end
136
138
 
139
+ # Per-(gem, version, mode) row carrying the cache slice
140
+ # boundary for ADR-10 dependency-source inference. A
141
+ # `bundle update` that bumps a listed gem's pinned version
142
+ # produces a different `gem_version` here and therefore a
143
+ # fresh cache key — invalidating exactly that gem's slice
144
+ # without disturbing other gems' slices or the project's
145
+ # own cache.
146
+ #
147
+ # `mode` mirrors the
148
+ # [Configuration::Dependencies::VALID_MODES](../configuration/dependencies.rb)
149
+ # enum (`:disabled` / `:when_missing` / `:full`); a mode
150
+ # change for the same gem also forces invalidation because
151
+ # the inferred shapes depend on whether RBS overrides the
152
+ # walk.
153
+ class DependencyEntry
154
+ VALID_MODES = %i[disabled when_missing full].freeze
155
+
156
+ attr_reader :gem_name, :gem_version, :mode
157
+
158
+ def initialize(gem_name:, gem_version:, mode:)
159
+ unless VALID_MODES.include?(mode)
160
+ raise ArgumentError,
161
+ "DependencyEntry mode must be one of #{VALID_MODES.inspect}, got #{mode.inspect}"
162
+ end
163
+
164
+ @gem_name = gem_name.to_s.dup.freeze
165
+ @gem_version = gem_version.to_s.dup.freeze
166
+ @mode = mode
167
+ freeze
168
+ end
169
+
170
+ def to_h
171
+ { "gem_name" => gem_name, "gem_version" => gem_version, "mode" => mode.to_s }
172
+ end
173
+
174
+ def ==(other)
175
+ other.is_a?(DependencyEntry) &&
176
+ other.gem_name == gem_name &&
177
+ other.gem_version == gem_version &&
178
+ other.mode == mode
179
+ end
180
+ alias eql? ==
181
+
182
+ def hash
183
+ [self.class, gem_name, gem_version, mode].hash
184
+ end
185
+ end
186
+
137
187
  # Raised when {.compose} encounters incompatible entries
138
188
  # under the same key (file digest mismatch, gem-locked
139
189
  # disagreement, …). Callers handle the exception by
@@ -141,13 +191,14 @@ module Rigor
141
191
  # contribution silently.
142
192
  class Conflict < StandardError; end
143
193
 
144
- attr_reader :files, :gems, :plugins, :configs
194
+ attr_reader :files, :gems, :plugins, :configs, :dependencies
145
195
 
146
- def initialize(files: [], gems: [], plugins: [], configs: [])
196
+ def initialize(files: [], gems: [], plugins: [], configs: [], dependencies: [])
147
197
  @files = files.dup.freeze
148
198
  @gems = gems.dup.freeze
149
199
  @plugins = plugins.dup.freeze
150
200
  @configs = configs.dup.freeze
201
+ @dependencies = dependencies.dup.freeze
151
202
  freeze
152
203
  end
153
204
 
@@ -170,7 +221,8 @@ module Rigor
170
221
  gems = compose_by_key(descriptors.flat_map(&:gems), :name)
171
222
  plugins = compose_by_key(descriptors.flat_map(&:plugins), :id)
172
223
  configs = compose_by_key(descriptors.flat_map(&:configs), :key)
173
- new(files: files, gems: gems, plugins: plugins, configs: configs)
224
+ dependencies = compose_by_key(descriptors.flat_map(&:dependencies), :gem_name)
225
+ new(files: files, gems: gems, plugins: plugins, configs: configs, dependencies: dependencies)
174
226
  end
175
227
 
176
228
  # @param producer_id [String]
@@ -196,6 +248,7 @@ module Rigor
196
248
  def to_canonical_hash
197
249
  {
198
250
  "configs" => sort_entries(configs, "key").map(&:to_h),
251
+ "dependencies" => sort_entries(dependencies, "gem_name").map(&:to_h),
199
252
  "files" => sort_entries(files, "path").map(&:to_h),
200
253
  "gems" => sort_entries(gems, "name").map(&:to_h),
201
254
  "plugins" => sort_entries(plugins, "id").map(&:to_h)
@@ -0,0 +1,235 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rigor
4
+ class Configuration
5
+ # Parsed `dependencies:` section of `.rigor.yml`. Per
6
+ # [ADR-10](../../../docs/adr/10-dependency-source-inference.md),
7
+ # the only nested key today is `source_inference:`, listing
8
+ # gems whose Ruby implementation Rigor MAY walk during
9
+ # inference instead of degrading to `Dynamic[top]` at the
10
+ # dependency boundary.
11
+ #
12
+ # Slice 1 lands the parser only — `Configuration#dependencies`
13
+ # is read, but no analyzer machinery consumes it yet. Slice 2
14
+ # wires `Analysis::DependencySourceInference` against this
15
+ # value object.
16
+ class Dependencies # rubocop:disable Metrics/ClassLength
17
+ # Walking modes per
18
+ # [ADR-10 § "Decision"](../../../docs/adr/10-dependency-source-inference.md#decision).
19
+ VALID_MODES = %i[disabled when_missing full].freeze
20
+
21
+ # Default `roots:` for an entry that does not supply one.
22
+ # The hard-excluded directories (`spec/` / `test/` / `bin/`
23
+ # / C extensions) are enforced by the walker, not the
24
+ # parser — see ADR-10 § "Hard exclusions".
25
+ DEFAULT_ROOTS = %w[lib].freeze
26
+
27
+ # Default per-gem catalog cap. ADR-10 slice 4 picks
28
+ # 5000 method definitions: it covers Rack (~1500),
29
+ # Faraday (~500), Sidekiq (~800) and other realistic
30
+ # opt-in targets, while still surfacing a diagnostic for
31
+ # ActiveSupport-class libraries (~10 000+ methods) where
32
+ # the user should ship RBS or de-list the gem instead.
33
+ DEFAULT_BUDGET_PER_GEM = 5000
34
+
35
+ # Range bounds per ADR-10 § "Budget interaction"
36
+ # ("range 0.25× – 4×"). Configured against the default,
37
+ # this lands at 1250 – 20 000.
38
+ MIN_BUDGET_PER_GEM = (DEFAULT_BUDGET_PER_GEM * 0.25).to_i
39
+ MAX_BUDGET_PER_GEM = (DEFAULT_BUDGET_PER_GEM * 4).to_i
40
+
41
+ # ADR-10 5b — budget-overrun strategy enum.
42
+ #
43
+ # - `:walker_cap` (default): the (α) semantics. The
44
+ # walker stops harvesting at the cap; methods past the
45
+ # cap fall through to the existing user-class fallback
46
+ # path. Existing v0.1.3 behaviour.
47
+ # - `:dependency_silence`: the (β) semantics. Same
48
+ # walker behaviour, but the dispatcher additionally
49
+ # consults `Index#class_to_gem` after a catalog miss.
50
+ # When the receiver's class belongs to a budget-
51
+ # exceeded gem, the call resolves to `Dynamic[top]`
52
+ # rather than falling through to user-class fallback.
53
+ # This silences `call.undefined-method` for unrecorded
54
+ # methods at the cost of weaker static checking on
55
+ # that gem's surface.
56
+ VALID_BUDGET_OVERRUN_STRATEGIES = %i[walker_cap dependency_silence].freeze
57
+ DEFAULT_BUDGET_OVERRUN_STRATEGY = :walker_cap
58
+
59
+ # Frozen value object describing a single per-gem opt-in.
60
+ # `gem:` is the gem name (matched against the bundle at
61
+ # walk time); `mode:` is one of {VALID_MODES}; `roots:` is
62
+ # the list of subdirectories within the gem's installation
63
+ # directory to walk (defaults to `["lib"]`).
64
+ Entry = Data.define(:gem, :mode, :roots) do
65
+ def disabled? = mode == :disabled
66
+ def when_missing? = mode == :when_missing
67
+ def full? = mode == :full
68
+ end
69
+
70
+ attr_reader :source_inference, :budget_per_gem, :budget_overrun_strategy, :warnings
71
+
72
+ # Parse the YAML-shaped `dependencies:` value into a
73
+ # frozen {Dependencies}. Accepts `nil` / `{}` / a Hash with
74
+ # `source_inference:` and / or `budget_per_gem:` /
75
+ # `budget_overrun_strategy:` present.
76
+ def self.from_h(data)
77
+ return new([]) if data.nil?
78
+ raise ArgumentError, "dependencies: must be a Hash, got #{data.inspect}" unless data.is_a?(Hash)
79
+
80
+ raw_entries = Array(data["source_inference"]).map { |raw| coerce_entry(raw) }
81
+ entries, warnings = dedupe_entries(raw_entries)
82
+ budget = coerce_budget_per_gem(data.fetch("budget_per_gem", DEFAULT_BUDGET_PER_GEM))
83
+ strategy = coerce_budget_overrun_strategy(
84
+ data.fetch("budget_overrun_strategy", DEFAULT_BUDGET_OVERRUN_STRATEGY)
85
+ )
86
+ new(entries, budget, warnings, strategy)
87
+ end
88
+
89
+ def initialize(source_inference, budget_per_gem = DEFAULT_BUDGET_PER_GEM,
90
+ warnings = [], budget_overrun_strategy = DEFAULT_BUDGET_OVERRUN_STRATEGY)
91
+ @source_inference = source_inference.freeze
92
+ @budget_per_gem = budget_per_gem
93
+ @warnings = warnings.freeze
94
+ @budget_overrun_strategy = budget_overrun_strategy
95
+ freeze
96
+ end
97
+
98
+ def to_h
99
+ {
100
+ "source_inference" => @source_inference.map do |entry|
101
+ {
102
+ "gem" => entry.gem,
103
+ "mode" => entry.mode.to_s,
104
+ "roots" => entry.roots
105
+ }
106
+ end,
107
+ "budget_per_gem" => @budget_per_gem,
108
+ "budget_overrun_strategy" => @budget_overrun_strategy.to_s
109
+ }
110
+ end
111
+
112
+ def empty? = @source_inference.empty?
113
+
114
+ class << self
115
+ # ADR-10 § "config-conflict diagnostic" — merges a
116
+ # potentially-duplicated entry list (the `includes:`
117
+ # chain produces concatenated arrays via
118
+ # `Configuration.deep_merge`'s special-case for
119
+ # `dependencies.source_inference`) into a single
120
+ # canonical entry per gem name. The merge rules:
121
+ #
122
+ # - Same gem, same all fields → idempotent collapse
123
+ # (no warning).
124
+ # - Same gem, different `mode:` → keep the LAST entry
125
+ # (matches existing right-wins semantics elsewhere)
126
+ # AND emit a `:warning` so the user knows their
127
+ # `includes:` chain is ambiguous.
128
+ # - Same gem, different `roots:` → union the roots
129
+ # silently (no warning). The walker is happy to
130
+ # visit the union.
131
+ #
132
+ # Returns `[entries, warnings]` so the caller can
133
+ # plumb the warning list through to the Runner for
134
+ # diagnostic emission.
135
+ def dedupe_entries(entries)
136
+ warnings = []
137
+ by_gem = {}
138
+ entries.each do |entry|
139
+ existing = by_gem[entry.gem]
140
+ by_gem[entry.gem] = if existing.nil?
141
+ entry
142
+ else
143
+ merge_entry_pair(existing, entry, warnings)
144
+ end
145
+ end
146
+ [by_gem.values, warnings]
147
+ end
148
+
149
+ def merge_entry_pair(existing, incoming, warnings)
150
+ if existing.mode != incoming.mode
151
+ warnings << "dependencies.source_inference[].gem #{incoming.gem.inspect} declared with " \
152
+ "conflicting modes (#{existing.mode.inspect} vs #{incoming.mode.inspect}); " \
153
+ "the later (#{incoming.mode.inspect}) wins."
154
+ end
155
+ merged_roots = (existing.roots + incoming.roots).uniq.freeze
156
+ Entry.new(gem: incoming.gem, mode: incoming.mode, roots: merged_roots)
157
+ end
158
+
159
+ private
160
+
161
+ def coerce_entry(raw)
162
+ unless raw.is_a?(Hash)
163
+ raise ArgumentError,
164
+ "dependencies.source_inference[] entry must be a Hash, got #{raw.inspect}"
165
+ end
166
+
167
+ Entry.new(
168
+ gem: coerce_gem(raw["gem"]),
169
+ mode: coerce_mode(raw["mode"]),
170
+ roots: coerce_roots(raw)
171
+ )
172
+ end
173
+
174
+ def coerce_gem(value)
175
+ unless value.is_a?(String) && !value.empty?
176
+ raise ArgumentError,
177
+ "dependencies.source_inference[].gem must be a non-empty String, got #{value.inspect}"
178
+ end
179
+
180
+ value.dup.freeze
181
+ end
182
+
183
+ def coerce_mode(value)
184
+ mode = (value || "when_missing").to_sym
185
+ return mode if VALID_MODES.include?(mode)
186
+
187
+ raise ArgumentError,
188
+ "dependencies.source_inference[].mode must be one of " \
189
+ "#{VALID_MODES.inspect}, got #{value.inspect}"
190
+ end
191
+
192
+ def coerce_roots(raw)
193
+ roots = Array(raw.fetch("roots", DEFAULT_ROOTS)).map(&:to_s).freeze
194
+ return roots unless roots.empty?
195
+
196
+ raise ArgumentError,
197
+ "dependencies.source_inference[].roots must not be empty when supplied " \
198
+ "(omit the key to fall back to the default #{DEFAULT_ROOTS.inspect})"
199
+ end
200
+
201
+ def coerce_budget_overrun_strategy(value)
202
+ symbol = value.to_sym
203
+ return symbol if VALID_BUDGET_OVERRUN_STRATEGIES.include?(symbol)
204
+
205
+ raise ArgumentError,
206
+ "dependencies.budget_overrun_strategy must be one of " \
207
+ "#{VALID_BUDGET_OVERRUN_STRATEGIES.inspect}, got #{value.inspect}"
208
+ end
209
+
210
+ # ADR-10 slice 4. Per-gem catalog cap is mandatory
211
+ # (the parser supplies the default before this is
212
+ # called, so `nil` only reaches here on an explicit
213
+ # `budget_per_gem: ~`). Range bounds match
214
+ # MIN_BUDGET_PER_GEM .. MAX_BUDGET_PER_GEM
215
+ # (i.e. 0.25× – 4× of the default).
216
+ def coerce_budget_per_gem(value)
217
+ unless value.is_a?(Integer)
218
+ raise ArgumentError,
219
+ "dependencies.budget_per_gem must be an Integer, " \
220
+ "got #{value.inspect}"
221
+ end
222
+
223
+ unless value.between?(MIN_BUDGET_PER_GEM, MAX_BUDGET_PER_GEM)
224
+ raise ArgumentError,
225
+ "dependencies.budget_per_gem must be in the range " \
226
+ "#{MIN_BUDGET_PER_GEM}..#{MAX_BUDGET_PER_GEM}, " \
227
+ "got #{value.inspect}"
228
+ end
229
+
230
+ value
231
+ end
232
+ end
233
+ end
234
+ end
235
+ end
@@ -2,6 +2,7 @@
2
2
 
3
3
  require "yaml"
4
4
 
5
+ require_relative "configuration/dependencies"
5
6
  require_relative "configuration/severity_profile"
6
7
 
7
8
  module Rigor
@@ -58,7 +59,11 @@ module Rigor
58
59
  "allowed_url_hosts" => []
59
60
  },
60
61
  "severity_profile" => "balanced",
61
- "severity_overrides" => {}
62
+ "severity_overrides" => {},
63
+ "dependencies" => {
64
+ "source_inference" => [],
65
+ "budget_per_gem" => Configuration::Dependencies::DEFAULT_BUDGET_PER_GEM
66
+ }
62
67
  }.freeze
63
68
 
64
69
  # Top-level keys whose values are file/directory paths that
@@ -72,7 +77,8 @@ module Rigor
72
77
  :libraries, :signature_paths, :fold_platform_specific_paths,
73
78
  :plugins_io_network, :plugins_io_allowed_paths,
74
79
  :plugins_io_allowed_url_hosts,
75
- :severity_profile, :severity_overrides
80
+ :severity_profile, :severity_overrides,
81
+ :dependencies
76
82
 
77
83
  # Loads a configuration file.
78
84
  #
@@ -174,15 +180,39 @@ module Rigor
174
180
 
175
181
  merged = left.dup
176
182
  right.each do |key, value|
177
- merged[key] = if merged.key?(key) && merged[key].is_a?(Hash) && value.is_a?(Hash)
178
- deep_merge(merged[key], value)
179
- else
180
- value
181
- end
183
+ merged[key] = merge_value(key, merged, value)
182
184
  end
183
185
  merged
184
186
  end
185
- private_class_method :load_with_includes, :merge_includes, :resolve_paths_in, :deep_merge
187
+
188
+ # Most keys are right-wins (override) or recursively
189
+ # merged hashes. ADR-10 § "config-conflict diagnostic"
190
+ # carves out `dependencies.source_inference[]`: the
191
+ # per-gem merge across `includes:` chains needs union
192
+ # behaviour with mode-conflict detection. The Hash itself
193
+ # still merges deeply; only the inner array gets
194
+ # concatenated so {Dependencies.from_h} sees every
195
+ # contributor's entries and can dedupe them.
196
+ def self.merge_value(key, merged, value)
197
+ if key == "dependencies" && merged[key].is_a?(Hash) && value.is_a?(Hash)
198
+ merge_dependencies_hash(merged[key], value)
199
+ elsif merged.key?(key) && merged[key].is_a?(Hash) && value.is_a?(Hash)
200
+ deep_merge(merged[key], value)
201
+ else
202
+ value
203
+ end
204
+ end
205
+
206
+ def self.merge_dependencies_hash(left, right)
207
+ out = deep_merge(left, right)
208
+ left_si = Array(left["source_inference"])
209
+ right_si = Array(right["source_inference"])
210
+ both_empty = left_si.empty? && right_si.empty?
211
+ out["source_inference"] = left_si + right_si unless both_empty # rigor:disable flow.always-truthy-condition
212
+ out
213
+ end
214
+ private_class_method :load_with_includes, :merge_includes, :resolve_paths_in, :deep_merge,
215
+ :merge_value, :merge_dependencies_hash
186
216
 
187
217
  # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity, Metrics/MethodLength
188
218
  def initialize(data = DEFAULTS)
@@ -213,6 +243,9 @@ module Rigor
213
243
  @severity_overrides = coerce_severity_overrides(
214
244
  data.fetch("severity_overrides", DEFAULTS.fetch("severity_overrides"))
215
245
  )
246
+ @dependencies = Dependencies.from_h(
247
+ data.fetch("dependencies", DEFAULTS.fetch("dependencies"))
248
+ )
216
249
  end
217
250
  # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity, Metrics/MethodLength
218
251
 
@@ -235,7 +268,8 @@ module Rigor
235
268
  "allowed_url_hosts" => plugins_io_allowed_url_hosts
236
269
  },
237
270
  "severity_profile" => severity_profile.to_s,
238
- "severity_overrides" => severity_overrides.to_h { |k, v| [k, v.to_s] }
271
+ "severity_overrides" => severity_overrides.to_h { |k, v| [k, v.to_s] },
272
+ "dependencies" => dependencies.to_h
239
273
  }
240
274
  end
241
275
 
@@ -41,7 +41,7 @@ module Rigor
41
41
  prism rbs
42
42
  ].freeze
43
43
 
44
- attr_reader :class_registry, :rbs_loader, :plugin_registry
44
+ attr_reader :class_registry, :rbs_loader, :plugin_registry, :dependency_source_index
45
45
 
46
46
  # @param class_registry [Rigor::Environment::ClassRegistry]
47
47
  # @param rbs_loader [Rigor::Environment::RbsLoader, nil] when nil the
@@ -57,10 +57,17 @@ module Rigor
57
57
  # default), no plugin-level return-type contribution
58
58
  # participates — useful for tests, the `Environment.default`
59
59
  # facade, and analyses that don't load plugins.
60
- def initialize(class_registry: ClassRegistry.default, rbs_loader: nil, plugin_registry: nil)
60
+ # @param dependency_source_index [Rigor::Analysis::DependencySourceInference::Index, nil]
61
+ # ADR-10 slice 2b-ii. The per-run index of opt-in gem
62
+ # sources the dispatcher consults BELOW RBS dispatch.
63
+ # When nil (the default), no dep-source contribution
64
+ # participates and the dispatcher tier is a no-op.
65
+ def initialize(class_registry: ClassRegistry.default, rbs_loader: nil,
66
+ plugin_registry: nil, dependency_source_index: nil)
61
67
  @class_registry = class_registry
62
68
  @rbs_loader = rbs_loader
63
69
  @plugin_registry = plugin_registry
70
+ @dependency_source_index = dependency_source_index
64
71
  freeze
65
72
  end
66
73
 
@@ -90,7 +97,8 @@ module Rigor
90
97
  # reflection artefacts) consult the cache. Pass `nil` (the
91
98
  # default) to skip caching for this environment.
92
99
  # @return [Rigor::Environment]
93
- def for_project(root: Dir.pwd, libraries: [], signature_paths: nil, cache_store: nil, plugin_registry: nil)
100
+ def for_project(root: Dir.pwd, libraries: [], signature_paths: nil, cache_store: nil, # rubocop:disable Metrics/ParameterLists
101
+ plugin_registry: nil, dependency_source_index: nil)
94
102
  resolved_paths = signature_paths || default_signature_paths(root)
95
103
  merged_libraries = (DEFAULT_LIBRARIES + libraries.map(&:to_s)).uniq
96
104
  loader = RbsLoader.new(
@@ -98,7 +106,11 @@ module Rigor
98
106
  signature_paths: resolved_paths,
99
107
  cache_store: cache_store
100
108
  )
101
- new(rbs_loader: loader, plugin_registry: plugin_registry)
109
+ new(
110
+ rbs_loader: loader,
111
+ plugin_registry: plugin_registry,
112
+ dependency_source_index: dependency_source_index
113
+ )
102
114
  end
103
115
 
104
116
  private
@@ -1,5 +1,9 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "conflict"
4
+ require_relative "fact"
5
+ require_relative "merge_result"
6
+
3
7
  module Rigor
4
8
  class FlowContribution
5
9
  # Composes any number of {FlowContribution} bundles into a
@@ -86,6 +86,18 @@ module Rigor
86
86
  )
87
87
  return rbs_result if rbs_result
88
88
 
89
+ # ADR-10 slice 2b-ii — dependency-source inference tier.
90
+ # Sits BELOW RBS dispatch (RBS / RBS::Inline / generated
91
+ # stubs / plugin contracts always win) and ABOVE the
92
+ # user-class fallback so a method defined in an opt-in
93
+ # gem stops emitting `call.undefined-method` even when
94
+ # no signature contract resolves. Returns
95
+ # `Dynamic[top]` — slice 2b-ii deliberately stops at the
96
+ # dynamic-origin envelope; per-method return-type
97
+ # precision is queued for a later slice.
98
+ dep_source_result = try_dependency_source(receiver_type, method_name, environment)
99
+ return dep_source_result if dep_source_result
100
+
89
101
  # Slice 7 phase 10 — user-class ancestor fallback. When
90
102
  # the receiver is `Nominal[T]` or `Singleton[T]` for a
91
103
  # class not in the RBS environment (typically a
@@ -125,6 +137,81 @@ module Rigor
125
137
  FlowContribution::Merger.merge(contributions).return_type
126
138
  end
127
139
 
140
+ # ADR-10 slice 2b-ii. Consults the per-run
141
+ # `Analysis::DependencySourceInference::Index` carried by
142
+ # the environment for `(class_name, method_name)`
143
+ # observations harvested from opt-in gems' `roots:`. On a
144
+ # hit, returns `Combinator.untyped` so the call site
145
+ # carries the `Dynamic[top]` provenance (per ADR-10's
146
+ # "Inference contract": gem-source-inferred shapes never
147
+ # publish as ground-truth `T`). Returns `nil` when the
148
+ # environment carries no index, the index has no entry, or
149
+ # the receiver has no nominal class to look up.
150
+ def try_dependency_source(receiver_type, method_name, environment)
151
+ index = environment&.dependency_source_index
152
+ return nil if index.nil? || index.empty?
153
+
154
+ class_name = dep_source_class_name(receiver_type)
155
+ return nil if class_name.nil?
156
+
157
+ # ADR-10 5a — per-receiver plugin veto. When a
158
+ # registered plugin declares `manifest(owns_receivers:
159
+ # [<class>])` AND the call's receiver IS that class
160
+ # (or a subclass), decline and let plugins handle the
161
+ # call. Plugins that own a receiver are the
162
+ # authoritative source for that type; gem-source
163
+ # inference must not contribute behind their backs.
164
+ return nil if plugin_owns_receiver?(class_name, environment)
165
+
166
+ contribution_kind = index.contribution_for(class_name: class_name, method_name: method_name)
167
+ return Type::Combinator.untyped if contribution_kind
168
+
169
+ # ADR-10 5b — β budget semantics. On a catalog miss,
170
+ # if the receiver class belongs to a budget-exceeded
171
+ # gem AND the user opted into `:dependency_silence`,
172
+ # return `Dynamic[top]` rather than falling through to
173
+ # the user-class fallback. The user-class fallback
174
+ # would otherwise emit `call.undefined-method` for
175
+ # methods Rigor's catalog couldn't reach because the
176
+ # walker hit its cap.
177
+ budget_silence_result(class_name, index, environment)
178
+ end
179
+
180
+ def budget_silence_result(class_name, index, _environment)
181
+ return nil unless index.budget_overrun_strategy == :dependency_silence
182
+
183
+ owning_gem = index.gem_for(class_name)
184
+ return nil if owning_gem.nil?
185
+ return nil unless index.budget_exceeded.include?(owning_gem)
186
+
187
+ Type::Combinator.untyped
188
+ end
189
+
190
+ def plugin_owns_receiver?(class_name, environment)
191
+ registry = environment&.plugin_registry
192
+ return false if registry.nil? || registry.empty?
193
+
194
+ registry.plugins.any? do |plugin|
195
+ owns = plugin.manifest.owns_receivers # rigor:disable undefined-method
196
+ owns.any? { |owner| receiver_matches_owner?(class_name, owner, environment) }
197
+ end
198
+ end
199
+
200
+ def receiver_matches_owner?(class_name, owner, environment)
201
+ return true if class_name == owner
202
+
203
+ ordering = environment.class_ordering(class_name, owner)
204
+ %i[equal subclass].include?(ordering)
205
+ rescue StandardError
206
+ false
207
+ end
208
+
209
+ def dep_source_class_name(receiver_type)
210
+ case receiver_type
211
+ when Type::Nominal, Type::Singleton then receiver_type.class_name
212
+ end
213
+ end
214
+
128
215
  def collect_plugin_contributions(registry, call_node, scope)
129
216
  registry.plugins.filter_map do |plugin|
130
217
  contribution = plugin.flow_contribution_for(call_node: call_node, scope: scope)
@@ -785,6 +785,7 @@ module Rigor
785
785
  evaluate_block_if_present(node)
786
786
  post_scope = record_closure_escape_if_any(node)
787
787
  post_scope = apply_rbs_extended_assertions(node, post_scope)
788
+ post_scope = apply_plugin_assertions(node, post_scope)
788
789
  post_scope = apply_rspec_matcher_narrowing(node, post_scope)
789
790
  [call_type, post_scope]
790
791
  end
@@ -978,6 +979,61 @@ module Rigor
978
979
  end
979
980
  end
980
981
 
982
+ # ADR-7 § "Slice 4-A" / T.bind priority slice 2 — applies
983
+ # the post-return facts plugin contributions produce. This
984
+ # is the sibling of {apply_rbs_extended_assertions}: the
985
+ # carrier (`Rigor::FlowContribution::Fact`) and the
986
+ # downstream narrowing path (`apply_post_return_fact` →
987
+ # `apply_self_post_return_fact`) are the same; only the
988
+ # *source* of the bundle changes (RBS::Extended vs the
989
+ # registered plugins' `flow_contribution_for`).
990
+ #
991
+ # `:self`-targeted facts narrow `scope.self_type` for the
992
+ # surrounding scope. In a block body, the surrounding
993
+ # scope is the block's own scope, so the narrowing applies
994
+ # to the rest of the block — exactly the contract Sorbet's
995
+ # `T.bind(self, T)` commits to.
996
+ #
997
+ # `:parameter`-targeted facts only land when the called
998
+ # method has an authoritative RBS sig (via
999
+ # `resolve_call_method`); plugins recognising their own
1000
+ # synthetic call shapes (e.g. `T.assert_type!`) have no
1001
+ # method_def and the parameter facts silently skip — the
1002
+ # plugin's own diagnostics_for_file path covers those
1003
+ # cases. The full plugin-side parameter-targeting story
1004
+ # (PHPStan-style Type-Specifying Extensions on
1005
+ # plugin-recognised calls) lives behind a follow-up slice
1006
+ # that introduces `:local` / `:argument_at` target kinds.
1007
+ def apply_plugin_assertions(call_node, current_scope)
1008
+ registry = current_scope.environment&.plugin_registry
1009
+ return current_scope if registry.nil? || registry.empty?
1010
+
1011
+ contributions = collect_plugin_contributions(registry, call_node, current_scope)
1012
+ return current_scope if contributions.empty?
1013
+
1014
+ result = Rigor::FlowContribution::Merger.merge(contributions)
1015
+ post_return = result.post_return_facts
1016
+ return current_scope if post_return.empty?
1017
+
1018
+ method_def = resolve_call_method(call_node, current_scope)
1019
+ post_return.reduce(current_scope) do |scope_acc, fact|
1020
+ apply_post_return_fact(fact, call_node, scope_acc, method_def)
1021
+ end
1022
+ end
1023
+
1024
+ # Walks the registry and collects each plugin's
1025
+ # `flow_contribution_for` result, swallowing per-plugin
1026
+ # exceptions so a buggy plugin can't abort the assertion
1027
+ # path. Mirrors `MethodDispatcher.collect_plugin_contributions`
1028
+ # exactly — the two paths consume the same hook.
1029
+ def collect_plugin_contributions(registry, call_node, current_scope)
1030
+ registry.plugins.filter_map do |plugin|
1031
+ plugin.flow_contribution_for(call_node: call_node, scope: current_scope)
1032
+ rescue StandardError
1033
+ nil
1034
+ end
1035
+ end
1036
+
981
1037
  def resolve_call_method(call_node, current_scope) # rubocop:disable Metrics/PerceivedComplexity
982
1038
  receiver_node = call_node.receiver
983
1039
  receiver_type =
@@ -1064,7 +1120,15 @@ module Rigor
1064
1120
  end
1065
1121
  end
1066
1122
 
1067
- def lookup_post_return_arg(call_node, method_def, target_name)
1123
+ def lookup_post_return_arg(call_node, method_def, target_name) # rubocop:disable Metrics/CyclomaticComplexity
1124
+ # Plugin-source contributions arrive without an
1125
+ # authoritative method_def (the plugin recognised the
1126
+ # call shape directly). Parameter-targeting falls back
1127
+ # to "no narrow" in that case — the wider plugin-side
1128
+ # parameter mapping (`:local` / `:argument_at`) is a
1129
+ # follow-up slice.
1130
+ return nil if method_def.nil?
1131
+
1068
1132
  arguments = call_node.arguments&.arguments || []
1069
1133
  method_def.method_types.each do |mt|
1070
1134
  params = mt.type.required_positionals + mt.type.optional_positionals