rigortype 0.0.8 → 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 (73) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +234 -22
  3. data/data/builtins/ruby_core/encoding.yml +210 -0
  4. data/data/builtins/ruby_core/exception.yml +641 -0
  5. data/data/builtins/ruby_core/numeric.yml +3 -2
  6. data/data/builtins/ruby_core/proc.yml +731 -0
  7. data/data/builtins/ruby_core/random.yml +166 -0
  8. data/data/builtins/ruby_core/re.yml +689 -0
  9. data/data/builtins/ruby_core/struct.yml +449 -0
  10. data/lib/rigor/analysis/check_rules.rb +228 -40
  11. data/lib/rigor/analysis/diagnostic.rb +15 -1
  12. data/lib/rigor/analysis/runner.rb +199 -4
  13. data/lib/rigor/builtins/imported_refinements.rb +6 -1
  14. data/lib/rigor/cache/rbs_class_ancestor_table.rb +63 -0
  15. data/lib/rigor/cache/rbs_class_type_param_names.rb +60 -0
  16. data/lib/rigor/cache/rbs_constant_table.rb +15 -51
  17. data/lib/rigor/cache/rbs_descriptor.rb +55 -0
  18. data/lib/rigor/cache/rbs_environment.rb +52 -0
  19. data/lib/rigor/cache/rbs_environment_marshal_patch.rb +40 -0
  20. data/lib/rigor/cache/rbs_instance_definitions.rb +79 -0
  21. data/lib/rigor/cache/rbs_known_class_names.rb +43 -0
  22. data/lib/rigor/cache/store.rb +81 -15
  23. data/lib/rigor/cli.rb +45 -7
  24. data/lib/rigor/configuration/severity_profile.rb +109 -0
  25. data/lib/rigor/configuration.rb +110 -6
  26. data/lib/rigor/environment/rbs_hierarchy.rb +18 -5
  27. data/lib/rigor/environment/rbs_loader.rb +220 -32
  28. data/lib/rigor/environment.rb +11 -2
  29. data/lib/rigor/flow_contribution/conflict.rb +81 -0
  30. data/lib/rigor/flow_contribution/element.rb +53 -0
  31. data/lib/rigor/flow_contribution/fact.rb +88 -0
  32. data/lib/rigor/flow_contribution/merge_result.rb +67 -0
  33. data/lib/rigor/flow_contribution/merger.rb +275 -0
  34. data/lib/rigor/flow_contribution.rb +179 -0
  35. data/lib/rigor/inference/block_parameter_binder.rb +15 -0
  36. data/lib/rigor/inference/builtins/encoding_catalog.rb +67 -0
  37. data/lib/rigor/inference/builtins/exception_catalog.rb +92 -0
  38. data/lib/rigor/inference/builtins/proc_catalog.rb +122 -0
  39. data/lib/rigor/inference/builtins/random_catalog.rb +58 -0
  40. data/lib/rigor/inference/builtins/re_catalog.rb +81 -0
  41. data/lib/rigor/inference/builtins/struct_catalog.rb +55 -0
  42. data/lib/rigor/inference/expression_typer.rb +110 -6
  43. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +16 -1
  44. data/lib/rigor/inference/method_dispatcher/literal_string_folding.rb +173 -0
  45. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +21 -1
  46. data/lib/rigor/inference/method_dispatcher.rb +2 -0
  47. data/lib/rigor/inference/multi_target_binder.rb +2 -0
  48. data/lib/rigor/inference/narrowing.rb +134 -144
  49. data/lib/rigor/inference/scope_indexer.rb +75 -1
  50. data/lib/rigor/inference/statement_evaluator.rb +380 -40
  51. data/lib/rigor/plugin/access_denied_error.rb +24 -0
  52. data/lib/rigor/plugin/base.rb +241 -0
  53. data/lib/rigor/plugin/io_boundary.rb +102 -0
  54. data/lib/rigor/plugin/load_error.rb +23 -0
  55. data/lib/rigor/plugin/loader.rb +191 -0
  56. data/lib/rigor/plugin/manifest.rb +134 -0
  57. data/lib/rigor/plugin/registry.rb +50 -0
  58. data/lib/rigor/plugin/services.rb +65 -0
  59. data/lib/rigor/plugin/trust_policy.rb +99 -0
  60. data/lib/rigor/plugin.rb +61 -0
  61. data/lib/rigor/rbs_extended.rb +103 -0
  62. data/lib/rigor/reflection.rb +2 -2
  63. data/lib/rigor/type/combinator.rb +72 -0
  64. data/lib/rigor/type/refined.rb +50 -2
  65. data/lib/rigor/version.rb +1 -1
  66. data/lib/rigor.rb +13 -0
  67. data/sig/rigor/environment.rbs +7 -1
  68. data/sig/rigor/inference.rbs +1 -0
  69. data/sig/rigor/rbs_extended.rbs +2 -0
  70. data/sig/rigor/scope.rbs +1 -0
  71. data/sig/rigor/type.rbs +7 -0
  72. data/sig/rigor.rbs +3 -1
  73. metadata +38 -1
