rigortype 0.0.9 → 0.1.0

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 (49) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +40 -2
  3. data/lib/rigor/analysis/check_rules.rb +228 -40
  4. data/lib/rigor/analysis/diagnostic.rb +15 -1
  5. data/lib/rigor/analysis/runner.rb +183 -4
  6. data/lib/rigor/cache/rbs_class_ancestor_table.rb +1 -1
  7. data/lib/rigor/cache/rbs_class_type_param_names.rb +1 -1
  8. data/lib/rigor/cache/rbs_constant_table.rb +2 -2
  9. data/lib/rigor/cache/rbs_descriptor.rb +2 -0
  10. data/lib/rigor/cache/rbs_instance_definitions.rb +79 -0
  11. data/lib/rigor/cache/store.rb +2 -0
  12. data/lib/rigor/cli.rb +9 -3
  13. data/lib/rigor/configuration/severity_profile.rb +109 -0
  14. data/lib/rigor/configuration.rb +110 -6
  15. data/lib/rigor/environment/rbs_loader.rb +89 -13
  16. data/lib/rigor/flow_contribution/conflict.rb +81 -0
  17. data/lib/rigor/flow_contribution/element.rb +53 -0
  18. data/lib/rigor/flow_contribution/fact.rb +88 -0
  19. data/lib/rigor/flow_contribution/merge_result.rb +67 -0
  20. data/lib/rigor/flow_contribution/merger.rb +275 -0
  21. data/lib/rigor/flow_contribution.rb +51 -0
  22. data/lib/rigor/inference/block_parameter_binder.rb +15 -0
  23. data/lib/rigor/inference/expression_typer.rb +84 -5
  24. data/lib/rigor/inference/method_dispatcher/literal_string_folding.rb +95 -9
  25. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +21 -1
  26. data/lib/rigor/inference/multi_target_binder.rb +2 -0
  27. data/lib/rigor/inference/narrowing.rb +105 -130
  28. data/lib/rigor/inference/scope_indexer.rb +75 -1
  29. data/lib/rigor/inference/statement_evaluator.rb +380 -40
  30. data/lib/rigor/plugin/access_denied_error.rb +24 -0
  31. data/lib/rigor/plugin/base.rb +241 -0
  32. data/lib/rigor/plugin/io_boundary.rb +102 -0
  33. data/lib/rigor/plugin/load_error.rb +23 -0
  34. data/lib/rigor/plugin/loader.rb +191 -0
  35. data/lib/rigor/plugin/manifest.rb +134 -0
  36. data/lib/rigor/plugin/registry.rb +50 -0
  37. data/lib/rigor/plugin/services.rb +65 -0
  38. data/lib/rigor/plugin/trust_policy.rb +99 -0
  39. data/lib/rigor/plugin.rb +61 -0
  40. data/lib/rigor/rbs_extended.rb +57 -9
  41. data/lib/rigor/reflection.rb +2 -2
  42. data/lib/rigor/version.rb +1 -1
  43. data/lib/rigor.rb +7 -0
  44. data/sig/rigor/environment.rbs +7 -1
  45. data/sig/rigor/inference.rbs +1 -0
  46. data/sig/rigor/rbs_extended.rbs +2 -0
  47. data/sig/rigor/scope.rbs +1 -0
  48. data/sig/rigor/type.rbs +7 -0
  49. metadata +18 -1
@@ -33,10 +33,10 @@ module Rigor
33
33
  loader.each_constant_decl do |name, entry|
34
34
  translated = Inference::RbsTypeTranslator.translate(entry.decl.type)
35
35
  table[name] = translated unless translated.is_a?(Type::Bot)
36
- rescue StandardError
36
+ rescue ::RBS::BaseError
37
37
  # Skip entries whose RBS type fails to translate; the cache
38
38
  # stays robust to a broken signature rather than corrupting
39
- # the whole table.
39
+ # the whole table. Analyzer-internal errors propagate.
40
40
  end
41
41
  table
42
42
  end
@@ -2,6 +2,8 @@
2
2
 
3
3
  require "digest"
4
4
 
5
+ require_relative "descriptor"
6
+
5
7
  module Rigor
6
8
  module Cache
