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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 47346567152367cb408115b6a33214e43468f2255bd632b4894d8c68164b8ada
4
- data.tar.gz: 407d5cda9e08342670a4eeb05ae2c565d398866174d292c586793cb213a77fdb
3
+ metadata.gz: 3542aa2842a06783d02f8f35b7f31247eb8d10dce91214b31364839ad7087809
4
+ data.tar.gz: 6beca5ec330dab3264cb64a110bd4e6a790e16a92c5f1cf10383799b988d2bb9
5
5
  SHA512:
6
- metadata.gz: f6ffecd573e1bbb387c861cbe3a6d909d217dfadef453a7a84339a4f7fd44e2c86dbbac54c8369298b56a2de3771c0f75c28cfe098cb895355a52155657f4f9d
7
- data.tar.gz: 6f924aed4bc15581bae58ffa62a5d8194db3fabbdc3056f2b6cfc4f068f972063e0148f85df8b8ef3e5f5003fad35b4e9016e534ef604cbfc581d0cdab76bb7b
6
+ metadata.gz: 33d98371534cd4d193b39afe08dc5b442cd90e0909388a9e9a20413d8ae766665d8bb7a8573bb2236997a72ce178e35c7d2c62c64ceac804f1a31bf88b079950
7
+ data.tar.gz: 0fca38a07730117fa58b42d8c14642e86540a492ce0513c4612dbf58049600bb8adc224fc32cdfc518ba581a870c478b8dfbbc02ce43bf78eb251c3d1dd771db
data/README.md CHANGED
@@ -239,6 +239,16 @@ Rigor consults, in order:
239
239
  Rigor walks `def` / `define_method` / `attr_*` /
240
240
  `Data.define(*Symbol)` so user-defined methods on a class
241
241
  are recognised.
242
+ 5. **Opt-in gem-source inference (ADR-10).** Gems listed
243
+ under `dependencies.source_inference:` in `.rigor.yml`
244
+ have their `lib/` walked the same way project source is,
245
+ so methods on those gems' classes resolve even without
246
+ RBS. Inferred returns crossing the gem boundary are
247
+ wrapped in `Dynamic[T]` so the call site retains the
248
+ provenance — RBS / RBS::Inline / generated stubs / plugin
249
+ contracts always win on conflict. Default behaviour is
250
+ unchanged: gems not listed stay at the
251
+ RBS-or-`Dynamic[Top]` boundary.
242
252
 
243
253
  If a type cannot be proved, the engine returns `Dynamic[Top]`