@@ -42,9 +42,29 @@ module Rigor
42
42
  def reset_default!
43
43
  @default = nil
44
44
  end
45
+
46
+ # Builds an `RBS::Environment` from explicit `libraries` and
47
+ # `signature_paths`. Stateless surface so the v0.0.9
48
+ # {Cache::RbsEnvironment} producer can build an env on cache
49
+ # miss without holding a loader instance, and the
50
+ # instance-side {#build_env} delegates here so the
51
+ # implementation stays single-rooted.
52
+ def build_env_for(libraries:, signature_paths:)
53
+ rbs_loader = RBS::EnvironmentLoader.new
54
+ libraries.each do |library|
55
+ next unless rbs_loader.has_library?(library: library, version: nil)
56
+
57
+ rbs_loader.add(library: library, version: nil)
58
+ end
59
+ signature_paths.each do |path|
60
+ path = Pathname(path) unless path.is_a?(Pathname)
61
+ rbs_loader.add(path: path) if path.directory?
62
+ end
63
+ RBS::Environment.from_loader(rbs_loader).resolve_type_names
64
+ end
45
65
  end
46
66
 
47
- attr_reader :libraries, :signature_paths
67
+ attr_reader :libraries, :signature_paths, :cache_store
48
68
 
49
69
  # @param libraries [Array<String, Symbol>] stdlib library names to
50
70
  # load on top of core (e.g., `["pathname", "json"]`). Empty by
@@ -57,9 +77,16 @@ module Rigor
57
77
  # `sig/` tree). Non-existent or non-directory paths are filtered
58
78
  # out at build time so the loader stays robust to fixtures and
59
79
  # bare repositories.
60
- def initialize(libraries: [], signature_paths: [])
80
+ # @param cache_store [Rigor::Cache::Store, nil] the persistent
81
+ # cache the loader consults for translated constant lookups
82
+ # (and, in later v0.0.9 slices, other Marshal-clean
83
+ # reflection artefacts). Pass `nil` (the default) to skip
84
+ # the cache entirely; the runner threads its own Store
85
+ # through here when caching is enabled.
86
+ def initialize(libraries: [], signature_paths: [], cache_store: nil)
61
87
  @libraries = libraries.map(&:to_s).freeze
62
88
  @signature_paths = signature_paths.map { |p| Pathname(p) }.freeze
89
+ @cache_store = cache_store
63
90
  @state = { env: nil, builder: nil }
64
91
  @instance_definition_cache = {}
65
92
  @singleton_definition_cache = {}
@@ -71,22 +98,69 @@ module Rigor
71
98
  # name is loaded. Accepts unprefixed or top-level-prefixed names
72
99
  # ("Integer" or "::Integer"). Memoized per-name (positive and
73
100
  # negative results both cache).
101
+ #
102
+ # When `cache_store` is set, the loader fetches the entire set of
103
+ # known class / module / alias names once (per process) through
104
+ # {Cache::RbsKnownClassNames.fetch} and answers `class_known?`
105
+ # from the in-memory Set. Cold runs pay a single env walk and
106
+ # persist the result; warm runs (and a separate loader sharing
107
+ # the same Store) skip the env walk entirely.
74
108
  def class_known?(name)