7
9
  # Shared descriptor builder for cache producers that depend on the
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "rbs_descriptor"
4
+ require_relative "rbs_environment_marshal_patch"
5
+
6
+ module Rigor
7
+ module Cache
8
+ # Cache producer that materialises the full
9
+ # `Hash<String, RBS::Definition>` for instance-side class
10
+ # definitions in the RBS environment, in a single cache
11
+ # entry. Mirrors the {RbsConstantTable} layout.
12
+ #
13
+ # ADR-7 § "Slice 6-D" carry-over and dogfooding feedback:
14
+ # the earlier per-class cache layout (one entry per class,
15
+ # ~1300 files) made warm runs *slower* than `--no-cache`
16
+ # because each `instance_definition` call paid disk-open +
17
+ # `Marshal.load` overhead and the in-memory
18
+ # `RBS::DefinitionBuilder.build_instance` was actually fast
19
+ # given a cached `RBS::Environment`. The single-blob layout
20
+ # collapses that to one `Marshal.load` per process; warm runs
21
+ # now match `--no-cache` timing while preserving the
22
+ # cross-process invalidation story.
23
+ #
24
+ # Marshal-cleanness of `RBS::Definition` is enabled by the
25
+ # v0.0.9 C2 `RBS::Location` patch.
26
+ class RbsInstanceDefinitions
27
+ PRODUCER_ID = "rbs.instance_definitions"
28
+
29
+ # @param loader [Rigor::Environment::RbsLoader]
30
+ # @param store [Rigor::Cache::Store]
31
+ # @return [Hash{String => RBS::Definition}]
32
+ def self.fetch(loader:, store:)
33
+ descriptor = RbsDescriptor.build(loader)
34
+ store.fetch_or_compute(producer_id: PRODUCER_ID, params: {}, descriptor: descriptor) do
35
+ compute(loader)
36
+ end
37
+ end
38
+
39
+ def self.compute(loader)
40
+ table = {}
41
+ loader.each_known_class_name do |name|
42
+ definition = loader.uncached_instance_definition(name)
43
+ table[name] = definition if definition
44
+ end
45
+ table
46
+ end
47
+
48
+ private_class_method :compute
49
+ end
50
+
51
+ # Singleton-side equivalent of {RbsInstanceDefinitions}.
52
+ # Caches the full `Hash<String, RBS::Definition>` for the
53
+ # singleton class of every RBS-known class.
54
+ class RbsSingletonDefinitions
55
+ PRODUCER_ID = "rbs.singleton_definitions"
56
+
57
+ # @param loader [Rigor::Environment::RbsLoader]
58
+ # @param store [Rigor::Cache::Store]
59
+ # @return [Hash{String => RBS::Definition}]
60
+ def self.fetch(loader:, store:)
61
+ descriptor = RbsDescriptor.build(loader)
62
+ store.fetch_or_compute(producer_id: PRODUCER_ID, params: {}, descriptor: descriptor) do
63
+ compute(loader)
64
+ end
65
+ end
66
+
67
+ def self.compute(loader)
68
+ table = {}
69
+ loader.each_known_class_name do |name|
70
+ definition = loader.uncached_singleton_definition(name)
71
+ table[name] = definition if definition
72
+ end
73
+ table
74
+ end
75
+
76
+ private_class_method :compute
77
+ end
78
+ end
79
+ end
@@ -5,6 +5,8 @@ require "fileutils"
5
5
  require "json"
6
6
  require "securerandom"
7
7
 
8
+ require_relative "descriptor"
9
+
8
10
  module Rigor
9
11
  module Cache
10
12
  # Filesystem-backed cache store. Schema, layout, file format,
data/lib/rigor/cli.rb CHANGED
@@ -211,9 +211,15 @@ module Rigor
211
211
  # (no plugins are loaded today).
212
212
  # - disable: list of `rigor check` rule identifiers to
213
213
  # silence project-wide. The shipped rules are
214
- # undefined-method, wrong-arity,
215
- # argument-type-mismatch, possible-nil-receiver,
216
- # dump-type, assert-type. In-source
214
+ # call.undefined-method, call.wrong-arity,
215
+ # call.argument-type-mismatch,
216
+ # call.possible-nil-receiver, dump.type,
217
+ # assert.type-mismatch, flow.always-raises.
218
+ # A bare family token (`call`, `flow`,
219
+ # `assert`, `dump`, `def`) wildcards every
220
+ # rule under that prefix. Legacy unprefixed
221
+ # names (`undefined-method`, …) still
222
+ # resolve. In-source
217
223
  # `# rigor:disable <rule>` comments at the end
