rigortype 0.1.8 → 0.1.9

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 (35) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +186 -513
  3. data/lib/rigor/analysis/check_rules.rb +20 -0
  4. data/lib/rigor/cli/annotate_command.rb +224 -0
  5. data/lib/rigor/cli/baseline_command.rb +36 -16
  6. data/lib/rigor/cli/prism_colorizer.rb +111 -0
  7. data/lib/rigor/cli.rb +62 -4
  8. data/lib/rigor/environment.rb +9 -1
  9. data/lib/rigor/inference/builtins/method_catalog.rb +17 -1
  10. data/lib/rigor/inference/builtins/time_catalog.rb +10 -1
  11. data/lib/rigor/inference/expression_typer.rb +165 -6
  12. data/lib/rigor/inference/method_dispatcher/cgi_folding.rb +109 -0
  13. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +173 -10
  14. data/lib/rigor/inference/method_dispatcher/kernel_dispatch.rb +53 -1
  15. data/lib/rigor/inference/method_dispatcher/math_folding.rb +149 -0
  16. data/lib/rigor/inference/method_dispatcher/overload_selector.rb +20 -1
  17. data/lib/rigor/inference/method_dispatcher/regexp_folding.rb +81 -0
  18. data/lib/rigor/inference/method_dispatcher/set_folding.rb +81 -0
  19. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +316 -2
  20. data/lib/rigor/inference/method_dispatcher/shellwords_folding.rb +126 -0
  21. data/lib/rigor/inference/method_dispatcher/time_folding.rb +56 -0
  22. data/lib/rigor/inference/method_dispatcher/uri_folding.rb +67 -0
  23. data/lib/rigor/inference/method_dispatcher.rb +148 -1
  24. data/lib/rigor/inference/method_parameter_binder.rb +67 -10
  25. data/lib/rigor/inference/narrowing.rb +29 -10
  26. data/lib/rigor/inference/statement_evaluator.rb +3 -1
  27. data/lib/rigor/plugin/base.rb +39 -0
  28. data/lib/rigor/plugin/loader.rb +22 -1
  29. data/lib/rigor/plugin/manifest.rb +73 -10
  30. data/lib/rigor/plugin/protocol_contract.rb +185 -0
  31. data/lib/rigor/plugin/registry.rb +66 -0
  32. data/lib/rigor/triage/catalogue.rb +2 -2
  33. data/lib/rigor/type/constant.rb +29 -2
  34. data/lib/rigor/version.rb +1 -1
  35. metadata +11 -1
@@ -13,6 +13,13 @@ require_relative "method_dispatcher/rbs_dispatch"
13
13
  require_relative "method_dispatcher/iterator_dispatch"
14
14
  require_relative "method_dispatcher/block_folding"
15
15
  require_relative "method_dispatcher/file_folding"
16
+ require_relative "method_dispatcher/shellwords_folding"
17
+ require_relative "method_dispatcher/math_folding"
18
+ require_relative "method_dispatcher/time_folding"
19
+ require_relative "method_dispatcher/regexp_folding"
20
+ require_relative "method_dispatcher/cgi_folding"
21
+ require_relative "method_dispatcher/uri_folding"
22
+ require_relative "method_dispatcher/set_folding"
16
23
  require_relative "method_dispatcher/kernel_dispatch"
17
24
  require_relative "method_dispatcher/method_folding"
18
25
 
@@ -643,7 +650,7 @@ module Rigor
643
650
  ConstantFolding.try_fold(receiver: receiver_type, method_name: method_name, args: arg_types) ||
644
651
  LiteralStringFolding.try_dispatch(receiver: receiver_type, method_name: method_name, args: arg_types) ||
645
652
  ShapeDispatch.try_dispatch(receiver: receiver_type, method_name: method_name, args: arg_types) ||
646
- FileFolding.try_dispatch(receiver: receiver_type, method_name: method_name, args: arg_types) ||
653
+ dispatch_stdlib_module_tiers(receiver_type, method_name, arg_types) ||
647
654
  KernelDispatch.try_dispatch(receiver: receiver_type, method_name: method_name, args: arg_types) ||
648
655
  MethodFolding.try_forward(receiver: receiver_type, method_name: method_name, args: arg_types) ||
649
656
  BlockFolding.try_fold(
@@ -651,6 +658,21 @@ module Rigor
651
658
  )
652
659
  end
653
660
 