244
254
  (Rigor's gradual carrier) and stays silent — Rigor never invents
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "gem_resolver"
4
+ require_relative "index"
5
+ require_relative "walker"
6
+
7
+ module Rigor
8
+ module Analysis
9
+ module DependencySourceInference
10
+ # Folds a `Configuration::Dependencies` value into a
11
+ # frozen {Index}. Resolves each non-disabled entry through
12
+ # {GemResolver}, walks each resolved gem's `roots:` via
13
+ # {Walker.walk} under the configured `budget_per_gem` cap,
14
+ # and aggregates the per-gem method catalogs into the
15
+ # Index's flat `(class_name, method_name) → kind` table.
16
+ #
17
+ # Entries with `mode: :disabled` are skipped without
18
+ # resolution attempts so users can "list and disable" a
19
+ # gem in configuration without provoking a missing-gem
20
+ # diagnostic.
21
+ module Builder
22
+ module_function
23
+
24
+ # @param dependencies [Rigor::Configuration::Dependencies]
25
+ # @return [Index]
26
+ def build(dependencies) # rubocop:disable Metrics/MethodLength
27
+ return Index::EMPTY if dependencies.empty?
28
+
29
+ resolved = []
30
+ unresolvable = []
31
+ catalog = {}
32
+ class_to_gem = {}
33
+ budget_exceeded = []
34
+ budget = dependencies.budget_per_gem
35
+
36
+ dependencies.source_inference.each do |entry|
37
+ next if entry.disabled?
38
+
39
+ outcome = GemResolver.resolve(entry)
40
+ case outcome
41
+ when GemResolver::Resolved
42
+ resolved << outcome
43
+ walked = walker_outcome_for(outcome, budget)
44
+ catalog.merge!(walked.catalog)
45
+ record_class_to_gem(walked.catalog, outcome.gem_name, class_to_gem)
46
+ budget_exceeded << outcome.gem_name if walked.truncated?
47
+ when GemResolver::Unresolvable then unresolvable << outcome
48
+ end
49
+ end
50
+
51
+ Index.new(
52
+ resolved_gems: resolved, unresolvable: unresolvable,
53
+ method_catalog: catalog, budget_exceeded: budget_exceeded,
54
+ class_to_gem: class_to_gem,
55
+ budget_overrun_strategy: dependencies.budget_overrun_strategy
56
+ )
57
+ end
58
+
59
+ # ADR-10 5b — per-class reverse-lookup table (β budget
60
+ # semantics). Records `class_name → gem_name` for every
61
+ # class observed in the gem's catalog. First-write-wins:
62
+ # if two opt-in gems re-open the same class, the first
63
+ # gem to harvest the class owns it in the reverse index.
64
+ # The dispatcher only consults this map when the
65
+ # `budget_overrun_strategy` is `:dependency_silence`,
66
+ # so the storage cost is never paid back unless the
67
+ # user opts in.
68
+ def record_class_to_gem(catalog, gem_name, class_to_gem)
69
+ catalog.each_key do |(class_name, _method_name)|
70
+ class_to_gem[class_name] ||= gem_name
71
+ end
72
+ end
73
+
74
+ # Per-resolved-gem walk. Isolated so a single gem's
75
+ # filesystem error / parse failure cannot abort the
76
+ # build; the walker swallows its own per-file errors,
77
+ # and a top-level raise here degrades the gem to "no
78
+ # contributions" without touching the rest of the run.
79
+ def walker_outcome_for(resolved, budget)
80
+ Walker.walk(gem_dir: resolved.gem_dir, roots: resolved.roots, budget: budget)
81
+ rescue StandardError
82
+ Walker::Outcome.new(catalog: {}.freeze, truncated: false)
83
+ end
84
+ end
85
+ end
86
+ end
87
+ end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rigor
4
+ module Analysis
5
+ module DependencySourceInference
6
+ # Maps a `Configuration::Dependencies::Entry` to the gem's
7
+ # on-disk installation directory by consulting RubyGems
8
+ # (`Gem.loaded_specs` first, falling back to
9
+ # `Gem::Specification.find_by_name`). Returns either a
10
+ # frozen {Resolved} value object or an {Unresolvable} value
11
+ # describing why the gem cannot participate in this run.
12
+ #
13
+ # Resolution failures are surfaced as
14
+ # `dynamic.dependency-source.gem-not-found` diagnostics by
15
+ # {Analysis::Runner} rather than crashing the run, so a
16
+ # missing gem in `dependencies.source_inference` degrades
17
+ # cleanly to "no contributions from that gem" — every other
18
+ # gem and the project source remain unaffected.
19
+ module GemResolver
20
+ # Successful resolution. `version` is the spec version as
21
+ # a String so it round-trips into cache descriptors
22
+ # (slice 3) without leaking a `Gem::Version` instance
23
+ # through public surfaces.
24
+ Resolved = Data.define(:gem_name, :version, :gem_dir, :mode, :roots) do
25
+ def descriptor_key
26
+ [gem_name, version, mode].freeze
27
+ end
28
+ end
29
+
30
+ # Unresolvable reasons. `:not_in_bundle` covers both the
31
+ # "RubyGems doesn't know this gem" case and the
32
+ # `LoadError`-style raise from `find_by_name`. Future
33
+ # reasons (`:c_extension_only`, `:no_lib_root`) are
34
+ # introduced as the walker discovers them in slice 2b.
35
+ Unresolvable = Data.define(:gem_name, :reason)
36
+
37
+ VALID_REASONS = %i[not_in_bundle].freeze
38
+
39
+ module_function
40
+
41
+ # @param entry [Rigor::Configuration::Dependencies::Entry]
42
+ # @return [Resolved, Unresolvable]
43
+ def resolve(entry)
44
+ spec = locate_gem_spec(entry.gem)
45
+ return Unresolvable.new(gem_name: entry.gem, reason: :not_in_bundle) if spec.nil?
46
+
47
+ Resolved.new(
48
+ gem_name: entry.gem,
49
+ version: spec.version.to_s,
50
+ gem_dir: spec.full_gem_path, # rigor:disable undefined-method
51
+ mode: entry.mode,
52
+ roots: entry.roots
53
+ )
54
+ end
55
+
56
+ # Locator. `Gem.loaded_specs` reflects the bundle (cheap
57
+ # lookup, no filesystem walk); `find_by_name` is the
58
+ # broader fallback for gems present on the gem path but
59
+ # not yet `require`'d. `Gem::MissingSpecError` is a
60
+ # `LoadError` subclass, so the rescue covers both
61
+ # missing-spec and load-error signals.
62
+ def locate_gem_spec(name)
63
+ Gem.loaded_specs[name] || begin
64
+ Gem::Specification.find_by_name(name)
65
+ rescue LoadError
66
+ nil
67
+ end
68
+ end
69
+ end
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,110 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../cache/descriptor"
4
+
5
+ module Rigor
6
+ module Analysis
7
+ module DependencySourceInference
8
+ # Per-run collection of gem-source-inference state. Holds
9
+ # the resolved gems the walker MAY visit (slice 2b) plus
10
+ # the unresolvable entries the runner SHOULD surface as
11
+ # `dynamic.dependency-source.gem-not-found` diagnostics.
12
+ #
13
+ # Slice 2a lands the data structure only; the dispatcher
14
+ # tier consults {#contribution_for} but the lookup always
15
+ # answers `nil` until slice 2b populates the method table
16
+ # by walking the resolved gems' `roots:`.
17
+ class Index
18
+ attr_reader :resolved_gems, :unresolvable, :method_catalog, :budget_exceeded,
19
+ :class_to_gem, :budget_overrun_strategy
20
+
21
+ # @param method_catalog [Hash{[String, Symbol] => Symbol}]
22
+ # the flat `(class_name, method_name) → :instance | :singleton`
23
+ # table produced by {Walker.walk}, aggregated across
24
+ # every resolved gem in the run. The Index itself stays
25
+ # gem-agnostic — the per-gem attribution that slice 3's
26
+ # cache descriptor needs lives on `Resolved`, not here.
27
+ # @param budget_exceeded [Array<String>] gem names whose
28
+ # {Walker} run hit the per-gem catalog cap (slice 4).
29
+ # The Runner consumes this list to emit one
30
+ # `dynamic.dependency-source.budget-exceeded` warning
31
+ # per gem.
32
+ # @param class_to_gem [Hash<String, String>] reverse
33
+ # lookup `class_name → gem_name` (slice 5b). Built
34
+ # first-write-wins: when two opt-in gems re-open the
35
+ # same class, the first gem owns it. The dispatcher
36
+ # consults this map under the `:dependency_silence`
37
+ # budget overrun strategy so call sites on a
38
+ # budget-exceeded gem's classes degrade to
39
+ # `Dynamic[top]` instead of falling through to the
40
+ # user-class fallback.
41
+ def initialize( # rubocop:disable Metrics/ParameterLists
42
+ resolved_gems: [], unresolvable: [], method_catalog: {},
43
+ budget_exceeded: [], class_to_gem: {},
44
+ budget_overrun_strategy: :walker_cap
45
+ )
46
+ @resolved_gems = resolved_gems.freeze
47
+ @unresolvable = unresolvable.freeze
48
+ @method_catalog = method_catalog.freeze
49
+ @budget_exceeded = budget_exceeded.freeze
50
+ @class_to_gem = class_to_gem.freeze
51
+ @budget_overrun_strategy = budget_overrun_strategy
52
+ freeze
53
+ end
54
+
55
+ # @return [String, nil] the gem that owns `class_name`
56
+ # (first-write-wins); `nil` when the class isn't in
57
+ # any opt-in gem's catalog.
58
+ def gem_for(class_name)
59
+ @class_to_gem[class_name]
60
+ end
61
+
62
+ # Looks up the recorded method kind for a
63
+ # `(class_name, method_name)` pair. Returns `:instance`
64
+ # / `:singleton` when the walker observed a definition
65
+ # under one of the resolved gems' `roots:`, or `nil`
66
+ # otherwise. Slice 2b-ii enriches this with the inferred
67
+ # return type so the dispatcher tier can build a
68
+ # `Type::Dynamic` directly from the lookup result.
69
+ def contribution_for(class_name:, method_name:)
70
+ @method_catalog[[class_name, method_name]]
71
+ end
72
+
73
+ def empty?
74
+ @resolved_gems.empty?
75
+ end
76
+
77
+ # Builds a frozen `Cache::Descriptor` carrying one
78
+ # `DependencyEntry` row per resolved gem in this run.
79
+ # Cache producers that observe ADR-10 inference outputs
80
+ # compose this descriptor with their own (RBS, plugin,
81
+ # file-digest) descriptors so a `bundle update` on a
82
+ # listed gem invalidates exactly that gem's slice while
83
+ # leaving the rest of the cache hot.
84
+ #
85
+ # Unresolvable entries contribute nothing — there is no
86
+ # version to key on, and the runner already surfaces them
87
+ # as `dynamic.dependency-source.gem-not-found`
88
+ # diagnostics. Resolved-but-disabled entries are also
89
+ # absent: the {Builder} skips them before resolution, so
90
+ # they never reach the index.
91
+ def cache_descriptor
92
+ dependencies = @resolved_gems.map do |resolved|
93
+ Cache::Descriptor::DependencyEntry.new(
94
+ gem_name: resolved.gem_name,
95
+ gem_version: resolved.version,
96
+ mode: resolved.mode
97
+ )
98
+ end
99
+ Cache::Descriptor.new(dependencies: dependencies)
100
+ end
101
+ end
102
+
103
+ # Frozen empty index — the runner uses this when
104
+ # `Configuration#dependencies.source_inference` is empty
105
+ # so the dispatcher tier holds a stable, non-nil
106
+ # reference even on default configurations.
107
+ Index::EMPTY = Index.new.freeze
108
+ end
109
+ end
110
+ end
@@ -0,0 +1,200 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+
5
+ module Rigor
6
+ module Analysis
7
+ module DependencySourceInference
8
+ # Walks a resolved gem's `roots:` and collects the
9
+ # `(class_name, method_name) → :instance | :singleton`
10
+ # method catalog. The walker is the source of facts the
11
+ # dispatcher tier (slice 2b-ii) consults to recognise a
12
+ # method as defined by an opt-in gem and contribute a
13
+ # `Type::Dynamic` return at the call site.
14
+ #
15
+ # Slice 2b-i intentionally collects only the catalog, not
16
+ # the inferred return type. The dispatcher tier returns
17
+ # `Dynamic[top]` on a hit until slice 2b-ii wires return-
18
+ # type inference; the visible payoff today is removing the
19
+ # `call.undefined-method` diagnostic for opt-in gem methods
20
+ # at receivers Rigor knows by `Nominal[T]` (typically
21
+ # because the user authored an RBS skeleton).
22
+ #
23
+ # Hard exclusions are NOT user-configurable, per ADR-10
24
+ # § "Hard exclusions": top-level `spec/`, `test/`, `bin/`,
25
+ # plus any non-`.rb` source. C extensions fall out
26
+ # automatically because the walker only loads `.rb` files.
27
+ module Walker
28
+ # Top-level directories that MUST NOT participate in
29
+ # gem-source inference even when the user lists them
30
+ # under `roots:`. The check is case-insensitive against
31
+ # the first segment of `roots:`; nested `spec/` /
32
+ # `test/` directories deeper inside `lib/` are NOT
33
+ # filtered (a few gems legitimately ship `lib/.../spec/`).
34
+ HARD_EXCLUDED_ROOTS = %w[spec test bin].freeze
35
+
36
+ # Walker outcome wrapping the harvested method catalog
37
+ # plus a budget-exceeded flag. ADR-10 slice 4 introduces
38
+ # the cap; the Walker stops appending to the accumulator
39
+ # once `catalog.size` reaches `budget`, and `truncated?`
40
+ # reports whether the cap was reached. The Index records
41
+ # this per-gem so the Runner can surface a single
42
+ # `dynamic.dependency-source.budget-exceeded` warning
43
+ # naming the affected gem(s).
44
+ Outcome = Data.define(:catalog, :truncated) do
45
+ def truncated? = truncated
46
+ end
47
+
48
+ # Sentinel for "no cap" — used by callers that don't
49
+ # care about the budget (specs, tooling). Production
50
+ # code MUST pass an integer.
51
+ UNBOUNDED = Float::INFINITY
52
+
53
+ module_function
54
+
55
+ # @param gem_dir [String, Pathname] absolute path to the
56
+ # gem's installation directory.
57
+ # @param roots [Array<String>] subdirectory names within
58
+ # the gem to walk (defaults to `["lib"]` per
59
+ # `Configuration::Dependencies::Entry`).
60
+ # @param budget [Integer, Float] per-gem catalog cap
61
+ # (method-definition count). When unset, defaults to
62
+ # `UNBOUNDED` for backwards-compatible test paths.
63
+ # @return [Outcome] frozen wrapper carrying the catalog
64
+ # (`Hash{[class_name, method_name] => :instance |
65
+ # :singleton}`) and a `truncated?` flag set when the
66
+ # walker stopped harvesting because the budget was
67
+ # reached. Methods of identical name on the same class
68
+ # with different kinds (rare; private API mostly)
69
+ # carry the kind that wins the per-class first walk.
70
+ def walk(gem_dir:, roots:, budget: UNBOUNDED)
71
+ accumulator = {}
72
+ truncated = false
73
+ accepted_roots(roots).each do |root|
74
+ break if truncated
75
+
76
+ truncated = walk_root(File.join(gem_dir.to_s, root), accumulator, budget)
77
+ end
78
+ Outcome.new(catalog: accumulator.freeze, truncated: truncated)
79
+ end
80
+
81
+ # Drops hard-excluded entries before any filesystem
82
+ # walk happens. Reasoning: we never want a gem's
83
+ # `spec/` to participate even if the user requested
84
+ # it — the noise from RSpec-style globals plus the
85
+ # cost of walking test fixtures isn't worth the
86
+ # marginal coverage.
87
+ def accepted_roots(roots)
88
+ roots.reject { |root| HARD_EXCLUDED_ROOTS.include?(root.downcase) }
89
+ end
90
+
91
+ # Returns true when the budget tripped during this
92
+ # root's walk so the caller can stop iterating
93
+ # subsequent roots.
94
+ def walk_root(root_dir, accumulator, budget) # rubocop:disable Naming/PredicateMethod
95
+ return false unless File.directory?(root_dir)
96
+
97
+ Dir.glob(File.join(root_dir, "**", "*.rb")).each do |path|
98
+ harvest_file(path, accumulator, budget)
99
+ return true if accumulator.size >= budget
100
+ end
101
+ false
102
+ end
103
+
104
+ def harvest_file(path, accumulator, budget)
105
+ parse_result = Prism.parse_file(path)
106
+ return unless parse_result.errors.empty?
107
+
108
+ walk_node(parse_result.value, [], false, accumulator, budget)
109
+ rescue StandardError
110
+ # Gem source we can't parse / read silently degrades
111
+ # to "no contribution from this file". The user-facing
112
+ # diagnostic stream is reserved for the project source;
113
+ # opt-in gem source MUST NOT pollute it with parse
114
+ # errors the user cannot fix.
115
+ nil
116
+ end
117
+
118
+ # Walks a Prism subtree, accumulating method definitions
119
+ # under their qualified class name. Mirrors the shape of
120
+ # `Inference::ScopeIndexer#walk_methods` but stays
121
+ # decoupled from `Scope` because gem-source inference
122
+ # runs without a scope context.
123
+ def walk_node(node, qualified_prefix, in_singleton_class, accumulator, budget)
124
+ return unless node.is_a?(Prism::Node)
125
+ return if accumulator.size >= budget
126
+
127
+ case node
128
+ when Prism::ClassNode, Prism::ModuleNode
129
+ descend_class_or_module(node, qualified_prefix, in_singleton_class, accumulator, budget)
130
+ when Prism::SingletonClassNode
131
+ descend_singleton_class(node, qualified_prefix, accumulator, budget)
132
+ when Prism::DefNode
133
+ record_def_node(node, qualified_prefix, in_singleton_class, accumulator, budget)
134
+ else
135
+ walk_children(node, qualified_prefix, in_singleton_class, accumulator, budget)
136
+ end
137
+ end
138
+
139
+ def walk_children(node, qualified_prefix, in_singleton_class, accumulator, budget)
140
+ node.compact_child_nodes.each do |child|
141
+ break if accumulator.size >= budget
142
+
143
+ walk_node(child, qualified_prefix, in_singleton_class, accumulator, budget)
144
+ end
145
+ end
146
+
147
+ # `class Foo` / `module Bar`. The dynamic-prefix shape
148
+ # (`module ::Foo`-rooted variants whose left side is a
149
+ # runtime expression) is treated as opaque — we walk the
150
+ # children under the same prefix so any inner class
151
+ # definitions are still recorded under their own name.
152
+ def descend_class_or_module(node, qualified_prefix, in_singleton_class, accumulator, budget)
153
+ name = qualified_name_for(node.constant_path)
154
+ if name && node.body
155
+ walk_node(node.body, qualified_prefix + [name], in_singleton_class, accumulator, budget)
156
+ else
157
+ walk_children(node, qualified_prefix, in_singleton_class, accumulator, budget)
158
+ end
159
+ end
160
+
161
+ # `class << self` only — `class << expr` for any other
162
+ # `expr` is treated as opaque so we don't accidentally
163
+ # record per-instance singleton methods under the
164
+ # surrounding class.
165
+ def descend_singleton_class(node, qualified_prefix, accumulator, budget)
166
+ if node.expression.is_a?(Prism::SelfNode) && node.body
167
+ walk_node(node.body, qualified_prefix, true, accumulator, budget)
168
+ else
169
+ walk_children(node, qualified_prefix, false, accumulator, budget)
170
+ end
171
+ end
172
+
173
+ def record_def_node(node, qualified_prefix, in_singleton_class, accumulator, _budget)
174
+ return if qualified_prefix.empty?
175
+
176
+ class_name = qualified_prefix.join("::")
177
+ kind = node.receiver.is_a?(Prism::SelfNode) || in_singleton_class ? :singleton : :instance
178
+ accumulator[[class_name, node.name]] ||= kind
179
+ end
180
+
181
+ # Resolves a `Prism::ConstantPathNode` /
182
+ # `Prism::ConstantReadNode` chain to its dot-separated
183
+ # name (e.g. `"Foo::Bar"`). Returns nil for the rare
184
+ # dynamic-prefix shape (`module ::Foo`-rooted variants
185
+ # whose left side is a runtime expression) so the
186
+ # walker treats those as opaque rather than guessing.
187
+ def qualified_name_for(node)
188
+ case node
189
+ when Prism::ConstantReadNode then node.name.to_s
190
+ when Prism::ConstantPathNode
191
+ parent = node.parent.nil? ? nil : qualified_name_for(node.parent)
192
+ return nil if !node.parent.nil? && parent.nil?
193
+
194
+ parent.nil? ? node.name.to_s : "#{parent}::#{node.name}"
195
+ end
196
+ end
197
+ end
198
+ end
199
+ end
200
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "dependency_source_inference/gem_resolver"
4
+ require_relative "dependency_source_inference/index"
5
+ require_relative "dependency_source_inference/walker"
6
+ require_relative "dependency_source_inference/builder"
7
+
8
+ module Rigor
9
+ module Analysis
10
+ # Implementation of [ADR-10 — Opt-in dependency-source
11
+ # inference](../../../docs/adr/10-dependency-source-inference.md).
12
+ #
13
+ # The namespace coordinates three components:
14
+ #
15
+ # - {GemResolver} maps a
16
+ # `Configuration::Dependencies::Entry` to either a frozen
17
+ # `Resolved(gem_name, version, gem_dir, mode, roots)` or an
18
+ # `Unresolvable(gem_name, reason)` value.
19
+ # - {Builder.build} folds a `Configuration::Dependencies`
20
+ # into a frozen {Index} carrying the partitioned outcomes.
21
+ # - {Index} holds the per-run state the dispatcher tier
22
+ # consults via `#contribution_for`. Slice 2a ships the
23
+ # stub returning `nil`; slice 2b populates the method
24
+ # table by walking each resolved gem's `roots:`.
25
+ #
26
+ # Per the ADR's "Implementation slicing" section, slice 2 is
27
+ # split internally:
28
+ #
29
+ # - Slice 2a (this commit): gem resolution, index plumbing,
30
+ # `Analysis::Runner` wiring, `dynamic.dependency-source.gem-not-found`
31
+ # diagnostic for unresolvable entries.
32
+ # - Slice 2b (next commit): walker, dispatcher tier
33
+ # integration, `Type::Dynamic`-wrapped returns.
34
+ module DependencySourceInference
35
+ end
36
+ end
37
+ end
@@ -12,6 +12,7 @@ require_relative "../inference/coverage_scanner"
12
12
  require_relative "../inference/scope_indexer"
13
13
  require_relative "../inference/method_dispatcher/file_folding"
14
14
  require_relative "check_rules"
15
+ require_relative "dependency_source_inference"
15
16
  require_relative "diagnostic"
16
17
  require_relative "result"
17
18
 
@@ -21,7 +22,7 @@ module Rigor
21
22
  RUBY_GLOB = "**/*.rb"
22
23
  DEFAULT_CACHE_ROOT = ".rigor/cache"
23
24
 
24
- attr_reader :cache_store, :plugin_registry
25
+ attr_reader :cache_store, :plugin_registry, :dependency_source_index
25
26
 
26
27
  # @param configuration [Rigor::Configuration]
27
28
  # @param explain [Boolean] surface fail-soft fallback events
@@ -40,6 +41,7 @@ module Rigor
40
41
  @cache_store = cache_store
41
42
  @plugin_requirer = plugin_requirer
42
43
  @plugin_registry = Plugin::Registry::EMPTY
44
+ @dependency_source_index = DependencySourceInference::Index::EMPTY
43
45
  end
44
46
 
45
47
  # Walks every Ruby file under `paths`, parses it, builds a
@@ -58,22 +60,37 @@ module Rigor
58
60
  return Result.new(diagnostics: [target_ruby_error]) if target_ruby_error
59
61
 
60
62
  @plugin_registry = load_plugins
63
+ @dependency_source_index = DependencySourceInference::Builder.build(@configuration.dependencies)
61
64
  environment = Environment.for_project(
62
65
  libraries: @configuration.libraries,
63
66
  signature_paths: @configuration.signature_paths,
64
67
  cache_store: @cache_store,
65
- plugin_registry: @plugin_registry
68
+ plugin_registry: @plugin_registry,
69
+ dependency_source_index: @dependency_source_index
66
70
  )
67
71
  expansion = expand_paths(paths)
68
72
 
69
- diagnostics = plugin_load_diagnostics
70
- diagnostics += plugin_prepare_diagnostics
71
- diagnostics += expansion.fetch(:errors)
73
+ diagnostics = pre_file_diagnostics(expansion)
72
74
  diagnostics += expansion.fetch(:files).flat_map { |path| analyze_file(path, environment) }
73
75
 
74
76
  Result.new(diagnostics: apply_severity_profile(diagnostics))
75
77
  end
76
78
 
79
+ # Pre-file diagnostic streams that fire once per run rather
80
+ # than per analyzed file: plugin load / prepare envelopes,
81
+ # the ADR-10 dependency-source resolution surface, and the
82
+ # `expand_paths` errors for `paths:` entries that don't
83
+ # exist or aren't `.rb`. Aggregated here so `#run` stays
84
+ # under the ABC budget.
85
+ def pre_file_diagnostics(expansion)
86
+ plugin_load_diagnostics +
87
+ plugin_prepare_diagnostics +
88
+ dependency_source_diagnostics +
89
+ dependency_source_budget_diagnostics +
90
+ dependency_source_config_conflict_diagnostics +
91
+ expansion.fetch(:errors)
92
+ end
93
+
77
94
  # `target_ruby` flows through to Prism's `version:` option.
78
95
  # Prism enforces the supported range and raises
79
96
  # `ArgumentError` for versions it does not recognise. Run a
@@ -207,6 +224,78 @@ module Rigor
207
224
  end
208
225
  end
209
226
 
227
+ # ADR-10 § "Diagnostic prefix family" — surfaces gems
228
+ # listed in `dependencies.source_inference` that RubyGems
229
+ # could not resolve. The run continues; the gem simply
230
+ # contributes nothing this session, mirroring the
231
+ # plugin-load error envelope. Authored `:warning` because
232
+ # an unresolvable gem usually means a typo or a missing
233
+ # `bundle install` rather than a project-blocking problem;
234
+ # the severity profile still re-stamps it.
235
+ def dependency_source_diagnostics
236
+ @dependency_source_index.unresolvable.map do |entry|
237
+ Diagnostic.new(
238
+ path: ".rigor.yml",
239
+ line: 1,
240
+ column: 1,
241
+ message: "dependencies.source_inference[].gem #{entry.gem_name.inspect} could not be " \
242
+ "resolved (#{entry.reason}); skipping",
243
+ severity: :warning,
244
+ rule: "dynamic.dependency-source.gem-not-found",
245
+ source_family: :builtin
246
+ )
247
+ end
248
+ end
249
+
250
+ # ADR-10 § "Budget interaction" / slice 4 — emits one
251
+ # `:warning` per gem whose Walker run hit the
252
+ # `dependencies.budget_per_gem` cap. The cap is a Walker-
253
+ # side guard rail (slice 4 picks the (α) semantics from
254
+ # ADR-10 WD4: harvesting stops, the dispatcher behaves
255
+ # exactly as before for unrecorded methods). The
256
+ # diagnostic names the gem and points the user at the
257
+ # three remediations: ship RBS, reduce `mode:` from
258
+ # `full` to `when_missing`, or de-list the gem.
259
+ # ADR-10 § "config-conflict diagnostic" / 5d — surfaces
260
+ # `Configuration::Dependencies` warnings accumulated
261
+ # during `from_h` deduplication of the `includes:`-chain
262
+ # source_inference array. Each warning describes a
263
+ # per-gem mode conflict that the merge resolved
264
+ # right-wins; the user sees one diagnostic per conflict.
265
+ # `:warning` matches the user's "warn but don't block"
266
+ # preference per the design discussion.
267
+ def dependency_source_config_conflict_diagnostics
268
+ @configuration.dependencies.warnings.map do |message|
269
+ Diagnostic.new(
270
+ path: ".rigor.yml",
271
+ line: 1,
272
+ column: 1,
273
+ message: message,
274
+ severity: :warning,
275
+ rule: "dynamic.dependency-source.config-conflict",
276
+ source_family: :builtin
277
+ )
278
+ end
279
+ end
280
+
281
+ def dependency_source_budget_diagnostics
282
+ budget = @configuration.dependencies.budget_per_gem
283
+ @dependency_source_index.budget_exceeded.map do |gem_name|
284
+ Diagnostic.new(
285
+ path: ".rigor.yml",
286
+ line: 1,
287
+ column: 1,
288
+ message: "dependencies.source_inference[].gem #{gem_name.inspect} exceeded the per-gem " \
289
+ "catalog cap (#{budget} method definitions); the remaining methods fall back " \
290
+ "to the existing RBS-or-Dynamic[top] boundary. Ship RBS for the gem, set " \
291
+ "`mode: when_missing` instead of `full`, or de-list the gem.",
292
+ severity: :warning,
293
+ rule: "dynamic.dependency-source.budget-exceeded",
294
+ source_family: :builtin
295
+ )
296
+ end
297
+ end
298
+
210
299
  # ADR-9 slice 3 — invokes every loaded plugin's `#prepare`
211
300
  # hook once per run, after the loader's `#init` pass and
212
301
  # before per-file iteration. Plugins publish facts here