218
224
  # of an offending line silence per-line; use
219
225
  # `# rigor:disable all` to suppress every rule.
@@ -0,0 +1,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rigor
4
+ class Configuration
5
+ # ADR-8 § "Severity profile" — three named profiles tune the
6
+ # severity of every built-in `Analysis::CheckRules` rule for
7
+ # the run. Profiles are applied as a **final filter** on
8
+ # `Diagnostic#severity`: rules emit with their authored
9
+ # severity, then `Analysis::Runner` re-stamps the severity
10
+ # from the active profile before adding the diagnostic to
11
+ # the result.
12
+ #
13
+ # Three profiles:
14
+ #
15
+ # - `lenient`: Only proven (`:no`) diagnostics are errors;
16
+ # uncertain (`:maybe`) drop to `:warning`. Useful for
17
+ # incremental adoption on legacy code.
18
+ # - `balanced` (**default**): Current Rigor stance — most
19
+ # rules `:error`; `dump.type` `:info`; uncertain rules
20
+ # `:warning`.
21
+ # - `strict`: Every rule is `:error`. CI-friendly.
22
+ #
23
+ # The profile resolution order:
24
+ #
25
+ # 1. Profile-specific entry for the canonical rule id.
26
+ # 2. The diagnostic's own authored severity (the rule's
27
+ # default).
28
+ # 3. `:error` (catch-all so an unrecognised rule still emits
29
+ # visibly — the public-API drift spec catches the
30
+ # bookkeeping gap separately).
31
+ module SeverityProfile
32
+ VALID_PROFILES = %i[lenient balanced strict].freeze
33
+ VALID_SEVERITIES = %i[error warning info off].freeze
34
+
35
+ DEFAULT_PROFILE = :balanced
36
+
37
+ # Per-profile severity tables. Missing keys fall back to
38
+ # the diagnostic's authored severity (typically `:error`).
39
+ PROFILES = {
40
+ lenient: {
41
+ "call.undefined-method" => :error,
42
+ "call.wrong-arity" => :error,
43
+ "call.argument-type-mismatch" => :warning,
44
+ "call.possible-nil-receiver" => :warning,
45
+ "flow.always-raises" => :warning,
46
+ "assert.type-mismatch" => :error,
47
+ "dump.type" => :info,
48
+ "def.return-type-mismatch" => :warning
49
+ }.freeze,
50
+ balanced: {
51
+ "call.undefined-method" => :error,
52
+ "call.wrong-arity" => :error,
53
+ "call.argument-type-mismatch" => :error,
54
+ "call.possible-nil-receiver" => :error,
55
+ "flow.always-raises" => :error,
56
+ "assert.type-mismatch" => :error,
57
+ "dump.type" => :info,
58
+ "def.return-type-mismatch" => :warning
59
+ }.freeze,
60
+ strict: {
61
+ "call.undefined-method" => :error,
62
+ "call.wrong-arity" => :error,
63
+ "call.argument-type-mismatch" => :error,
64
+ "call.possible-nil-receiver" => :error,
65
+ "flow.always-raises" => :error,
66
+ "assert.type-mismatch" => :error,
67
+ "dump.type" => :error,
68
+ "def.return-type-mismatch" => :error
69
+ }.freeze
70
+ }.freeze
71
+
72
+ module_function
73
+
74
+ # Resolves the configured severity for a diagnostic given
75
+ # the active profile and any per-rule overrides.
76
+ #
77
+ # @param rule [String, nil] canonical rule id (`call.undefined-method`).
78
+ # @param authored_severity [Symbol] severity the rule emitted
79
+ # the diagnostic with (`:error`, `:warning`, `:info`).
80
+ # @param profile [Symbol] one of {VALID_PROFILES}; falls back
81
+ # to {DEFAULT_PROFILE} for unknown values.
82
+ # @param overrides [Hash{String => Symbol}] per-rule severity
83
+ # overrides from `.rigor.yml`'s `severity_overrides:` map.
84
+ # Keys are canonical rule ids; values are
85
+ # {VALID_SEVERITIES} symbols. Family-wildcard keys
86
+ # (`call`) match every rule under that prefix.
87
+ # @return [Symbol] the resolved severity. Returns `:off` to
88
+ # mean "drop the diagnostic entirely".
89
+ def resolve(rule:, authored_severity:, profile: DEFAULT_PROFILE, overrides: {})
90
+ return authored_severity if rule.nil?
91
+
92
+ override = overrides[rule] || family_override(rule, overrides)
93
+ return override.to_sym if override
94
+
95
+ profile_table = PROFILES[profile] || PROFILES.fetch(DEFAULT_PROFILE)
96
+ profile_table.fetch(rule, authored_severity)
97
+ end
98
+
99
+ def family_override(rule, overrides)
100
+ family = rule.split(".").first
101
+ return nil if family.nil?
102
+
103
+ overrides[family]
104
+ end
105
+
106
+ private_class_method :family_override
107
+ end
108
+ end
109
+ end
@@ -2,8 +2,10 @@
2
2
 