75
109
  key = name.to_s
76
110
  return @class_known_cache[key] if @class_known_cache.key?(key)
77
111
 
78
- @class_known_cache[key] = compute_class_known(name)
112
+ @class_known_cache[key] = if cache_store
113
+ cached_class_known(name)
114
+ else
115
+ compute_class_known(name)
116
+ end
117
+ end
118
+
119
+ # Yields every known class / module / alias name (top-level
120
+ # prefixed) currently loaded into the environment. The cache
121
+ # producer that materialises the known-name set uses this so
122
+ # it never recurses back through {#class_known?}.
123
+ def each_known_class_name
124
+ return enum_for(:each_known_class_name) unless block_given?
125
+
126
+ env.class_decls.each_key { |rbs_name| yield rbs_name.to_s }
127
+ env.class_alias_decls.each_key { |rbs_name| yield rbs_name.to_s }
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.
79
134
  end
80
135
 
81
136
  # @return [RBS::Definition, nil] the resolved instance definition
82
137
  # for `class_name`, or nil when the class is unknown or its
83
138
  # definition cannot be built (RBS may raise on broken hierarchies;
84
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.
85
147
  def instance_definition(class_name)
86
148
  key = class_name.to_s
87
149
  return @instance_definition_cache[key] if @instance_definition_cache.key?(key)
88
150
 
89
- @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)
90
164
  end
91
165
 
92
166
  # @return [RBS::Definition::Method, nil]
@@ -102,11 +176,25 @@ module Rigor
102
176
  # definition are the *class methods* of `class_name`, including
103
177
  # those inherited from `Class` and `Module` for class types.
104
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}.
105
184
  def singleton_definition(class_name)
106
185
  key = class_name.to_s
107
186
  return @singleton_definition_cache[key] if @singleton_definition_cache.key?(key)
108
187
 
109
- @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)
110
198
  end
111
199
 
112
200
  # @return [RBS::Definition::Method, nil] the class method on
@@ -133,7 +221,19 @@ module Rigor
133
221
  # names (the loader stays fail-soft). NOTE: in the `rbs` gem,
134
222
  # `RBS::Definition#type_params` returns `Array<Symbol>` directly,
135
223
  # not the AST `TypeParam` object (those live on the AST level).
224
+ #
225
+ # When `cache_store` is set, the loader fetches the entire
226
+ # type-parameter-name table once (per process) through
227
+ # {Cache::RbsClassTypeParamNames.fetch} and answers point
228
+ # lookups from it. Cold runs build the table once and persist
229
+ # it; warm runs (and a separate loader sharing the same Store)
230
+ # skip the env walk entirely.
136
231
  def class_type_param_names(class_name)
232
+ if cache_store
233
+ key = class_name.to_s.delete_prefix("::")
234
+ return type_param_names_table.fetch(key, []).dup
235
+ end
236
+
137
237
  definition = instance_definition(class_name)
138
238
  return [] unless definition
139
239
 
@@ -151,10 +251,25 @@ module Rigor
151
251
  # should keep using {#constant_type} for point lookups.
152
252
  def constant_names
153
253
  env.constant_decls.keys.map(&:to_s)
154
- rescue StandardError
254
+ rescue ::RBS::BaseError
155
255
  []
156
256
  end
157
257
 
258
+ # Yields `(name, entry)` for every RBS constant declaration
259
+ # currently loaded into the environment. The cache producer
260
+ # uses this to materialise the constant-type table without
261
+ # going back through {#constant_type} (which would recurse
262
+ # back into the cache when `cache_store` is set).
263
+ def each_constant_decl
264
+ return enum_for(:each_constant_decl) unless block_given?
265
+
266
+ env.constant_decls.each do |rbs_name, entry|
267
+ yield rbs_name.to_s, entry
268
+ end
269
+ rescue ::RBS::BaseError
270
+ # fail-soft: a broken RBS environment yields no entries.
271
+ end
272
+
158
273
  # Slice A constant-value lookup. Returns the translated