661
+ # Stdlib module singleton-folding tiers: File, Shellwords, Math,
662
+ # Time, Regexp, CGI, URI, Set. Extracted from
663
+ # `dispatch_precise_tiers` to keep the parent method within the
664
+ # cyclomatic-complexity limit.
665
+ def dispatch_stdlib_module_tiers(receiver_type, method_name, arg_types)
666
+ FileFolding.try_dispatch(receiver: receiver_type, method_name: method_name, args: arg_types) ||
667
+ ShellwordsFolding.try_dispatch(receiver: receiver_type, method_name: method_name, args: arg_types) ||
668
+ MathFolding.try_dispatch(receiver: receiver_type, method_name: method_name, args: arg_types) ||
669
+ TimeFolding.try_dispatch(receiver: receiver_type, method_name: method_name, args: arg_types) ||
670
+ RegexpFolding.try_dispatch(receiver: receiver_type, method_name: method_name, args: arg_types) ||
671
+ CGIFolding.try_dispatch(receiver: receiver_type, method_name: method_name, args: arg_types) ||
672
+ URIFolding.try_dispatch(receiver: receiver_type, method_name: method_name, args: arg_types) ||
673
+ SetFolding.try_dispatch(receiver: receiver_type, method_name: method_name, args: arg_types)
674
+ end
675
+
654
676
  def try_user_class_fallback(receiver_type, method_name, arg_types, environment, block_type, call_node = nil)
655
677
  return nil if environment.nil?
656
678
 
@@ -771,6 +793,18 @@ module Rigor
771
793
  array_lift = array_new_lift(receiver_type.class_name, arg_types)
772
794
  return array_lift if array_lift
773
795
 
796
+ range_lift = range_new_lift(receiver_type.class_name, arg_types)
797
+ return range_lift if range_lift
798
+
799
+ set_lift = set_new_lift(receiver_type.class_name, arg_types)
800
+ return set_lift if set_lift
801
+
802
+ regexp_lift = regexp_new_lift(receiver_type.class_name, arg_types)
803
+ return regexp_lift if regexp_lift
804
+
805
+ date_lift = date_new_lift(receiver_type.class_name, arg_types)
806
+ return date_lift if date_lift
807
+
774
808
  Type::Combinator.nominal_of(receiver_type.class_name)
775
809
  end
776
810
 
@@ -840,6 +874,119 @@ module Rigor
840
874
  type
841
875
  end
842
876
 
