rigortype 0.1.7 → 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 (47) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +186 -513
  3. data/lib/rigor/analysis/check_rules.rb +23 -1
  4. data/lib/rigor/analysis/diagnostic.rb +17 -3
  5. data/lib/rigor/analysis/runner.rb +178 -3
  6. data/lib/rigor/analysis/worker_session.rb +14 -3
  7. data/lib/rigor/cli/annotate_command.rb +224 -0
  8. data/lib/rigor/cli/baseline_command.rb +36 -16
  9. data/lib/rigor/cli/prism_colorizer.rb +111 -0
  10. data/lib/rigor/cli/triage_command.rb +83 -0
  11. data/lib/rigor/cli/triage_renderer.rb +77 -0
  12. data/lib/rigor/cli.rb +71 -5
  13. data/lib/rigor/environment.rb +9 -1
  14. data/lib/rigor/inference/builtins/method_catalog.rb +17 -1
  15. data/lib/rigor/inference/builtins/time_catalog.rb +10 -1
  16. data/lib/rigor/inference/expression_typer.rb +300 -18
  17. data/lib/rigor/inference/method_dispatcher/cgi_folding.rb +109 -0
  18. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +173 -10
  19. data/lib/rigor/inference/method_dispatcher/kernel_dispatch.rb +53 -1
  20. data/lib/rigor/inference/method_dispatcher/math_folding.rb +149 -0
  21. data/lib/rigor/inference/method_dispatcher/overload_selector.rb +20 -1
  22. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +33 -8
  23. data/lib/rigor/inference/method_dispatcher/regexp_folding.rb +81 -0
  24. data/lib/rigor/inference/method_dispatcher/set_folding.rb +81 -0
  25. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +316 -2
  26. data/lib/rigor/inference/method_dispatcher/shellwords_folding.rb +126 -0
  27. data/lib/rigor/inference/method_dispatcher/time_folding.rb +56 -0
  28. data/lib/rigor/inference/method_dispatcher/uri_folding.rb +67 -0
  29. data/lib/rigor/inference/method_dispatcher.rb +179 -4
  30. data/lib/rigor/inference/method_parameter_binder.rb +67 -10
  31. data/lib/rigor/inference/narrowing.rb +29 -10
  32. data/lib/rigor/inference/scope_indexer.rb +156 -6
  33. data/lib/rigor/inference/statement_evaluator.rb +43 -21
  34. data/lib/rigor/plugin/base.rb +39 -0
  35. data/lib/rigor/plugin/loader.rb +22 -1
  36. data/lib/rigor/plugin/manifest.rb +73 -10
  37. data/lib/rigor/plugin/protocol_contract.rb +185 -0
  38. data/lib/rigor/plugin/registry.rb +66 -0
  39. data/lib/rigor/scope.rb +46 -0
  40. data/lib/rigor/triage/catalogue.rb +296 -0
  41. data/lib/rigor/triage/hint.rb +27 -0
  42. data/lib/rigor/triage.rb +89 -0
  43. data/lib/rigor/type/constant.rb +29 -2
  44. data/lib/rigor/version.rb +1 -1
  45. data/sig/rigor/inference.rbs +1 -0
  46. data/sig/rigor/scope.rbs +6 -0
  47. metadata +16 -1
@@ -109,12 +109,11 @@ module Rigor
109
109
  discovered_methods = build_discovered_methods(root)
110
110
  seeded_scope = seeded_scope.with_discovered_methods(discovered_methods)
111
111
 
112
- # v0.0.2 #5 also record the def node itself for
113
- # instance methods so the engine can re-type the body
114
- # when a call site dispatches against a user-defined
115
- # method without an RBS sig.
116
- discovered_def_nodes = build_discovered_def_nodes(root)
117
- seeded_scope = seeded_scope.with_discovered_def_nodes(discovered_def_nodes)
112
+ # v0.0.2 #5 + ADR-24 slice 2 record per-instance-method
113
+ # def nodes, the class -> superclass map, and the
114
+ # class/module -> included-modules map, each merged under
115
+ # the cross-file pre-pass seed (see below).
116
+ seeded_scope = merge_project_method_indexes(seeded_scope, default_scope, root)
118
117
 
119
118
  # v0.1.2 — per-class table of method visibilities
120
119
  # (`:public` / `:private` / `:protected`). The
@@ -134,6 +133,31 @@ module Rigor
134
133
  table
135
134
  end
136
135
 