159
274
  # `Rigor::Type` for a non-class constant declaration
160
275
  # (`BUCKETS: Array[Symbol]`, `DEFAULT_PATH: String`, ...) or
@@ -164,23 +279,112 @@ module Rigor
164
279
  # nil; the loader does NOT consult the class declarations
165
280
  # here — class objects are still resolved through
166
281
  # {#class_known?} and `Environment#singleton_for_name`.
282
+ #
283
+ # When `cache_store` is set, the loader fetches the entire
284
+ # translated constant table once (per process) through
285
+ # {Cache::RbsConstantTable.fetch} and answers point lookups
286
+ # from it. Cold runs pay the translation cost up-front and
287
+ # write the result to disk; warm runs skip the translation
288
+ # entirely and pay only a `Marshal.load` of the table.
167
289
  def constant_type(name)
168
290
  rbs_name = parse_type_name(name)
169
291
  return nil unless rbs_name
170
292
 
293
+ if cache_store
294
+ constant_type_table[rbs_name.to_s]
295
+ else
296
+ translate_constant_decl(rbs_name)
297
+ end
298
+ rescue ::RBS::BaseError
299
+ nil
300
+ end
301
+
302
+ private
303
+
304
+ def constant_type_table
305
+ @constant_type_table ||= begin
306
+ require_relative "../cache/rbs_constant_table"
307
+ Cache::RbsConstantTable.fetch(loader: self, store: cache_store)
308
+ end
309
+ end
310
+
311
+ def known_class_names_set
312
+ @known_class_names_set ||= begin
313
+ require_relative "../cache/rbs_known_class_names"
314
+ Cache::RbsKnownClassNames.fetch(loader: self, store: cache_store)
315
+ end
316
+ end
317
+
318
+ def type_param_names_table
319
+ @type_param_names_table ||= begin
320
+ require_relative "../cache/rbs_class_type_param_names"
321
+ Cache::RbsClassTypeParamNames.fetch(loader: self, store: cache_store)
322
+ end
323
+ end
324
+
325
+ def cached_class_known(name)
326
+ rbs_name = parse_type_name(name)
327
+ return false unless rbs_name
328
+
329
+ known_class_names_set.include?(rbs_name.to_s)
330
+ rescue ::RBS::BaseError
331
+ false
332
+ end
333
+
334
+ def translate_constant_decl(rbs_name)
171
335
  entry = env.constant_decls[rbs_name]
172
336
  return nil unless entry
173
337
 
174
338
  translated = Inference::RbsTypeTranslator.translate(entry.decl.type)
175
339
  translated unless translated.is_a?(Type::Bot)
176
- rescue StandardError
177
- nil
178
340
  end
179
341
 
180
- private
181
-
182
342
  def env
183
- @state[:env] ||= build_env
343
+ @state[:env] ||= cache_store ? cached_env : build_env
344
+ end
345
+
346
+ def cached_env
347
+ require_relative "../cache/rbs_environment"
348
+ Cache::RbsEnvironment.fetch(loader: self, store: cache_store)
349
+ end
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}"
184
388
  end
185
389
 
186
390
  def builder
@@ -188,23 +392,7 @@ module Rigor
188
392
  end
189
393
 
190
394
  def build_env
191
- rbs_loader = RBS::EnvironmentLoader.new
192
- @libraries.each do |library|
193
- # Phase 2a deliberately fails-soft on unknown stdlib libraries
194
- # so a stale `.rigor.yml` (or future config plumbing) does not
195
- # take down the whole analyzer. Phase 2b will surface this
196
- # through diagnostics once the configuration layer can name
197
- # the offending source. The unknown-library check happens at
198
- # `from_loader` time, not at `add` time, so we have to gate
199
- # ahead of `add`.
200
- next unless rbs_loader.has_library?(library: library, version: nil)
201
-
202
- rbs_loader.add(library: library, version: nil)
203
- end
204
- @signature_paths.each do |path|
205
- rbs_loader.add(path: path) if path.directory?
206
- end
207
- RBS::Environment.from_loader(rbs_loader).resolve_type_names
395
+ self.class.build_env_for(libraries: @libraries, signature_paths: @signature_paths)
208
396
  end