877
+ # `Range.new(b, e)` / `Range.new(b, e, excl)` — folds to
878
+ # `Constant[Range]` when both endpoints are `Constant[Integer]`
879
+ # or both are `Constant[String]`, and the optional third argument
880
+ # is a `Constant[true/false]`. Nil endpoints (beginless /
881
+ # endless ranges) are not folded because the useful instance
882
+ # methods (`to_a`, `first`, `last`) are not defined for them;
883
+ # the RBS tier answers `Nominal[Range]` for those forms.
884
+ def range_new_lift(class_name, arg_types)
885
+ return nil unless class_name == "Range"
886
+ return nil if arg_types.size < 2 || arg_types.size > 3
887
+
888
+ b_type = arg_types[0]
889
+ e_type = arg_types[1]
890
+
891
+ return nil unless b_type.is_a?(Type::Constant) && e_type.is_a?(Type::Constant)
892
+
893
+ b_val = b_type.value
894
+ e_val = e_type.value
895
+
896
+ # Only fold homogeneous Integer or String endpoint pairs.
897
+ return nil unless b_val.instance_of?(e_val.class)
898
+ return nil unless b_val.is_a?(Integer) || b_val.is_a?(String)
899
+
900
+ excl = range_new_excl(arg_types[2])
901
+ return nil if excl.nil?
902
+
903
+ Type::Combinator.constant_of(Range.new(b_val, e_val, excl))
904
+ rescue StandardError
905
+ nil
906
+ end
907
+
908
+ # Resolves the optional `exclude_end` argument for `range_new_lift`.
909
+ # Returns `false` (no arg), `true`/`false` (Constant[bool] arg),
910
+ # or `nil` to signal "decline" (wrong type / wrong value class).
911
+ def range_new_excl(excl_type)
912
+ case excl_type
913
+ when nil then false
914
+ when Type::Constant
915
+ excl_type.value if [true, false].include?(excl_type.value)
916
+ end
917
+ end
918
+
919
+ # `Set.new` / `Set.new(tuple_of_constants)` — folds to `Constant[Set]`
920
+ # when zero arguments are given or the single argument is a `Tuple`
921
+ # whose every element is a `Constant[T]`. Mirrors `SetFolding#fold_new`
922
+ # but lives here so the `:new` path in `try_meta_introspection` can
923
+ # reach it before the RBS tier answers `Nominal[Set]`.
924
+ def set_new_lift(class_name, arg_types)
925
+ return nil unless class_name == "Set"
926
+ return Type::Combinator.constant_of(::Set.new) if arg_types.empty?
927
+ return nil if arg_types.size > 1
928
+
929
+ arg = arg_types.first
930
+ return nil unless arg.is_a?(Type::Tuple)
931
+ return nil unless arg.elements.all?(Type::Constant)
932
+
933
+ values = arg.elements.map(&:value)
934
+ Type::Combinator.constant_of(::Set.new(values))
935
+ rescue StandardError
936
+ nil
937
+ end
938
+
939
+ # `Regexp.new(pattern)` / `Regexp.new(pattern, opts)` — folds to
940
+ # `Constant[Regexp]` when the pattern is a `Constant[String]`.
941
+ # Mirrors `RegexpFolding#fold_new` but lives here so the `:new` path
942
+ # in `try_meta_introspection` can reach it.
943
+ def regexp_new_lift(class_name, arg_types)
944
+ return nil unless class_name == "Regexp"
945
+ return nil if arg_types.empty? || arg_types.size > 2
946
+
947
+ pattern_arg = arg_types.first
948
+ return nil unless pattern_arg.is_a?(Type::Constant) && pattern_arg.value.is_a?(String)
949
+
950
+ opts = if arg_types.size == 2
951
+ opt_type = arg_types[1]
952
+ return nil unless opt_type.is_a?(Type::Constant)
953
+
954
+ opt_type.value
955
+ else
956
+ 0
957
+ end
958
+
959
+ Type::Combinator.constant_of(Regexp.new(pattern_arg.value, opts))
960
+ rescue StandardError
961
+ nil
962
+ end
963
+
964
+ # `Date.new(y, m, d)` / `DateTime.new(y, m, d, h, min, s, off)`
965
+ # — folds to `Constant[Date]` / `Constant[DateTime]` when every
966
+ # argument is a `Constant` carrying an Integer (or, for the
967
+ # `DateTime` offset / `start` slots, a Rational or String).
968
+ # `Date.new` carries no timezone, and `DateTime.new`'s offset
969
+ # defaults to UTC, so the literal is machine-independent.
970
+ # `Date.today` / `Date.parse` are not constructors here — they
971
+ # are non-deterministic / string-dependent and stay
972
+ # `Nominal[Date]`.
973
+ DATE_NEW_CLASSES = %w[Date DateTime].freeze
974
+ private_constant :DATE_NEW_CLASSES
975
+
976
+ def date_new_lift(class_name, arg_types)
977
+ return nil unless DATE_NEW_CLASSES.include?(class_name)
978
+ return nil unless arg_types.size.between?(1, 8)
979
+ return nil unless arg_types.all?(Type::Constant)
980
+
981
+ values = arg_types.map(&:value)
982
+ return nil unless values.all? { |v| v.is_a?(Integer) || v.is_a?(Rational) || v.is_a?(String) }
983
+
984
+ klass = class_name == "Date" ? Date : DateTime
985
+ Type::Combinator.constant_of(klass.new(*values))
986
+ rescue StandardError
987
+ nil
988
+ end
989
+
843
990
  CONSTANT_METACLASSES = {
844
991
  Integer => "Integer", Float => "Float", String => "String",
845
992
  Symbol => "Symbol", Range => "Range",
@@ -43,10 +43,15 @@ module Rigor
43
43
  # method (either `def self.foo` or a `def foo` inside
44
44
  # `class << self`); routes the lookup through
45
45
  # `RbsLoader#singleton_method`.
46
- def initialize(environment:, class_path:, singleton:)
46
+ # @param source_path [String, nil] the project-relative path of
47
+ # the file the method is defined in. Used to match ADR-28
48
+ # path-scoped protocol contracts; `nil` (the default for
49
+ # synthetic / probe scopes) disables the contract tier.
50
+ def initialize(environment:, class_path:, singleton:, source_path: nil)
47
51
  @environment = environment
48
52
  @class_path = class_path
49
53
  @singleton = singleton
54
+ @source_path = source_path
50
55
  end
51
56
 
52
57
  # @param def_node [Prism::DefNode]
@@ -58,15 +63,22 @@ module Rigor
58
63
  types = default_types_for(slots)
59
64
 
60
65
  rbs_method = lookup_rbs_method(def_node)
61
- return types unless rbs_method
62
-
63
- apply_rbs_overloads(types, slots, rbs_method.method_types) unless rbs_method.method_types.empty?
64
- # `rigor:v1:param: <name> <refinement>` annotations
65
- # tighten the bound type for matching slots. Applied
66
- # after the RBS-overload pass so the override is the
67
- # authoritative answer regardless of what the RBS
68
- # signature declared.
69
- apply_param_overrides(types, slots, rbs_method)
66
+ if rbs_method
67
+ apply_rbs_overloads(types, slots, rbs_method.method_types) unless rbs_method.method_types.empty?
68
+ # `rigor:v1:param: <name> <refinement>` annotations
69
+ # tighten the bound type for matching slots. Applied
70
+ # after the RBS-overload pass so the override is the
71
+ # authoritative answer regardless of what the RBS
72
+ # signature declared.
73
+ apply_param_overrides(types, slots, rbs_method)
74
+ end
75
+ # ADR-28 — a path-scoped protocol contract supplies the
76
+ # parameter type for a matching `def`. Applied last (most
77
+ # authoritative) and regardless of RBS presence: the
78
+ # methods a contract targets — controller actions and the
79
+ # like — typically have no RBS signature at all, so this
80
+ # tier must run even when `rbs_method` is nil.
81
+ apply_protocol_contract(types, slots, def_node)
70
82
  types
71
83
  end
72
84
 
@@ -75,6 +87,10 @@ module Rigor
75
87
  ParamSlot = Data.define(:kind, :name, :index)
76
88
  private_constant :ParamSlot
77
89
 
90
+ # Slot kinds a contract's positional `index` can address.
91
+ POSITIONAL_KINDS = %i[required_positional optional_positional].freeze
92
+ private_constant :POSITIONAL_KINDS
93
+
78
94
  # Walk the Prism `ParametersNode` and emit one slot per named
79
95
  # parameter, in declaration order. Anonymous slots (rest /
80
96
  # keyword-rest with no name) are skipped because we have no
@@ -187,6 +203,47 @@ module Rigor
187
203
  end
188
204
  end
189
205
 
206
+ # ADR-28 — when a path-scoped protocol contract targets this
207
+ # `def` (file path matches the contract's `path_glob`, method
208
+ # name + singleton-ness match), replace each contracted
209
+ # positional slot's binding with the contract's declared type.
210
+ # The type name resolves against the environment lazily here;
211
+ # an unresolvable name (the protocol's RBS not loaded) falls
212
+ # through to whatever the prior tiers bound, fail-soft.
213
+ def apply_protocol_contract(types, slots, def_node)
214
+ return if @source_path.nil?
215
+
216
+ registry = @environment.respond_to?(:plugin_registry) ? @environment.plugin_registry : nil
217
+ return if registry.nil?
218
+
219
+ contract = matching_contract(registry, def_node)
220
+ return if contract.nil?
221
+
222
+ contract.param_types.each do |param_type|
223
+ slot = positional_slot_at(slots, param_type.index)
224
+ next if slot.nil? || slot.name.nil?
225
+
226
+ resolved = @environment.nominal_for_name(param_type.type_name)
227
+ next if resolved.nil?
228
+
229
+ types[slot.name] = resolved
230
+ end
231
+ end
232
+
233
+ def matching_contract(registry, def_node)
234
+ contracts = registry.contracts_for_path(@source_path)
235
+ return nil if contracts.empty?
236
+
237
+ singleton = def_node.receiver.is_a?(Prism::SelfNode) || @singleton
238
+ contracts.find do |contract|
239
+ contract.method_name == def_node.name && contract.singleton == singleton
240
+ end
241
+ end
242
+
243
+ def positional_slot_at(slots, index)
244
+ slots.find { |slot| POSITIONAL_KINDS.include?(slot.kind) && slot.index == index }
245
+ end
246
+
190
247
  def collect_translated_types(method_types, slot)
191
248
  rbs_types = method_types.flat_map do |mt|
192
249
  t = rbs_type_for_slot(mt.type, slot)
@@ -386,8 +386,8 @@ module Rigor
386
386
  analyse_local_read(node, scope)
387
387
  when Prism::LocalVariableWriteNode
388
388
  analyse_local_write(node, scope)
389
- when Prism::InstanceVariableWriteNode
390
- analyse_ivar_write(node, scope)
389
+ when Prism::InstanceVariableReadNode, Prism::InstanceVariableWriteNode
390
+ analyse_ivar(node, scope)
391
391
  when Prism::ClassVariableWriteNode
392
392
  analyse_cvar_write(node, scope)
393
393
  when Prism::GlobalVariableWriteNode
@@ -767,7 +767,17 @@ module Rigor
767
767
  ]