136
+ # v0.0.2 #5 + ADR-24 slice 2 — seeds the three
137
+ # project-method indexes onto `seeded_scope`: the
138
+ # per-instance-method def-node table, the class ->
139
+ # superclass map, and the class/module -> included-modules
140
+ # map. Each per-file table is merged UNDER the cross-file
141
+ # `discovered_def_index_for_paths` seed carried on
142
+ # `default_scope` — same-file declarations win per entry,
143
+ # the cross-file seed supplies sibling-file ancestors.
144
+ def merge_project_method_indexes(seeded_scope, default_scope, root)
145
+ def_nodes = default_scope.discovered_def_nodes.merge(
146
+ build_discovered_def_nodes(root)
147
+ ) { |_class, cross_file, per_file| cross_file.merge(per_file) }
148
+ superclasses = default_scope.discovered_superclasses.merge(
149
+ build_discovered_superclasses(root)
150
+ )
151
+ includes = default_scope.discovered_includes.merge(
152
+ build_discovered_includes(root)
153
+ ) { |_class, cross_file, per_file| (cross_file + per_file).uniq }
154
+
155
+ seeded_scope
156
+ .with_discovered_def_nodes(def_nodes)
157
+ .with_discovered_superclasses(superclasses)
158
+ .with_discovered_includes(includes)
159
+ end
160
+
137
161
  # Slice 7 phase 2. Builds the class-level ivar accumulator
138
162
  # by walking every `Prism::ClassNode` / `Prism::ModuleNode`
139
163
  # body, descending into each nested `Prism::DefNode`, and
@@ -580,6 +604,94 @@ module Rigor
580
604
  accumulator[class_name][def_node.name] = def_node
581
605
  end
582
606
 
607
+ # ADR-24 slice 2 — per-class table mapping a fully
608
+ # qualified user class to its superclass name AS WRITTEN
609
+ # at the `class Foo < Bar` declaration. Only constant
610
+ # superclasses are recorded (`class Foo < Struct.new(...)`
611
+ # and other non-constant superclasses produce no entry).
612
+ # The as-written name is resolved to a qualified class at
613
+ # the call site against the subclass's lexical nesting —
614
+ # see `ExpressionTyper#resolve_ancestor_class_name`.
615
+ def build_discovered_superclasses(root)
616
+ accumulator = {}
617
+ walk_class_superclasses(root, [], accumulator)
618
+ accumulator.freeze
619
+ end
620
+
621
+ def walk_class_superclasses(node, qualified_prefix, accumulator)
622
+ return unless node.is_a?(Prism::Node)
623
+
624
+ case node
625
+ when Prism::ClassNode
626
+ name = qualified_name_for(node.constant_path)
627
+ if name
628
+ full = (qualified_prefix + [name]).join("::")
629
+ superclass = node.superclass && qualified_name_for(node.superclass)
630
+ accumulator[full] = superclass if superclass
631
+ walk_class_superclasses(node.body, qualified_prefix + [name], accumulator) if node.body
632
+ return
633
+ end
634
+ when Prism::ModuleNode
635
+ name = qualified_name_for(node.constant_path)
636
+ if name
637
+ walk_class_superclasses(node.body, qualified_prefix + [name], accumulator) if node.body
638
+ return
639
+ end
640
+ end
641
+
642
+ node.compact_child_nodes.each do |child|
643
+ walk_class_superclasses(child, qualified_prefix, accumulator)
644
+ end
645
+ end
646
+
647
+ MIXIN_CALL_NAMES = %i[include prepend].freeze
648
+
649
+ # ADR-24 slice 2 — per-class/module table mapping a fully
650
+ # qualified user class or module to the list of module
651
+ # names it `include`s / `prepend`s, AS WRITTEN at the
652
+ # mixin call (`include Foo` / `include Foo::Bar`). Only
653
+ # constant arguments are recorded; dynamic mixins
654
+ # (`include some_method`) produce no entry. `prepend` is
655
+ # bucketed with `include` — both contribute instance
656
+ # methods to the ancestor chain. `extend` is NOT tracked
657
+ # (it adds singleton methods; ADR-24 slice 2 resolves the
658
+ # instance-side chain).
659
+ def build_discovered_includes(root)
660
+ accumulator = {}
661
+ walk_class_includes(root, [], nil, accumulator)
662
+ accumulator.transform_values { |mods| mods.uniq.freeze }.freeze
663
+ end
664
+
665
+ def walk_class_includes(node, qualified_prefix, current_class, accumulator)
666
+ return unless node.is_a?(Prism::Node)
667
+
668
+ case node
669
+ when Prism::ClassNode, Prism::ModuleNode
670
+ name = qualified_name_for(node.constant_path)
671
+ if name
672
+ full = (qualified_prefix + [name]).join("::")
673
+ walk_class_includes(node.body, qualified_prefix + [name], full, accumulator) if node.body
674
+ return
675
+ end
676
+ when Prism::CallNode
677
+ record_mixin_call(node, current_class, accumulator)
678
+ end
679
+
680
+ node.compact_child_nodes.each do |child|
681
+ walk_class_includes(child, qualified_prefix, current_class, accumulator)
682
+ end
683
+ end
684
+
685
+ def record_mixin_call(node, current_class, accumulator)
686
+ return unless current_class && node.receiver.nil?
687
+ return unless MIXIN_CALL_NAMES.include?(node.name)
688
+
689
+ node.arguments&.arguments&.each do |arg|
690
+ mod = qualified_name_for(arg)
691
+ (accumulator[current_class] ||= []) << mod if mod
692
+ end
693
+ end
694
+
583
695
  VISIBILITY_MODIFIERS = %i[public private protected].freeze