3
3
  require "yaml"
4
4
 
5
+ require_relative "configuration/severity_profile"
6
+
5
7
  module Rigor
6
- class Configuration
8
+ class Configuration # rubocop:disable Metrics/ClassLength
7
9
  DEFAULT_PATH = ".rigor.yml"
8
10
  DEFAULTS = {
9
11
  "target_ruby" => "4.0",
@@ -15,11 +17,19 @@ module Rigor
15
17
  "fold_platform_specific_paths" => false,
16
18
  "cache" => {
17
19
  "path" => ".rigor/cache"
18
- }
20
+ },
21
+ "plugins_io" => {
22
+ "network" => "disabled",
23
+ "allowed_paths" => []
24
+ },
25
+ "severity_profile" => "balanced",
26
+ "severity_overrides" => {}
19
27
  }.freeze
20
28
 
21
29
  attr_reader :target_ruby, :paths, :plugins, :cache_path, :disabled_rules,
22
- :libraries, :signature_paths, :fold_platform_specific_paths
30
+ :libraries, :signature_paths, :fold_platform_specific_paths,
31
+ :plugins_io_network, :plugins_io_allowed_paths,
32
+ :severity_profile, :severity_overrides
23
33
 
24
34
  def self.load(path = DEFAULT_PATH)
25
35
  data = if File.exist?(path)
@@ -31,12 +41,15 @@ module Rigor
31
41
  new(DEFAULTS.merge(data))
32
42
  end
33
43
 
34
- def initialize(data = DEFAULTS) # rubocop:disable Metrics/AbcSize
44
+ def initialize(data = DEFAULTS) # rubocop:disable Metrics/AbcSize,Metrics/CyclomaticComplexity
35
45
  cache = DEFAULTS.fetch("cache").merge(data.fetch("cache", {}))
46
+ plugins_io = DEFAULTS.fetch("plugins_io").merge(data.fetch("plugins_io", {}))
36
47
 
37
48
  @target_ruby = data.fetch("target_ruby", DEFAULTS.fetch("target_ruby")).to_s
38
49
  @paths = Array(data.fetch("paths", DEFAULTS.fetch("paths"))).map(&:to_s)
39
- @plugins = Array(data.fetch("plugins", DEFAULTS.fetch("plugins"))).map(&:to_s)
50
+ @plugins = Array(data.fetch("plugins", DEFAULTS.fetch("plugins"))).map do |entry|
51
+ coerce_plugin_entry(entry)
52
+ end.freeze
40
53
  @disabled_rules = Array(data.fetch("disable", DEFAULTS.fetch("disable"))).map(&:to_s).freeze
41
54
  @libraries = Array(data.fetch("libraries", DEFAULTS.fetch("libraries"))).map(&:to_s).freeze
42
55
  sig_paths = data.fetch("signature_paths", DEFAULTS.fetch("signature_paths"))
@@ -45,6 +58,14 @@ module Rigor
45
58
  "fold_platform_specific_paths", DEFAULTS.fetch("fold_platform_specific_paths")
46
59
  ) == true
47
60
  @cache_path = cache.fetch("path").to_s