209
397
 
210
398
  def build_instance_definition(class_name)
@@ -213,7 +401,7 @@ module Rigor
213
401
  return nil unless env.class_decls.key?(rbs_name)
214
402
 
215
403
  builder.build_instance(rbs_name)
216
- rescue StandardError
404
+ rescue ::RBS::BaseError
217
405
  nil
218
406
  end
219
407
 
@@ -223,7 +411,7 @@ module Rigor
223
411
  return nil unless env.class_decls.key?(rbs_name)
224
412
 
225
413
  builder.build_singleton(rbs_name)
226
- rescue StandardError
414
+ rescue ::RBS::BaseError
227
415
  nil
228
416
  end
229
417
 
@@ -233,7 +421,7 @@ module Rigor
233
421
 
234
422
  s = "::#{s}" unless s.start_with?("::")
235
423
  RBS::TypeName.parse(s)
236
- rescue StandardError
424
+ rescue ::RBS::BaseError
237
425
  nil
238
426
  end
239
427
 
@@ -246,7 +434,7 @@ module Rigor
246
434
  # them under one map post-resolution. Aliases live in their
247
435
  # own table.
248
436
  env.class_decls.key?(rbs_name) || env.class_alias_decls.key?(rbs_name)
249
- rescue StandardError
437
+ rescue ::RBS::BaseError
250
438
  false
251
439
  end
252
440
  end
@@ -76,11 +76,20 @@ module Rigor
76
76
  # list of `sig/`-style directories. When `nil` (the default),
77
77
  # the canonical project layout `<root>/sig` is used if it
78
78
  # exists, otherwise no signature path is loaded.
79
+ # @param cache_store [Rigor::Cache::Store, nil] persistent cache
80
+ # threaded into the underlying {Environment::RbsLoader} so
81
+ # constant lookups (and, in later v0.0.9 slices, other
82
+ # reflection artefacts) consult the cache. Pass `nil` (the
83
+ # default) to skip caching for this environment.
79
84
  # @return [Rigor::Environment]
80
- def for_project(root: Dir.pwd, libraries: [], signature_paths: nil)
85
+ def for_project(root: Dir.pwd, libraries: [], signature_paths: nil, cache_store: nil)
81
86
  resolved_paths = signature_paths || default_signature_paths(root)
82
87
  merged_libraries = (DEFAULT_LIBRARIES + libraries.map(&:to_s)).uniq
83
- loader = RbsLoader.new(libraries: merged_libraries, signature_paths: resolved_paths)
88
+ loader = RbsLoader.new(
89
+ libraries: merged_libraries,
90
+ signature_paths: resolved_paths,
91
+ cache_store: cache_store
92
+ )
84
93
  new(rbs_loader: loader)
85
94
  end
86
95
 