768
768
  end
769
769
 
770
- def analyse_ivar_write(node, scope)
770
+ # Truthy guard on an instance variable. Covers both the bare
771
+ # read — `if @ivar`, the left arm of `@ivar && @ivar.foo`,
772
+ # the receiver of `unless @ivar.nil?` once `!` reverses it —
773
+ # and the assignment-in-condition write `if @ivar = expr`.
774
+ # Mirrors `analyse_local_read` / `analyse_local_write`: the
775
+ # truthy edge narrows the ivar by `narrow_truthy` (dropping
776
+ # `nil` / `false`), the falsey edge by `narrow_falsey`. The
777
+ # narrowing is scoped to the guarded branch only, so it is
778
+ # purely additive — it never widens or re-narrows the ivar
779
+ # binding seen elsewhere.
780
+ def analyse_ivar(node, scope)
771
781
  current = scope.ivar(node.name)
772
782
  return nil if current.nil?
773
783
 
@@ -2024,15 +2034,24 @@ module Rigor
2024
2034
  end
2025
2035
 
2026
2036
  def analyse_nil_predicate(receiver, scope)
2027
- return nil unless receiver.is_a?(Prism::LocalVariableReadNode)
2037
+ case receiver
2038
+ when Prism::LocalVariableReadNode
2039
+ current = scope.local(receiver.name)
2040
+ return nil if current.nil?
2028
2041
 