61
+ @plugins_io_network = coerce_network_policy(plugins_io.fetch("network"))
62
+ @plugins_io_allowed_paths = Array(plugins_io.fetch("allowed_paths")).map(&:to_s).freeze
63
+ @severity_profile = coerce_severity_profile(
64
+ data.fetch("severity_profile", DEFAULTS.fetch("severity_profile"))
65
+ )
66
+ @severity_overrides = coerce_severity_overrides(
67
+ data.fetch("severity_overrides", DEFAULTS.fetch("severity_overrides"))
68
+ )
48
69
  end
49
70
 
50
71
  def to_h
@@ -58,8 +79,91 @@ module Rigor
58
79
  "fold_platform_specific_paths" => fold_platform_specific_paths,
59
80
  "cache" => {
60
81
  "path" => cache_path
61
- }
82
+ },
83
+ "plugins_io" => {
84
+ "network" => plugins_io_network.to_s,
85
+ "allowed_paths" => plugins_io_allowed_paths
86
+ },
87
+ "severity_profile" => severity_profile.to_s,
88
+ "severity_overrides" => severity_overrides.to_h { |k, v| [k, v.to_s] }
62
89
  }
63
90
  end
91
+
92
+ private
93
+
94
+ # Accepts either `"rigor-foo"` (gem-name shorthand) or
95
+ # `{ "gem" => "rigor-foo", "id" => "foo", "config" => {...} }`
96
+ # (full form). Returns the canonical hash form so the loader
97
+ # works against a single shape.
98
+ def coerce_plugin_entry(entry)
99
+ case entry
100
+ when String
101
+ entry.dup.freeze
102
+ when Hash
103
+ entry.to_h { |k, v| [k.to_s, v] }.freeze
104
+ else
105
+ raise ArgumentError,
106
+ "plugin configuration entry must be a String or Hash, got #{entry.inspect}"
107
+ end
108
+ end
109
+
110
+ # Slice 2 only accepts `:disabled` for the network policy. The
111
+ # YAML scalar may arrive as a String (`"disabled"`) or already
112
+ # as the Symbol; coerce to the canonical Symbol shape so the
113
+ # downstream `TrustPolicy` constructor stays strict.
114
+ #
115
+ # The accepted set is duplicated from
116
+ # {Rigor::Plugin::TrustPolicy::VALID_NETWORK_POLICIES} so
117
+ # `Configuration` does not require the plugin namespace at
118
+ # load time (Configuration is loaded before Plugin in
119
+ # `lib/rigor.rb`); the two stay in lockstep via spec.
120
+ VALID_NETWORK_POLICIES = %i[disabled].freeze
121
+ private_constant :VALID_NETWORK_POLICIES
122
+
123
+ def coerce_network_policy(value)
124
+ sym = value.to_sym
125
+ unless VALID_NETWORK_POLICIES.include?(sym)
126
+ raise ArgumentError,
127
+ "plugins_io.network must be one of #{VALID_NETWORK_POLICIES.inspect}, got #{value.inspect}"
128
+ end
129
+
130
+ sym
131
+ end
132
+
133
+ # ADR-8 § "Severity profile" — accepts the canonical Symbol
134
+ # form or its String spelling; rejects unknown profile names
135
+ # so typos fail loudly.
136
+ def coerce_severity_profile(value)
137
+ sym = value.to_sym
138
+ unless SeverityProfile::VALID_PROFILES.include?(sym)
139
+ raise ArgumentError,
140
+ "severity_profile must be one of " \
141
+ "#{SeverityProfile::VALID_PROFILES.inspect}, got #{value.inspect}"
142
+ end
143
+
144
+ sym
145
+ end
146
+
147
+ # ADR-8 § "Severity profile" — `severity_overrides:` is a
148
+ # `{ rule => severity }` map. Keys are canonical rule ids
149
+ # (`call.undefined-method`) or family wildcards (`call`).
150
+ # Values are {SeverityProfile::VALID_SEVERITIES} symbols
151
+ # (`:error` / `:warning` / `:info` / `:off`). Unknown
152
+ # severities raise; unknown rule ids are silently kept (the
153
+ # override is inert until the rule lands).
154
+ def coerce_severity_overrides(value)
155
+ raise ArgumentError, "severity_overrides must be a Hash, got #{value.inspect}" unless value.is_a?(Hash)
156
+
157
+ value.to_h do |k, v|
158
+ sym = v.to_sym
159
+ unless SeverityProfile::VALID_SEVERITIES.include?(sym)
160
+ raise ArgumentError,
161
+ "severity_overrides[#{k.inspect}] must be one of " \
162
+ "#{SeverityProfile::VALID_SEVERITIES.inspect}, got #{v.inspect}"
163
+ end
164
+
165
+ [k.to_s, sym]
166
+ end.freeze
167
+ end
64
168
  end