584
696
 
585
697
  # v0.1.2 — per-class method-visibility table for the
@@ -845,6 +957,44 @@ module Rigor
845
957
  accumulator.freeze
846
958
  end
847
959
 
960
+ # ADR-24 slice 2 — cross-file companion to
961
+ # `discovered_classes_for_paths`. Walks every project
962
+ # file once and returns both the merged
963
+ # `discovered_def_nodes` table (a class reopened across
964
+ # files has its method tables merged) and the merged
965
+ # class -> superclass-name map. The engine consults these
966
+ # so an implicit-self call inside a subclass resolves
967
+ # against a superclass `def` declared in a sibling file
968
+ # (`Mastodon::CLI::Accounts` calling a helper defined in
969
+ # `Mastodon::CLI::Base`).
970
+ #
971
+ # @param paths [Array<String>] project file paths.
972
+ # @param buffer [Rigor::Analysis::BufferBinding, nil]
973
+ # @return [Hash{Symbol => Hash}] `{ def_nodes:, superclasses: }`
974
+ def discovered_def_index_for_paths(paths, buffer: nil)
975
+ def_nodes = {}
976
+ superclasses = {}
977
+ includes = {}
978
+ paths.each do |path|
979
+ physical = buffer ? buffer.resolve(path) : path
980
+ root = Prism.parse(File.read(physical), filepath: path).value
981
+ build_discovered_def_nodes(root).each do |class_name, methods|
982
+ (def_nodes[class_name] ||= {}).merge!(methods)
983
+ end
984
+ superclasses.merge!(build_discovered_superclasses(root))
985
+ build_discovered_includes(root).each do |class_name, mods|
986
+ includes[class_name] = ((includes[class_name] || []) + mods).uniq
987
+ end
988
+ rescue StandardError
989
+ # Skip files that fail to parse or read; the per-file
990
+ # analyzer surfaces the parse error separately.
991
+ next
992
+ end
993
+ def_nodes.each_value(&:freeze)
994
+ includes.each_value(&:freeze)
995
+ { def_nodes: def_nodes.freeze, superclasses: superclasses.freeze, includes: includes.freeze }
996
+ end
997
+
848
998
  # Class-only variant of `record_declarations` — descends
849
999
  # into nested module bodies (so `module Foo; class Bar`
850
1000
  # registers `Foo::Bar`) but never registers the module
@@ -358,9 +358,9 @@ module Rigor
358
358
  # is the falsey edge of the predicate (subsequent
359
359
  # statements observe the predicate-was-false world).
360
360
  return [Type::Combinator.union(then_type, else_type), falsey_scope] \
361
- if branch_unconditionally_exits?(node.statements) && node.subsequent.nil?
361
+ if branch_terminates?(node.statements, then_type) && node.subsequent.nil?
362
362
  return [Type::Combinator.union(then_type, else_type), truthy_scope] \
363
- if branch_unconditionally_exits?(node.subsequent) && node.statements
363
+ if branch_terminates?(node.subsequent, else_type) && node.statements
364
364
 