2029
- current = scope.local(receiver.name)
2030
- return nil if current.nil?
2042
+ [
2043
+ scope.with_local(receiver.name, narrow_nil(current)),
2044
+ scope.with_local(receiver.name, narrow_non_nil(current))
2045
+ ]
2046
+ when Prism::InstanceVariableReadNode
2047
+ current = scope.ivar(receiver.name)
2048
+ return nil if current.nil?
2031
2049
 
2032
- [
2033
- scope.with_local(receiver.name, narrow_nil(current)),
2034
- scope.with_local(receiver.name, narrow_non_nil(current))
2035
- ]
2050
+ [
2051
+ scope.with_ivar(receiver.name, narrow_nil(current)),
2052
+ scope.with_ivar(receiver.name, narrow_non_nil(current))
2053
+ ]
2054
+ end
2036
2055
  end
2037
2056
 
2038
2057
  # `a && b` short-circuits: the truthy edge is the truthy edge
@@ -1421,7 +1421,8 @@ module Rigor
1421
1421
  binder = MethodParameterBinder.new(
1422
1422
  environment: scope.environment,
1423
1423
  class_path: current_class_path,
1424
- singleton: singleton
1424
+ singleton: singleton,
1425
+ source_path: scope.source_path
1425
1426
  )
1426
1427
  bindings = binder.bind(def_node)
1427
1428
 
@@ -1486,6 +1487,7 @@ module Rigor
1486
1487
  # remain reachable from inside nested bodies.
1487
1488
  def build_fresh_body_scope # rubocop:disable Metrics/AbcSize
1488
1489
  Scope.empty(environment: scope.environment)
1490
+ .with_source_path(scope.source_path)
1489
1491
  .with_declared_types(scope.declared_types)
1490
1492
  .with_discovered_classes(scope.discovered_classes)
1491
1493
  .with_in_source_constants(scope.in_source_constants)
@@ -183,6 +183,45 @@ module Rigor
183
183
  self.class.manifest
184
184
  end
185
185
 