65
169
  end
@@ -125,19 +125,42 @@ module Rigor
125
125
 
126
126
  env.class_decls.each_key { |rbs_name| yield rbs_name.to_s }
127
127
  env.class_alias_decls.each_key { |rbs_name| yield rbs_name.to_s }
128
- rescue StandardError
129
- # fail-soft: a broken environment yields no names.
128
+ rescue ::RBS::BaseError
129
+ # fail-soft: a broken RBS environment yields no names.
130
+ # Analyzer-internal errors (NameError, NoMethodError,
131
+ # LoadError) are NOT swallowed — those are bugs and
132
+ # must surface so they don't hide silently the way the
133
+ # v0.0.9 cache `Cache::Descriptor` regression did.
130
134
  end
131
135
 
132
136
  # @return [RBS::Definition, nil] the resolved instance definition
133
137
  # for `class_name`, or nil when the class is unknown or its
134
138
  # definition cannot be built (RBS may raise on broken hierarchies;
135
139
  # we fail-soft and return nil so the caller can fall back).
140
+ #
141
+ # When `cache_store` is set, the loader fetches the per-class
142
+ # definition through {Cache::RbsInstanceDefinitions.fetch} so
143
+ # subsequent runs (and other loaders sharing the same Store)
144
+ # skip the `RBS::DefinitionBuilder.build_instance` step.
145
+ # In-memory `@instance_definition_cache` keeps the per-process
146
+ # short-circuit on top.
136
147
  def instance_definition(class_name)
137
148
  key = class_name.to_s
138
149
  return @instance_definition_cache[key] if @instance_definition_cache.key?(key)
139
150
 
140
- @instance_definition_cache[key] = build_instance_definition(class_name)
151
+ @instance_definition_cache[key] = if cache_store
152
+ cached_instance_definition(class_name)
153
+ else
154
+ build_instance_definition(class_name)
155
+ end
156
+ end
157
+
158
+ # Public uncached accessor used by the cache producer
159
+ # ({Rigor::Cache::RbsInstanceDefinitions}). Avoids the
160
+ # `private_method_called` round-trip a `loader.send(...)`
161
+ # callsite would require.
162
+ def uncached_instance_definition(class_name)
163
+ build_instance_definition(class_name)
141
164
  end
142
165
 
143
166
  # @return [RBS::Definition::Method, nil]
@@ -153,11 +176,25 @@ module Rigor
153
176
  # definition are the *class methods* of `class_name`, including
154
177
  # those inherited from `Class` and `Module` for class types.
155
178
  # Returns nil for unknown names and on RBS build errors (fail-soft).
179
+ #
180
+ # When `cache_store` is set, the loader fetches the per-class
181
+ # singleton definition through
182
+ # {Cache::RbsSingletonDefinitions.fetch}; the same caching
183
+ # discipline as {#instance_definition}.
156
184
  def singleton_definition(class_name)
157
185
  key = class_name.to_s
158
186
  return @singleton_definition_cache[key] if @singleton_definition_cache.key?(key)
159
187
 
160
- @singleton_definition_cache[key] = build_singleton_definition(class_name)
188
+ @singleton_definition_cache[key] = if cache_store
189
+ cached_singleton_definition(class_name)
190
+ else
191
+ build_singleton_definition(class_name)
192
+ end
193
+ end
194
+
195
+ # Public uncached accessor used by the cache producer.
196
+ def uncached_singleton_definition(class_name)
197
+ build_singleton_definition(class_name)
161
198
  end
162
199
 
163
200
  # @return [RBS::Definition::Method, nil] the class method on
@@ -214,7 +251,7 @@ module Rigor
214
251
  # should keep using {#constant_type} for point lookups.
