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 +4 -4
- data/README.md +10 -0
- data/lib/rigor/analysis/dependency_source_inference/builder.rb +87 -0
- data/lib/rigor/analysis/dependency_source_inference/gem_resolver.rb +72 -0
- data/lib/rigor/analysis/dependency_source_inference/index.rb +110 -0
- data/lib/rigor/analysis/dependency_source_inference/walker.rb +200 -0
- data/lib/rigor/analysis/dependency_source_inference.rb +37 -0
- data/lib/rigor/analysis/runner.rb +94 -5
- data/lib/rigor/cache/descriptor.rb +58 -5
- data/lib/rigor/configuration/dependencies.rb +235 -0
- data/lib/rigor/configuration.rb +43 -9
- data/lib/rigor/environment.rb +16 -4
- data/lib/rigor/flow_contribution/merger.rb +4 -0
- data/lib/rigor/inference/method_dispatcher.rb +87 -0
- data/lib/rigor/inference/statement_evaluator.rb +65 -1
- data/lib/rigor/plugin/manifest.rb +26 -5
- data/lib/rigor/version.rb +1 -1
- data/sig/rigor/environment.rbs +3 -2
- metadata +7 -1
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
data/lib/rigor/configuration.rb
CHANGED
|
@@ -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] =
|
|
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
|
-
|
|
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
|
|
data/lib/rigor/environment.rb
CHANGED
|
@@ -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
|
-
|
|
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,
|
|
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(
|
|
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
|
|
@@ -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
|