@@ -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
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rigor
4
+ class FlowContribution
5
+ # Tagged element flattening of a {FlowContribution} bundle —
6
+ # the analyzer-internal representation [ADR-2 § "Flow
7
+ # Contribution Bundle"](../../../docs/adr/2-extension-api.md)
8
+ # routes through the {Merger}.
9
+ #
10
+ # The flattening is **mechanical, deterministic, and round-
11
+ # trippable** with the bundle: every non-empty slot expands
12
+ # into one or more elements keyed by `(target, edge, kind)`,
13
+ # and an array of elements rebuilds an equivalent bundle when
14
+ # routed through `Merger.merge`.
15
+ #
16
+ # Plugin authors should not depend on the element shape — the
17
+ # bundle is the public contract; the element list is the
18
+ # implementation surface the merge policy operates over.
19
+ ELEMENT_VALID_EDGES = %i[normal truthy falsey post_return exceptional].freeze
20
+ ELEMENT_VALID_KINDS = %i[
21
+ return_type
22
+ truthy_fact
23
+ falsey_fact
24
+ post_return_fact
25
+ mutation
26
+ invalidation
27
+ exception
28
+ role
29
+ ].freeze
30
+
31
+ Element = Data.define(:target, :edge, :kind, :payload, :provenance) do
32
+ def initialize(target:, edge:, kind:, payload:, provenance:)
33
+ unless ELEMENT_VALID_EDGES.include?(edge)
34
+ raise ArgumentError,
35
+ "FlowContribution::Element edge must be one of " \
36
+ "#{ELEMENT_VALID_EDGES.inspect}, got #{edge.inspect}"
37
+ end
38
+
39
+ unless ELEMENT_VALID_KINDS.include?(kind)
40
+ raise ArgumentError,
41
+ "FlowContribution::Element kind must be one of " \
42
+ "#{ELEMENT_VALID_KINDS.inspect}, got #{kind.inspect}"
43
+ end
44
+
45
+ super
46
+ end
47
+
48
+ def merge_key
49
+ [target, edge, kind]
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rigor
4
+ class FlowContribution
5
+ # Canonical slot payload for the four edge-aware fact slots
6
+ # (`truthy_facts`, `falsey_facts`, `post_return_facts`, plus
7
+ # the equivalent under `mutations` / `invalidations` /
8
+ # `role_conformance` once those carriers grow Fact-shaped
9
+ # variants).
10
+ #
11
+ # ADR-7 § "Slice 4-A" pins this object as the **single
12
+ # canonical translation target** for the four parallel
13
+ # contribution carriers the engine has carried so far:
14
+ #
15
+ # 1. Built-in narrowing rules' direct fact emission
16
+ # (Inference::Narrowing#predicate_scopes).
17
+ # 2. RBS::Extended `predicate-if-*` directives
18
+ # (`Rigor::RbsExtended::PredicateEffect`).
19
+ # 3. RBS::Extended `assert*` directives
20
+ # (`Rigor::RbsExtended::AssertEffect`).
21
+ # 4. Future plugin contributions (slice 5 emission protocol).
22
+ #
23
+ # Each of those four carriers translates to / from Fact at
24
+ # its boundary; downstream of {Rigor::FlowContribution#to_element_list}
25
+ # and {Rigor::FlowContribution::Merger.merge}, every slot
26
+ # payload is a Fact (or a value that the merger compares by
27
+ # equality and never inspects). The typed `RbsExtended::*Effect`
28
+ # carriers stay internal to the parser side — they hold the
29
+ # source-text shape, but lose their identity at the
30
+ # `read_flow_contribution` boundary.
31
+ #
32
+ # ## Field set
33
+ #
34
+ # - `target_kind`: `:parameter` (call-site argument) or
35
+ # `:self` (receiver). Future slices may extend the set
36
+ # (`:local`, `:ivar`, `:result`); the merger is agnostic
37
+ # to the concrete kinds and only requires equality.
38
+ # - `target_name`: a `Symbol`. For `:parameter` it's the
39
+ # declared parameter name. For `:self` it is the literal
40
+ # `:self` symbol so the field stays non-nil and the merge
41
+ # key is well-defined.
42
+ # - `type`: a `Rigor::Type::*` (Nominal, Refined,
43
+ # IntegerRange, Difference, …) the fact narrows the
44
+ # target toward (when `negative` is false) or away from
45
+ # (when `negative` is true).
46
+ # - `negative`: `true` for the `~T` negation form
47
+ # (`predicate-if-true x is ~Integer`), `false` for the
48
+ # plain positive form. Mirrors the `negative` field on
49
+ # `PredicateEffect` / `AssertEffect`.
50
+ #
51
+ # The `target` accessor returns `:self` for self-targeted
52
+ # facts and `[:parameter, name]` otherwise — that's the
53
+ # value {Element#target} keys on, so two facts that narrow
54
+ # the same parameter from different contribution sources
55
+ # land in the same merge bucket.
56
+ FACT_VALID_TARGET_KINDS = %i[parameter self].freeze
57
+
58
+ Fact = Data.define(:target_kind, :target_name, :type, :negative) do
59
+ def initialize(target_kind:, target_name:, type:, negative: false)
60
+ unless FACT_VALID_TARGET_KINDS.include?(target_kind)
61
+ raise ArgumentError,
62
+ "FlowContribution::Fact target_kind must be one of " \
63
+ "#{FACT_VALID_TARGET_KINDS.inspect}, got #{target_kind.inspect}"
64
+ end
65
+
66
+ unless target_name.is_a?(Symbol)
67
+ raise ArgumentError,
68
+ "FlowContribution::Fact target_name must be a Symbol, got #{target_name.inspect}"
69
+ end
70
+
71
+ super
72
+ end
73
+
74
+ # Composite target identifier the merger keys on. `:self`
75
+ # for self-targeted facts; otherwise `[:parameter, name]`
76
+ # so two contributions that narrow the same parameter
77
+ # (regardless of source family) land in the same merge
78
+ # bucket.
79
+ def target
80
+ target_kind == :self ? :self : [target_kind, target_name]
81
+ end
82
+
83
+ def negative?
84
+ negative == true
85
+ end
86
+ end
87
+ end
88
+ end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rigor
4
+ class FlowContribution
5
+ # Result of folding any number of {FlowContribution} bundles
6
+ # through {Merger.merge}. Surfaces the merged content slot-by-
7
+ # slot, the ordered list of contributing provenances, and the
8
+ # {Conflict} list collected along the way.
9
+ #
10
+ # The merge result is a sibling shape of {FlowContribution} —
11
+ # the analyzer reads from it to drive narrowing / dispatch /
12
+ # diagnostics, and the formatter reads from it to surface
13
+ # plugin / RBS::Extended provenance. The shape is derived per
14
+ # ADR-2 § "Plugin Contribution Merging"; see
15
+ # [`docs/internal-spec/flow-contribution-merger.md`](../../../docs/internal-spec/flow-contribution-merger.md)
16
+ # for the slice-3 normative description.
17
+ class MergeResult
18
+ attr_reader :return_type, :truthy_facts, :falsey_facts, :post_return_facts,
19
+ :mutations, :invalidations, :exceptional, :role_conformance,
20
+ :provenances, :conflicts
21
+
22
+ # rubocop:disable Metrics/ParameterLists
23
+ def initialize(return_type: nil, truthy_facts: [], falsey_facts: [],
24
+ post_return_facts: [], mutations: [], invalidations: [],
25
+ exceptional: nil, role_conformance: [],
26
+ provenances: [], conflicts: [])
27
+ # rubocop:enable Metrics/ParameterLists
28
+ @return_type = return_type
29
+ @truthy_facts = truthy_facts.dup.freeze
30
+ @falsey_facts = falsey_facts.dup.freeze
31
+ @post_return_facts = post_return_facts.dup.freeze
32
+ @mutations = mutations.dup.freeze
33
+ @invalidations = invalidations.dup.freeze
34
+ @exceptional = exceptional
35
+ @role_conformance = role_conformance.dup.freeze
36
+ @provenances = provenances.dup.freeze
37
+ @conflicts = conflicts.dup.freeze
38
+ freeze
39
+ end
40
+
41
+ def conflict?
42
+ !@conflicts.empty?
43
+ end
44
+
45
+ def empty? # rubocop:disable Metrics/CyclomaticComplexity
46
+ @return_type.nil? && @truthy_facts.empty? && @falsey_facts.empty? &&
47
+ @post_return_facts.empty? && @mutations.empty? && @invalidations.empty? &&
48
+ @exceptional.nil? && @role_conformance.empty?
49
+ end
50
+
51
+ def to_h
52
+ {
53
+ "return_type" => return_type,
54
+ "truthy_facts" => truthy_facts,
55
+ "falsey_facts" => falsey_facts,
56
+ "post_return_facts" => post_return_facts,
57
+ "mutations" => mutations,
58
+ "invalidations" => invalidations,
59
+ "exceptional" => exceptional,
60
+ "role_conformance" => role_conformance,
61
+ "provenances" => provenances.map { |p| p.respond_to?(:to_h) ? p.to_h : p },
62
+ "conflicts" => conflicts.map(&:to_h)
63
+ }
64
+ end
65
+ end
66
+ end
67
+ end