215
252
  def constant_names
216
253
  env.constant_decls.keys.map(&:to_s)
217
- rescue StandardError
254
+ rescue ::RBS::BaseError
218
255
  []
219
256
  end
220
257
 
@@ -229,8 +266,8 @@ module Rigor
229
266
  env.constant_decls.each do |rbs_name, entry|
230
267
  yield rbs_name.to_s, entry
231
268
  end
232
- rescue StandardError
233
- # fail-soft: a broken environment yields no entries.
269
+ rescue ::RBS::BaseError
270
+ # fail-soft: a broken RBS environment yields no entries.
234
271
  end
235
272
 
236
273
  # Slice A constant-value lookup. Returns the translated
@@ -258,7 +295,7 @@ module Rigor
258
295
  else
259
296
  translate_constant_decl(rbs_name)
260
297
  end
261
- rescue StandardError
298
+ rescue ::RBS::BaseError
262
299
  nil
263
300
  end
264
301
 
@@ -290,7 +327,7 @@ module Rigor
290
327
  return false unless rbs_name
291
328
 
292
329
  known_class_names_set.include?(rbs_name.to_s)
293
- rescue StandardError
330
+ rescue ::RBS::BaseError
294
331
  false
295
332
  end
296
333
 
@@ -311,6 +348,45 @@ module Rigor
311
348
  Cache::RbsEnvironment.fetch(loader: self, store: cache_store)
312
349
  end
313
350
 
351
+ # Per-process Hash<String, RBS::Definition> for the instance
352
+ # side. Loaded once on first miss through the
353
+ # {Cache::RbsInstanceDefinitions} producer (single Marshal
354
+ # blob); subsequent calls are pure Hash lookups. Cold runs
355
+ # build every known class once and persist; warm runs (and
356
+ # other loaders sharing the same Store) skip the
357
+ # `RBS::DefinitionBuilder.build_instance` work entirely.
358
+ def cached_instance_definition(class_name)
359
+ instance_definitions_table[normalise_class_key(class_name)]
360
+ end
361
+
362
+ def instance_definitions_table
363
+ @state[:instance_definitions_table] ||= begin
364
+ require_relative "../cache/rbs_instance_definitions"
365
+ Cache::RbsInstanceDefinitions.fetch(loader: self, store: cache_store)
366
+ end
367
+ end
368
+
369
+ def cached_singleton_definition(class_name)
370
+ singleton_definitions_table[normalise_class_key(class_name)]
371
+ end
372
+
373
+ def singleton_definitions_table
374
+ @state[:singleton_definitions_table] ||= begin
375
+ require_relative "../cache/rbs_instance_definitions"
376
+ Cache::RbsSingletonDefinitions.fetch(loader: self, store: cache_store)
377
+ end
378
+ end
379
+
380
+ # The cache producers persist class names in
381
+ # `RBS::TypeName#to_s` form (top-level prefixed
382
+ # `"::Hash"`); plain-name lookups (`"Hash"`) normalise
383
+ # before the Hash query so callers stay agnostic to the
384
+ # prefix.
385
+ def normalise_class_key(class_name)
386
+ s = class_name.to_s
387
+ s.start_with?("::") ? s : "::#{s}"
388
+ end
389
+
314
390
  def builder
315
391
  @state[:builder] ||= RBS::DefinitionBuilder.new(env: env)
316
392
  end
@@ -325,7 +401,7 @@ module Rigor
325
401
  return nil unless env.class_decls.key?(rbs_name)
326
402
 
327
403
  builder.build_instance(rbs_name)
328
- rescue StandardError
404
+ rescue ::RBS::BaseError
329
405
  nil
330
406
  end
331
407
 
@@ -335,7 +411,7 @@ module Rigor
335
411
  return nil unless env.class_decls.key?(rbs_name)
336
412
 
337
413
  builder.build_singleton(rbs_name)
338
- rescue StandardError
414
+ rescue ::RBS::BaseError
339
415
  nil
340
416
  end
341
417
 
@@ -345,7 +421,7 @@ module Rigor
345
421
 
346
422
  s = "::#{s}" unless s.start_with?("::")
347
423
  RBS::TypeName.parse(s)