186
+ # ADR-25 — absolute RBS signature directories this plugin
187
+ # contributes. Resolves each `manifest.signature_paths` entry
188
+ # (declared relative to the plugin gem root) against that
189
+ # root. The gem root is the directory above `lib/` in the
190
+ # file that defined the plugin class (falling back to that
191
+ # file's directory for a non-conventional layout). Returns
192
+ # `[]` when the manifest declares no `signature_paths:` or
193
+ # the class is anonymous (an anonymous class cannot ship a
194
+ # gem). `Plugin::Loader` validates the resolved dirs exist at
195
+ # load time; `Environment.for_project` merges them into the
196
+ # signature-path set fed to `RbsLoader`.
197
+ def signature_paths
198
+ relative = manifest.signature_paths
199
+ return [] if relative.empty?
200
+
201
+ class_name = self.class.name
202
+ return [] if class_name.nil?
203
+
204
+ file, = Object.const_source_location(class_name)
205
+ return [] if file.nil?
206
+
207
+ before, separator, = file.rpartition("/lib/")
208
+ root = separator.empty? ? File.dirname(file) : before
209
+ relative.map { |rel| File.expand_path(rel, root) }
210
+ end
211
+
212
+ # ADR-28 — the path-scoped method-protocol contracts this
213
+ # plugin contributes. Defaults to the manifest-declared
214
+ # `protocol_contracts:`; the same indirection
215
+ # `#signature_paths` uses, so a plugin MAY override this to
216
+ # fold per-project config into the contract set (e.g.
217
+ # substituting the convention `path_glob` with a user-supplied
218
+ # one) without the manifest having to be config-aware.
219
+ # `Plugin::Registry#protocol_contracts` aggregates the result
220
+ # across loaded plugins.
221
+ def protocol_contracts
222
+ manifest.protocol_contracts
223
+ end
224
+
186
225
  # ADR-7 § "Slice 6-A/6-B" — per-plugin {IoBoundary}.
187
226
  # Memoised so the boundary's accumulated `FileEntry`
188
227
  # rows persist across producer invocations within the
@@ -125,7 +125,28 @@ module Rigor
125
125
  seen_ids[manifest.id] = entry[:gem]
126
126
 
127
127
  validate_config!(manifest, entry[:config])
128
- instantiate(plugin_class, entry[:config])
128
+ plugin = instantiate(plugin_class, entry[:config])
129
+ validate_signature_paths!(plugin)
130
+ plugin
131
+ end
132
+
133
+ # ADR-25 — a plugin's manifest-declared `signature_paths:`
134
+ # are resolved (by `Plugin::Base#signature_paths`) against
135
+ # the plugin gem root. A declared directory that does not
136
+ # exist is a load-time failure for that plugin — loud, not
137
+ # silent, because a missing `sig/` means the bundle gem is
138
+ # broken. The raised LoadError is collected like any other
139
+ # load failure and the plugin drops from the registry.
140
+ def validate_signature_paths!(plugin)
141
+ plugin.signature_paths.each do |dir|
142
+ next if File.directory?(dir)
143
+
144
+ raise LoadError.new(
145
+ "plugin #{plugin.manifest.id.inspect} declares signature path #{dir.inspect} " \
146
+ "which is not a directory",
147
+ plugin_ref: plugin.manifest.id
148
+ )
149
+ end
129
150
  end
130
151
 
131
152
  def require_gem!(entry)
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "../inference/hkt_registry"
4
+ require_relative "protocol_contract"
4
5
 
5
6
  module Rigor
6
7
  module Plugin
@@ -40,15 +41,16 @@ module Rigor
40
41
  end
41
42
 
42
43
  attr_reader :id, :version, :description, :protocols, :config_schema, :produces, :consumes,
43
- :owns_receivers, :type_node_resolvers, :block_as_methods, :heredoc_templates,
44
- :trait_registries, :external_files, :hkt_registrations, :hkt_definitions
44
+ :owns_receivers, :open_receivers, :type_node_resolvers, :block_as_methods,
45
+ :heredoc_templates, :trait_registries, :external_files, :hkt_registrations,
46
+ :hkt_definitions, :signature_paths, :protocol_contracts
45
47
 
46
48
  def initialize( # rubocop:disable Metrics/ParameterLists
47
49
  id:, version:,
48
50
  description: nil, protocols: [], config_schema: {},
49
- produces: [], consumes: [], owns_receivers: [], type_node_resolvers: [],
51
+ produces: [], consumes: [], owns_receivers: [], open_receivers: [], type_node_resolvers: [],
50
52
  block_as_methods: [], heredoc_templates: [], trait_registries: [], external_files: [],
51
- hkt_registrations: [], hkt_definitions: []
53
+ hkt_registrations: [], hkt_definitions: [], signature_paths: [], protocol_contracts: []
52
54
  )