365
365
  [
366
366
  Type::Combinator.union(then_type, else_type),
@@ -385,9 +385,9 @@ module Rigor
385
385
  # `if`: when the body unconditionally exits and there
386
386
  # is no else, the post-scope is the truthy edge.
387
387
  return [Type::Combinator.union(then_type, else_type), truthy_scope] \
388
- if branch_unconditionally_exits?(node.statements) && node.else_clause.nil?
388
+ if branch_terminates?(node.statements, then_type) && node.else_clause.nil?
389
389
  return [Type::Combinator.union(then_type, else_type), falsey_scope] \
390
- if branch_unconditionally_exits?(node.else_clause) && node.statements
390
+ if branch_terminates?(node.else_clause, else_type) && node.statements
391
391
 
392
392
  [
393
393
  Type::Combinator.union(then_type, else_type),
@@ -709,24 +709,25 @@ module Rigor
709
709
  # `a` when the RHS is skipped, while `a || b` can only produce
710
710
  # the truthy fragment of `a` when the RHS is skipped.
711
711
  #
712
- # When the RHS unconditionally exits (`raise` / `return` /
713
- # `throw` / `exit` / `abort` / `fail` / `next` / `break`), the
714
- # post-OR / post-AND scope is the LHS-skipped edge alone:
715
- # `a or raise` only survives when `a` was truthy, so subsequent
716
- # statements observe `a` narrowed to its truthy fragment; the
717
- # symmetric `a and raise` survives only when `a` was falsey.
718
- # Same shape as the `eval_if` / `eval_unless` early-return
719
- # narrowing.
712
+ # When the RHS is a terminating branch — it `raise`s /
713
+ # `return`s / `throw`s / `exit`s / `break`s / `next`s, OR its
714
+ # inferred type is `Bot` (ADR-24 WD6: a divergent helper such
715
+ # as `a or fail_with_message(...)`, recognised via
716
+ # `branch_terminates?`) the post-OR / post-AND scope is the
717
+ # LHS-skipped edge alone: `a or raise` only survives when `a`
718
+ # was truthy, so subsequent statements observe `a` narrowed to
719
+ # its truthy fragment; the symmetric `a and raise` survives
720
+ # only when `a` was falsey. Same shape as the `eval_if` /
721
+ # `eval_unless` early-return narrowing.
720
722
  def eval_and_or(node)
721
723
  left_type, left_scope = sub_eval(node.left, scope)
722
724
  truthy_left, falsey_left = Narrowing.predicate_scopes(node.left, left_scope)
723
725
  rhs_entry = node.is_a?(Prism::AndNode) ? truthy_left : falsey_left
724
- if branch_unconditionally_exits?(node.right)
725
- # Walk the RHS for side-effects (on_enter callbacks,
726
- # diagnostic dispatch on the raise / return expression
727
- # itself) but discard its scope: control never reaches
728
- # any statement after `a or raise` via that edge.
729
- sub_eval(node.right, rhs_entry)
726
+ right_type, right_scope = sub_eval(node.right, rhs_entry)
727
+
728
+ if branch_terminates?(node.right, right_type)
729
+ # Control never reaches any statement after `a or raise`
730
+ # via the RHS edge the RHS scope is discarded.
730
731
  surviving_type =
731
732
  if node.is_a?(Prism::AndNode)
732
733
  Narrowing.narrow_falsey(left_type)
@@ -737,7 +738,6 @@ module Rigor
737
738
  return [surviving_type, surviving_scope]
738
739
  end
739
740
 
740
- right_type, right_scope = sub_eval(node.right, rhs_entry)
741
741
  skipped_type =
742
742
  if node.is_a?(Prism::AndNode)
743
743
  Narrowing.narrow_falsey(left_type)
@@ -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
 
@@ -1484,8 +1485,9 @@ module Rigor
1484
1485
  # ScopeIndexer-populated declaration overrides
1485
1486
  # (`Prism::ConstantReadNode` for `module Foo` headers, etc.)
1486
1487
  # remain reachable from inside nested bodies.
1487
- def build_fresh_body_scope
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)
@@ -1493,6 +1495,9 @@ module Rigor
1493
1495
  .with_class_cvars(scope.class_cvars)
1494
1496
  .with_program_globals(scope.program_globals)
1495
1497
  .with_discovered_methods(scope.discovered_methods)
1498
+ .with_discovered_def_nodes(scope.discovered_def_nodes)
1499
+ .with_discovered_superclasses(scope.discovered_superclasses)
1500
+ .with_discovered_includes(scope.discovered_includes)
1496
1501
  .with_discovered_method_visibilities(scope.discovered_method_visibilities)
1497
1502
  end
1498
1503
 
@@ -1692,6 +1697,23 @@ module Rigor
1692
1697
  end
1693
1698
  end
1694
1699
 
1700
+ # ADR-24 WD6 / slice 3 — generalised terminating-branch
1701
+ # detection. `branch_unconditionally_exits?` recognises a
1702
+ # branch SYNTACTICALLY (return / next / break / a call
1703
+ # named raise / throw / exit / abort / fail). A branch
1704
+ # whose *inferred type is `Bot`* also terminates — it
1705
+ # cannot produce a value, so control never falls through
1706
+ # it — regardless of how it is spelled. The canonical
1707
+ # case is a resolved guard helper (`fail_with_message(...)`)
1708
+ # whose body always raises: ADR-24 slice 1 types the call
1709
+ # `bot`, and this OR-test makes `helper(...) if x.nil?`
1710
+ # narrow exactly like `raise ... if x.nil?`. The branch
1711
+ # type is already computed by `eval_if` / `eval_unless`.
1712
+ def branch_terminates?(branch_node, branch_type)
1713
+ branch_unconditionally_exits?(branch_node) ||
1714
+ branch_type.is_a?(Type::Bot)
1715
+ end
1716
+
1695
1717
  def eval_branch_or_nil(branch_node, branch_scope)
1696
1718
  return [Type::Combinator.constant_of(nil), branch_scope] if branch_node.nil?
1697
1719
 
@@ -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}"