348
- rescue StandardError
424
+ rescue ::RBS::BaseError
349
425
  nil
350
426
  end
351
427
 
@@ -358,7 +434,7 @@ module Rigor
358
434
  # them under one map post-resolution. Aliases live in their
359
435
  # own table.
360
436
  env.class_decls.key?(rbs_name) || env.class_alias_decls.key?(rbs_name)
361
- rescue StandardError
437
+ rescue ::RBS::BaseError
362
438
  false
363
439
  end
364
440
  end
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rigor
4
+ class FlowContribution
5
+ # Records a contradiction between two or more flow contributions
6
+ # detected during {Merger.merge}. Carried on {MergeResult#conflicts}
7
+ # so the analyzer / formatter can surface a `:contribution_merge`
8
+ # diagnostic per ADR-2 § "Plugin Contribution Merging".
9
+ #
10
+ # ADR-2 § "Plugin Contribution Merging" rules out first-wins /
11
+ # last-wins behaviour: when contributions conflict, both sources
12
+ # are reported and the merger falls back to the nearest non-
13
+ # conflicting higher-tier (or default) value for the affected
14
+ # `(target, edge, kind)` slot. The conflict object is the
15
+ # carrier of that report.
16
+ #
17
+ # Slice-3 conflict reasons:
18
+ #
19
+ # - `:return_type_collapse` — two return-type contributions
20
+ # intersect to `bot`.
21
+ # - `:exceptional_disagreement` — two contributions assert
22
+ # incompatible non-`nil` exceptional effects.
23
+ # - `:lower_tier_contradiction` — a lower-tier contribution
24
+ # would weaken or contradict a higher-tier proof.
25
+ CONFLICT_VALID_REASONS = %i[
26
+ return_type_collapse
27
+ exceptional_disagreement
28
+ lower_tier_contradiction
29
+ ].freeze
30
+
31
+ Conflict = Data.define(:target, :edge, :kind, :reason, :provenances, :message) do
32
+ def initialize(target:, edge:, kind:, reason:, provenances:, message:) # rubocop:disable Metrics/ParameterLists
33
+ unless CONFLICT_VALID_REASONS.include?(reason)
34
+ raise ArgumentError,
35
+ "FlowContribution::Conflict reason must be one of " \
36
+ "#{CONFLICT_VALID_REASONS.inspect}, got #{reason.inspect}"
37
+ end
38
+
39
+ super(target: target, edge: edge, kind: kind, reason: reason,
40
+ provenances: provenances.dup.freeze, message: message.to_s.dup.freeze)
41
+ end
42
+
43
+ def to_h
44
+ {
45
+ "target" => target.to_s,
46
+ "edge" => edge.to_s,
47
+ "kind" => kind.to_s,
48
+ "reason" => reason.to_s,
49
+ "sources" => provenances.map { |p| p.respond_to?(:to_h) ? p.to_h : p.to_s },
50
+ "message" => message
51
+ }
52
+ end
53
+
54
+ # ADR-7 § "Slice 5-C" — converts the conflict into a
55
+ # `Rigor::Analysis::Diagnostic` for the run result.
56
+ # Carries `source_family: :contribution_merge` so the
57
+ # qualified-rule formatter (slice 5 formatter half,
58
+ # `ef730b2`) prefixes the rule id with
59
+ # `contribution_merge.` and the JSON output side-bands
60
+ # `source_family` + `rule` for plugin attribution.
61
+ #
62
+ # The `rule` identifier is the kebab-case form of the
63
+ # conflict reason (`return_type_collapse` →
64
+ # `return-type-collapse`, etc.) so the qualified rule
65
+ # reads `[contribution_merge.return-type-collapse]` in
66
+ # the standard text stream.
67
+ def to_diagnostic(path:, line:, column:, severity: :error)
68
+ require_relative "../analysis/diagnostic" unless defined?(Rigor::Analysis::Diagnostic)
69
+ Rigor::Analysis::Diagnostic.new(
70
+ path: path,
71
+ line: line,
72
+ column: column,
73
+ message: message,
74
+ severity: severity,
75
+ rule: reason.to_s.tr("_", "-"),
76
+ source_family: :contribution_merge
77
+ )
78
+ end
79
+ end
80
+ end
81
+ end