53
55
  validate_id!(id)
54
56
  validate_version!(version)
@@ -56,6 +58,7 @@ module Rigor
56
58
  validate_config_schema!(config_schema)
57
59
  validate_produces!(produces)
58
60
  validate_owns_receivers!(owns_receivers)
61
+ validate_open_receivers!(open_receivers)
59
62
  validate_type_node_resolvers!(type_node_resolvers)
60
63
  validate_block_as_methods!(block_as_methods)
61
64
  validate_heredoc_templates!(heredoc_templates)
@@ -63,10 +66,12 @@ module Rigor
63
66
  validate_external_files!(external_files)
64
67
  validate_hkt_registrations!(hkt_registrations)
65
68
  validate_hkt_definitions!(hkt_definitions)
69
+ validate_signature_paths!(signature_paths)
70
+ validate_protocol_contracts!(protocol_contracts)
66
71
 
67
72
  assign_fields(id, version, description, protocols, config_schema, produces, consumes, owns_receivers,
68
- type_node_resolvers, block_as_methods, heredoc_templates, trait_registries, external_files,
69
- hkt_registrations, hkt_definitions)
73
+ open_receivers, type_node_resolvers, block_as_methods, heredoc_templates, trait_registries,
74
+ external_files, hkt_registrations, hkt_definitions, signature_paths, protocol_contracts)
70
75
  freeze
71
76
  end
72
77
 
@@ -74,8 +79,8 @@ module Rigor
74
79
 
75
80
  # rubocop:disable Metrics/ParameterLists, Metrics/AbcSize
76
81
  def assign_fields(id, version, description, protocols, config_schema, produces, consumes, owns_receivers,
77
- type_node_resolvers, block_as_methods, heredoc_templates, trait_registries, external_files,
78
- hkt_registrations, hkt_definitions)
82
+ open_receivers, type_node_resolvers, block_as_methods, heredoc_templates, trait_registries,
83
+ external_files, hkt_registrations, hkt_definitions, signature_paths, protocol_contracts)
79
84
  @id = id.dup.freeze
80
85
  @version = version.dup.freeze
81
86
  @description = description.nil? ? nil : description.to_s.dup.freeze
@@ -84,6 +89,7 @@ module Rigor
84
89
  @produces = produces.map(&:to_sym).freeze
85
90
  @consumes = coerce_consumes(consumes)
86
91
  @owns_receivers = owns_receivers.map { |c| c.to_s.dup.freeze }.freeze
92
+ @open_receivers = open_receivers.map { |c| c.to_s.dup.freeze }.freeze
87
93
  @type_node_resolvers = type_node_resolvers.dup.freeze
88
94
  @block_as_methods = block_as_methods.dup.freeze
89
95
  @heredoc_templates = heredoc_templates.dup.freeze
@@ -91,6 +97,8 @@ module Rigor
91
97
  @external_files = external_files.dup.freeze
92
98
  @hkt_registrations = hkt_registrations.dup.freeze
93
99
  @hkt_definitions = hkt_definitions.dup.freeze
100
+ @signature_paths = signature_paths.map { |p| p.to_s.dup.freeze }.freeze
101
+ @protocol_contracts = protocol_contracts.dup.freeze
94
102
  end
95
103
  # rubocop:enable Metrics/ParameterLists, Metrics/AbcSize
96
104
 
@@ -119,7 +127,7 @@ module Rigor
119
127
  errors
120
128
  end
121
129
 
122
- def to_h # rubocop:disable Metrics/AbcSize
130
+ def to_h # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
123
131
  {
124
132
  "id" => id,
125
133
  "version" => version,
@@ -129,13 +137,16 @@ module Rigor
129
137
  "produces" => produces.map(&:to_s),
130
138
  "consumes" => consumes.map { |c| consumption_hash(c) },
131
139
  "owns_receivers" => owns_receivers,
140
+ "open_receivers" => open_receivers,
132
141
  "type_node_resolvers" => type_node_resolvers.map { |r| r.class.name },
133
142
  "block_as_methods" => block_as_methods.map(&:to_h),
134
143
  "heredoc_templates" => heredoc_templates.map(&:to_h),
135
144
  "trait_registries" => trait_registries.map(&:to_h),
136
145
  "external_files" => external_files.map(&:to_h),
137
146
  "hkt_registrations" => hkt_registrations.map(&:to_h),
138
- "hkt_definitions" => hkt_definitions.map { |d| { "uri" => d.uri, "params" => d.params } }
147
+ "hkt_definitions" => hkt_definitions.map { |d| { "uri" => d.uri, "params" => d.params } },
148
+ "signature_paths" => signature_paths,
149
+ "protocol_contracts" => protocol_contracts.map(&:to_h)
139
150
  }
140
151
  end
141
152
 
@@ -217,6 +228,24 @@ module Rigor
217
228
  "got #{owns_receivers.inspect}"
218
229
  end
219
230
 
231
+ # ADR-26 — `open_receivers:` declares the class names this
232
+ # plugin marks as "open": statically known to respond beyond
233
+ # their RBS-declared method surface (e.g. `ActiveRecord::Relation`,
234
+ # which delegates an unbounded set of user-defined scopes to
235
+ # its model). `Analysis::CheckRules` skips the
236
+ # `call.undefined-method` rule for a receiver whose class any
237
+ # loaded plugin lists here — flagging a method on a class
238
+ # with an open dynamic surface is unsound. Distinct from
239
+ # `owns_receivers:` (which routes dispatch); this one only
240
+ # suppresses the diagnostic.
241
+ def validate_open_receivers!(open_receivers)
242
+ return if open_receivers.is_a?(Array) && open_receivers.all? { |c| c.is_a?(String) && !c.empty? }
243
+
244
+ raise ArgumentError,
245
+ "plugin manifest open_receivers must be an Array of non-empty String, " \
246
+ "got #{open_receivers.inspect}"
247
+ end
248
+
220
249
  # ADR-13 slice 2 — `type_node_resolvers:` declares the
221
250
  # plugin-supplied `TypeNodeResolver` instances the parser
222
251
  # consults (in slice 3) when an RBS::Extended payload's
@@ -326,6 +355,40 @@ module Rigor
326
355
  "Rigor::Inference::HktRegistry::Definition instances, got #{entries.inspect}"
327
356
  end
328
357
 
358
+ # ADR-25 — `signature_paths:` declares the RBS signature
359
+ # directories this plugin gem ships, as paths relative to the
360
+ # plugin's own gem root (e.g. `["sig"]`). `Plugin::Base#signature_paths`
361
+ # resolves them to absolute dirs against the gem root; the
362
+ # loader validates each exists and `Environment.for_project`
363
+ # merges the resolved set into the RBS environment.
364
+ def validate_signature_paths!(paths)
365
+ return if paths.is_a?(Array) && paths.all? { |p| p.is_a?(String) && !p.empty? }
366
+
367
+ raise ArgumentError,
368
+ "plugin manifest signature_paths must be an Array of non-empty String, " \
369
+ "got #{paths.inspect}"
370
+ end
371
+
372
+ # ADR-28 — `protocol_contracts:` declares the path-scoped
373
+ # method-protocol contracts the plugin contributes. Each
374
+ # entry MUST be a `Rigor::Plugin::ProtocolContract`. The
375
+ # registry aggregator on `Plugin::Registry` flattens
376
+ # contracts across loaded plugins; the engine consults them
377
+ # in two places — `MethodParameterBinder` provides the
378
+ # declared parameter types into matching method bodies, and
379
+ # the contributing plugin's `#diagnostics_for_file` checks
380
+ # method presence + return-type conformance. The manifest
381
+ # field carries the plugin's *default* contracts; a plugin
382
+ # MAY override `Plugin::Base#protocol_contracts` to fold in
383
+ # per-project config (e.g. a custom convention path).
384
+ def validate_protocol_contracts!(entries)
385
+ return if entries.is_a?(Array) && entries.all?(ProtocolContract)
386
+
387
+ raise ArgumentError,
388
+ "plugin manifest protocol_contracts must be an Array of " \
389
+ "Rigor::Plugin::ProtocolContract instances, got #{entries.inspect}"
390
+ end
391
+
329
392
  def coerce_consumes(consumes)
330
393
  unless consumes.is_a?(Array)
331
394
  raise ArgumentError, "plugin manifest consumes must be an Array, got #{consumes.inspect}"