rigortype 0.1.2 → 0.1.4

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 (116) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +135 -31
  3. data/lib/rigor/analysis/check_rules.rb +10 -18
  4. data/lib/rigor/analysis/dependency_source_inference/boundary_cross_reporter.rb +75 -0
  5. data/lib/rigor/analysis/dependency_source_inference/builder.rb +113 -0
  6. data/lib/rigor/analysis/dependency_source_inference/gem_resolver.rb +72 -0
  7. data/lib/rigor/analysis/dependency_source_inference/index.rb +139 -0
  8. data/lib/rigor/analysis/dependency_source_inference/walker.rb +200 -0
  9. data/lib/rigor/analysis/dependency_source_inference.rb +38 -0
  10. data/lib/rigor/analysis/diagnostic.rb +0 -2
  11. data/lib/rigor/analysis/fact_store.rb +11 -3
  12. data/lib/rigor/analysis/rule_catalog.rb +2 -2
  13. data/lib/rigor/analysis/runner.rb +206 -6
  14. data/lib/rigor/builtins/imported_refinements.rb +360 -55
  15. data/lib/rigor/cache/descriptor.rb +59 -6
  16. data/lib/rigor/cache/store.rb +1 -1
  17. data/lib/rigor/cli/diff_command.rb +1 -1
  18. data/lib/rigor/cli/sig_gen_command.rb +173 -0
  19. data/lib/rigor/cli/type_of_command.rb +1 -1
  20. data/lib/rigor/cli/type_scan_renderer.rb +1 -1
  21. data/lib/rigor/cli/type_scan_report.rb +2 -2
  22. data/lib/rigor/cli.rb +9 -1
  23. data/lib/rigor/configuration/dependencies.rb +235 -0
  24. data/lib/rigor/configuration.rb +45 -11
  25. data/lib/rigor/environment.rb +47 -4
  26. data/lib/rigor/flow_contribution/conflict.rb +2 -2
  27. data/lib/rigor/flow_contribution/element.rb +1 -1
  28. data/lib/rigor/flow_contribution/fact.rb +1 -1
  29. data/lib/rigor/flow_contribution/merge_result.rb +1 -1
  30. data/lib/rigor/flow_contribution/merger.rb +7 -3
  31. data/lib/rigor/flow_contribution.rb +2 -2
  32. data/lib/rigor/inference/block_parameter_binder.rb +0 -2
  33. data/lib/rigor/inference/coverage_scanner.rb +1 -1
  34. data/lib/rigor/inference/expression_typer.rb +67 -11
  35. data/lib/rigor/inference/fallback.rb +1 -1
  36. data/lib/rigor/inference/method_dispatcher/block_folding.rb +3 -5
  37. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +0 -12
  38. data/lib/rigor/inference/method_dispatcher/iterator_dispatch.rb +1 -3
  39. data/lib/rigor/inference/method_dispatcher/literal_string_folding.rb +1 -1
  40. data/lib/rigor/inference/method_dispatcher/method_folding.rb +118 -0
  41. data/lib/rigor/inference/method_dispatcher/overload_selector.rb +6 -11
  42. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +27 -11
  43. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +0 -4
  44. data/lib/rigor/inference/method_dispatcher.rb +233 -2
  45. data/lib/rigor/inference/method_parameter_binder.rb +1 -3
  46. data/lib/rigor/inference/narrowing.rb +2 -4
  47. data/lib/rigor/inference/rbs_type_translator.rb +0 -2
  48. data/lib/rigor/inference/scope_indexer.rb +14 -9
  49. data/lib/rigor/inference/statement_evaluator.rb +70 -6
  50. data/lib/rigor/plugin/io_boundary.rb +0 -2
  51. data/lib/rigor/plugin/loader.rb +2 -2
  52. data/lib/rigor/plugin/manifest.rb +49 -7
  53. data/lib/rigor/plugin/registry.rb +11 -0
  54. data/lib/rigor/plugin/services.rb +1 -1
  55. data/lib/rigor/plugin/type_node_resolver.rb +52 -0
  56. data/lib/rigor/plugin.rb +1 -0
  57. data/lib/rigor/rbs_extended/reporter.rb +91 -0
  58. data/lib/rigor/rbs_extended.rb +131 -32
  59. data/lib/rigor/scope.rb +25 -8
  60. data/lib/rigor/sig_gen/classification.rb +36 -0
  61. data/lib/rigor/sig_gen/generator.rb +1048 -0
  62. data/lib/rigor/sig_gen/layout_index.rb +108 -0
  63. data/lib/rigor/sig_gen/method_candidate.rb +62 -0
  64. data/lib/rigor/sig_gen/observation_collector.rb +391 -0
  65. data/lib/rigor/sig_gen/observed_call.rb +62 -0
  66. data/lib/rigor/sig_gen/path_mapper.rb +116 -0
  67. data/lib/rigor/sig_gen/renderer.rb +157 -0
  68. data/lib/rigor/sig_gen/type_elaborator.rb +92 -0
  69. data/lib/rigor/sig_gen/write_result.rb +48 -0
  70. data/lib/rigor/sig_gen/writer.rb +530 -0
  71. data/lib/rigor/sig_gen.rb +25 -0
  72. data/lib/rigor/type/bound_method.rb +79 -0
  73. data/lib/rigor/type/combinator.rb +195 -2
  74. data/lib/rigor/type/constant.rb +13 -0
  75. data/lib/rigor/type/hash_shape.rb +0 -2
  76. data/lib/rigor/type/union.rb +20 -1
  77. data/lib/rigor/type.rb +1 -0
  78. data/lib/rigor/type_node/generic.rb +62 -0
  79. data/lib/rigor/type_node/identifier.rb +30 -0
  80. data/lib/rigor/type_node/indexed_access.rb +41 -0
  81. data/lib/rigor/type_node/integer_literal.rb +29 -0
  82. data/lib/rigor/type_node/name_scope.rb +52 -0
  83. data/lib/rigor/type_node/resolver_chain.rb +56 -0
  84. data/lib/rigor/type_node/string_literal.rb +29 -0
  85. data/lib/rigor/type_node/symbol_literal.rb +28 -0
  86. data/lib/rigor/type_node/union.rb +42 -0
  87. data/lib/rigor/type_node.rb +29 -0
  88. data/lib/rigor/version.rb +1 -1
  89. data/lib/rigor.rb +2 -0
  90. data/sig/rigor/analysis/check_rules/always_truthy_condition_collector.rbs +10 -0
  91. data/sig/rigor/analysis/check_rules/dead_assignment_collector.rbs +10 -0
  92. data/sig/rigor/analysis/dependency_source_inference/gem_resolver.rbs +25 -0
  93. data/sig/rigor/analysis/dependency_source_inference/index.rbs +9 -0
  94. data/sig/rigor/cli/diff_command.rbs +4 -0
  95. data/sig/rigor/cli/explain_command.rbs +4 -0
  96. data/sig/rigor/cli/sig_gen_command.rbs +4 -0
  97. data/sig/rigor/cli/type_scan_command.rbs +3 -0
  98. data/sig/rigor/environment.rbs +6 -2
  99. data/sig/rigor/inference/builtins/method_catalog.rbs +4 -0
  100. data/sig/rigor/inference/builtins/numeric_catalog.rbs +3 -0
  101. data/sig/rigor/inference/builtins.rbs +2 -0
  102. data/sig/rigor/plugin/access_denied_error.rbs +3 -0
  103. data/sig/rigor/plugin/base.rbs +6 -0
  104. data/sig/rigor/plugin/fact_store.rbs +11 -0
  105. data/sig/rigor/plugin/io_boundary.rbs +4 -0
  106. data/sig/rigor/plugin/load_error.rbs +6 -0
  107. data/sig/rigor/plugin/loader.rbs +20 -0
  108. data/sig/rigor/plugin/manifest.rbs +9 -0
  109. data/sig/rigor/plugin/registry.rbs +3 -0
  110. data/sig/rigor/plugin/services.rbs +3 -0
  111. data/sig/rigor/plugin/trust_policy.rbs +4 -0
  112. data/sig/rigor/plugin/type_node_resolver.rbs +3 -0
  113. data/sig/rigor/plugin.rbs +8 -0
  114. data/sig/rigor/scope.rbs +4 -2
  115. data/sig/rigor/type.rbs +28 -6
  116. metadata +58 -1
@@ -785,6 +785,7 @@ module Rigor
785
785
  evaluate_block_if_present(node)
786
786
  post_scope = record_closure_escape_if_any(node)
787
787
  post_scope = apply_rbs_extended_assertions(node, post_scope)
788
+ post_scope = apply_plugin_assertions(node, post_scope)
788
789
  post_scope = apply_rspec_matcher_narrowing(node, post_scope)
789
790
  [call_type, post_scope]
790
791
  end
@@ -888,7 +889,7 @@ module Rigor
888
889
  # or nil otherwise. Centralised so each per-matcher
889
890
  # decoder can short-circuit on a non-matching outer
890
891
  # call.
891
- def rspec_expectation_target(call_node) # rubocop:disable Metrics/CyclomaticComplexity
892
+ def rspec_expectation_target(call_node)
892
893
  receiver = call_node.receiver
893
894
  return nil unless receiver.is_a?(Prism::CallNode) && receiver.name == :expect
894
895
  return nil unless receiver.receiver.nil?
@@ -966,7 +967,7 @@ module Rigor
966
967
  method_def = resolve_call_method(call_node, current_scope)
967
968
  return current_scope if method_def.nil?
968
969
 
969
- contribution = RbsExtended.read_flow_contribution(method_def)
970
+ contribution = RbsExtended.read_flow_contribution(method_def, environment: current_scope.environment)
970
971
  return current_scope if contribution.nil?
971
972
 
972
973
  result = Rigor::FlowContribution::Merger.merge([contribution])
@@ -978,7 +979,62 @@ module Rigor
978
979
  end
979
980
  end
980
981
 
981
- def resolve_call_method(call_node, current_scope) # rubocop:disable Metrics/PerceivedComplexity
982
+ # ADR-7 § "Slice 4-A" / T.bind priority slice 2 — applies
983
+ # the post-return facts plugin contributions produce. This
984
+ # is the sibling of {apply_rbs_extended_assertions}: the
985
+ # carrier (`Rigor::FlowContribution::Fact`) and the
986
+ # downstream narrowing path (`apply_post_return_fact` →
987
+ # `apply_self_post_return_fact`) are the same; only the
988
+ # *source* of the bundle changes (RBS::Extended vs the
989
+ # registered plugins' `flow_contribution_for`).
990
+ #
991
+ # `:self`-targeted facts narrow `scope.self_type` for the
992
+ # surrounding scope. In a block body, the surrounding
993
+ # scope is the block's own scope, so the narrowing applies
994
+ # to the rest of the block — exactly the contract Sorbet's
995
+ # `T.bind(self, T)` commits to.
996
+ #
997
+ # `:parameter`-targeted facts only land when the called
998
+ # method has an authoritative RBS sig (via
999
+ # `resolve_call_method`); plugins recognising their own
1000
+ # synthetic call shapes (e.g. `T.assert_type!`) have no
1001
+ # method_def and the parameter facts silently skip — the
1002
+ # plugin's own diagnostics_for_file path covers those
1003
+ # cases. The full plugin-side parameter-targeting story
1004
+ # (PHPStan-style Type-Specifying Extensions on
1005
+ # plugin-recognised calls) lives behind a follow-up slice
1006
+ # that introduces `:local` / `:argument_at` target kinds.
1007
+ def apply_plugin_assertions(call_node, current_scope)
1008
+ registry = current_scope.environment&.plugin_registry
1009
+ return current_scope if registry.nil? || registry.empty?
1010
+
1011
+ contributions = collect_plugin_contributions(registry, call_node, current_scope)
1012
+ return current_scope if contributions.empty?
1013
+
1014
+ result = Rigor::FlowContribution::Merger.merge(contributions)
1015
+ post_return = result.post_return_facts
1016
+ return current_scope if post_return.empty?
1017
+
1018
+ method_def = resolve_call_method(call_node, current_scope)
1019
+ post_return.reduce(current_scope) do |scope_acc, fact|
1020
+ apply_post_return_fact(fact, call_node, scope_acc, method_def)
1021
+ end
1022
+ end
1023
+
1024
+ # Walks the registry and collects each plugin's
1025
+ # `flow_contribution_for` result, swallowing per-plugin
1026
+ # exceptions so a buggy plugin can't abort the assertion
1027
+ # path. Mirrors `MethodDispatcher.collect_plugin_contributions`
1028
+ # exactly — the two paths consume the same hook.
1029
+ def collect_plugin_contributions(registry, call_node, current_scope)
1030
+ registry.plugins.filter_map do |plugin|
1031
+ plugin.flow_contribution_for(call_node: call_node, scope: current_scope)
1032
+ rescue StandardError
1033
+ nil
1034
+ end
1035
+ end
1036
+
1037
+ def resolve_call_method(call_node, current_scope)
982
1038
  receiver_node = call_node.receiver
983
1039
  receiver_type =
984
1040
  if receiver_node
@@ -1065,6 +1121,14 @@ module Rigor
1065
1121
  end
1066
1122
 
1067
1123
  def lookup_post_return_arg(call_node, method_def, target_name)
1124
+ # Plugin-source contributions arrive without an
1125
+ # authoritative method_def (the plugin recognised the
1126
+ # call shape directly). Parameter-targeting falls back
1127
+ # to "no narrow" in that case — the wider plugin-side
1128
+ # parameter mapping (`:local` / `:argument_at`) is a
1129
+ # follow-up slice.
1130
+ return nil if method_def.nil?
1131
+
1068
1132
  arguments = call_node.arguments&.arguments || []
1069
1133
  method_def.method_types.each do |mt|
1070
1134
  params = mt.type.required_positionals + mt.type.optional_positionals
@@ -1330,6 +1394,8 @@ module Rigor
1330
1394
  .with_class_ivars(scope.class_ivars)
1331
1395
  .with_class_cvars(scope.class_cvars)
1332
1396
  .with_program_globals(scope.program_globals)
1397
+ .with_discovered_methods(scope.discovered_methods)
1398
+ .with_discovered_method_visibilities(scope.discovered_method_visibilities)
1333
1399
  end
1334
1400
 
1335
1401
  def singleton_def?(def_node)
@@ -1437,7 +1503,7 @@ module Rigor
1437
1503
  EXIT_CALL_NAMES = %i[raise throw exit abort fail].freeze
1438
1504
  private_constant :EXIT_CALL_NAMES
1439
1505
 
1440
- def branch_unconditionally_exits?(node) # rubocop:disable Metrics/CyclomaticComplexity
1506
+ def branch_unconditionally_exits?(node)
1441
1507
  return false if node.nil?
1442
1508
 
1443
1509
  case node
@@ -1543,7 +1609,6 @@ module Rigor
1543
1609
  # Returns an array of `[Symbol, Rigor::Type]` pairs for every
1544
1610
  # variable captured by `pattern`. Unrecognised pattern nodes
1545
1611
  # contribute no bindings (fail-soft).
1546
- # rubocop:disable Metrics/CyclomaticComplexity
1547
1612
  def collect_in_pattern_bindings(subject, pattern, scope)
1548
1613
  case pattern
1549
1614
  when Prism::CapturePatternNode
@@ -1565,7 +1630,6 @@ module Rigor
1565
1630
  []
1566
1631
  end
1567
1632
  end
1568
- # rubocop:enable Metrics/CyclomaticComplexity
1569
1633
 
1570
1634
  def collect_array_pattern_bindings(pattern, scope)
1571
1635
  bindings = [*pattern.requireds, *pattern.posts].flat_map do |elem|
@@ -137,7 +137,6 @@ module Rigor
137
137
  # to the same `#get(url, timeout:, max_bytes:)` shape so the
138
138
  # tests don't require network access.
139
139
  class DefaultHttpClient
140
- # rubocop:disable Metrics/MethodLength
141
140
  def get(url, timeout:, max_bytes:)
142
141
  require "net/http"
143
142
  require "uri"
@@ -169,7 +168,6 @@ module Rigor
169
168
  end
170
169
  body
171
170
  end
172
- # rubocop:enable Metrics/MethodLength
173
171
  end
174
172
  end
175
173
  end
@@ -81,7 +81,7 @@ module Rigor
81
81
  # "rigor-rails"
82
82
  # { "gem" => "rigor-rails", "id" => "rails", "config" => {...} }
83
83
  # { gem: "rigor-rails", id: "rails", config: {...} }
84
- def normalise_entry(raw, index) # rubocop:disable Metrics/CyclomaticComplexity
84
+ def normalise_entry(raw, index)
85
85
  case raw
86
86
  when String
87
87
  { gem: raw, id: nil, config: {} }
@@ -136,7 +136,7 @@ module Rigor
136
136
  )
137
137
  end
138
138
 
139
- def lookup_plugin_class!(entry, newly_registered) # rubocop:disable Metrics/AbcSize,Metrics/MethodLength
139
+ def lookup_plugin_class!(entry, newly_registered)
140
140
  if entry[:id]
141
141
  plugin_class = Plugin.registered_for(entry[:id])
142
142
  unless plugin_class
@@ -11,7 +11,7 @@ module Rigor
11
11
  # The fields are pinned by ADR-2 § "Registration, Configuration,
12
12
  # and Caching"; the v0.1.0 plugin contract surface treats this
13
13
  # struct as the public manifest shape.
14
- class Manifest # rubocop:disable Metrics/ClassLength
14
+ class Manifest
15
15
  # Same regex {Rigor::Cache::Store::VALID_PRODUCER_ID} uses,
16
16
  # so plugin ids round-trip through cache producer ids and
17
17
  # `plugin.<id>.<rule>` diagnostic identifiers without escape.
@@ -31,32 +31,38 @@ module Rigor
31
31
  # topological sort + missing-producer detection (slice 5);
32
32
  # slice 4 carries the declarations on the manifest but the
33
33
  # loader does not yet enforce them.
34
- Consumption = Data.define(:plugin_id, :name, :optional) do
34
+ class Consumption < Data.define(:plugin_id, :name, :optional)
35
35
  def initialize(plugin_id:, name:, optional: false)
36
36
  super(plugin_id: plugin_id.to_s, name: name.to_sym, optional: optional ? true : false)
37
37
  end
38
38
  end
39
39
 
40
- attr_reader :id, :version, :description, :protocols, :config_schema, :produces, :consumes
40
+ attr_reader :id, :version, :description, :protocols, :config_schema, :produces, :consumes,
41
+ :owns_receivers, :type_node_resolvers
41
42
 
42
43
  def initialize( # rubocop:disable Metrics/ParameterLists
43
44
  id:, version:,
44
45
  description: nil, protocols: [], config_schema: {},
45
- produces: [], consumes: []
46
+ produces: [], consumes: [], owns_receivers: [], type_node_resolvers: []
46
47
  )
47
48
  validate_id!(id)
48
49
  validate_version!(version)
49
50
  validate_protocols!(protocols)
50
51
  validate_config_schema!(config_schema)
51
52
  validate_produces!(produces)
53
+ validate_owns_receivers!(owns_receivers)
54
+ validate_type_node_resolvers!(type_node_resolvers)
52
55
 
53
- assign_fields(id, version, description, protocols, config_schema, produces, consumes)
56
+ assign_fields(id, version, description, protocols, config_schema, produces, consumes, owns_receivers,
57
+ type_node_resolvers)
54
58
  freeze
55
59
  end
56
60
 
57
61
  private
58
62
 
59
- def assign_fields(id, version, description, protocols, config_schema, produces, consumes) # rubocop:disable Metrics/ParameterLists
63
+ # rubocop:disable Metrics/ParameterLists
64
+ def assign_fields(id, version, description, protocols, config_schema, produces, consumes, owns_receivers,
65
+ type_node_resolvers)
60
66
  @id = id.dup.freeze
61
67
  @version = version.dup.freeze
62
68
  @description = description.nil? ? nil : description.to_s.dup.freeze
@@ -64,7 +70,10 @@ module Rigor
64
70
  @config_schema = config_schema.to_h { |k, v| [k.to_s.dup.freeze, v.to_sym] }.freeze
65
71
  @produces = produces.map(&:to_sym).freeze
66
72
  @consumes = coerce_consumes(consumes)
73
+ @owns_receivers = owns_receivers.map { |c| c.to_s.dup.freeze }.freeze
74
+ @type_node_resolvers = type_node_resolvers.dup.freeze
67
75
  end
76
+ # rubocop:enable Metrics/ParameterLists
68
77
 
69
78
  public
70
79
 
@@ -99,7 +108,9 @@ module Rigor
99
108
  "protocols" => protocols.map(&:to_s),
100
109
  "config_schema" => config_schema.to_h { |k, v| [k, v.to_s] },
101
110
  "produces" => produces.map(&:to_s),
102
- "consumes" => consumes.map { |c| consumption_hash(c) }
111
+ "consumes" => consumes.map { |c| consumption_hash(c) },
112
+ "owns_receivers" => owns_receivers,
113
+ "type_node_resolvers" => type_node_resolvers.map { |r| r.class.name }
103
114
  }
104
115
  end
105
116
 
@@ -166,6 +177,37 @@ module Rigor
166
177
  raise ArgumentError, "plugin manifest produces must be an Array of Symbol/String, got #{produces.inspect}"
167
178
  end
168
179
 
180
+ # ADR-10 5a — `owns_receivers:` declares the class names
181
+ # this plugin claims sole ownership of. The dispatcher's
182
+ # dependency-source-inference tier consults this list
183
+ # before consulting its own catalog: receivers owned by a
184
+ # registered plugin (directly or via subclass) decline,
185
+ # so plugin contributions stay authoritative for those
186
+ # types.
187
+ def validate_owns_receivers!(owns_receivers)
188
+ return if owns_receivers.is_a?(Array) && owns_receivers.all? { |c| c.is_a?(String) && !c.empty? }
189
+
190
+ raise ArgumentError,
191
+ "plugin manifest owns_receivers must be an Array of non-empty String, " \
192
+ "got #{owns_receivers.inspect}"
193
+ end
194
+
195
+ # ADR-13 slice 2 — `type_node_resolvers:` declares the
196
+ # plugin-supplied `TypeNodeResolver` instances the parser
197
+ # consults (in slice 3) when an RBS::Extended payload's
198
+ # named- or generic-type head misses the built-in registry.
199
+ # Slice 2 carries the declarations on the manifest and the
200
+ # registry exposes them in registration order; the parser
201
+ # integration that actually drives the chain lands in
202
+ # slice 3.
203
+ def validate_type_node_resolvers!(resolvers)
204
+ return if resolvers.is_a?(Array) && resolvers.all?(TypeNodeResolver)
205
+
206
+ raise ArgumentError,
207
+ "plugin manifest type_node_resolvers must be an Array of " \
208
+ "Rigor::Plugin::TypeNodeResolver instances, got #{resolvers.inspect}"
209
+ end
210
+
169
211
  def coerce_consumes(consumes)
170
212
  unless consumes.is_a?(Array)
171
213
  raise ArgumentError, "plugin manifest consumes must be an Array, got #{consumes.inspect}"
@@ -44,6 +44,17 @@ module Rigor
44
44
  !load_errors.empty?
45
45
  end
46
46
 
47
+ # ADR-13 slice 2 — flat ordered list of every loaded
48
+ # plugin's manifest-declared {TypeNodeResolver} instances,
49
+ # in plugin registration order. Slice 3 wires this into
50
+ # the parser's resolver chain; until then the method is a
51
+ # read-side aggregator only. The first non-nil
52
+ # `#resolve(node, scope)` return wins per ADR-13 WD3 / WD5
53
+ # — registration order is the user's lever.
54
+ def type_node_resolvers
55
+ plugins.flat_map { |plugin| plugin.manifest.type_node_resolvers }
56
+ end
57
+
47
58
  EMPTY = new.freeze
48
59
  end
49
60
  end
@@ -42,7 +42,7 @@ module Rigor
42
42
  class Services
43
43
  attr_reader :reflection, :type, :configuration, :cache_store, :trust_policy, :fact_store
44
44
 
45
- def initialize( # rubocop:disable Metrics/ParameterLists
45
+ def initialize(
46
46
  reflection:, type:, configuration:,
47
47
  cache_store: nil, trust_policy: nil, fact_store: nil
48
48
  )
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rigor
4
+ module Plugin
5
+ # Plugin-supplied resolver for custom named / generic type
6
+ # vocabulary in RBS::Extended payloads. ADR-13 § "Decision".
7
+ #
8
+ # Subclasses override {#resolve} to return a
9
+ # {Rigor::Type::Base} when the node matches the vocabulary
10
+ # the resolver covers, or `nil` to fall through to the next
11
+ # resolver in the chain (and finally to the built-in / RBS
12
+ # fallback). The base implementation returns `nil` so an
13
+ # unimplemented subclass is a safe no-op.
14
+ #
15
+ # Resolvers are registered through their plugin's manifest
16
+ # under the `type_node_resolvers:` slot:
17
+ #
18
+ # class RigorTypescriptUtilityTypes < Rigor::Plugin::Base
19
+ # manifest(
20
+ # id: "typescript-utility-types",
21
+ # version: "0.1.0",
22
+ # type_node_resolvers: [Resolvers::Pick.new,
23
+ # Resolvers::Omit.new]
24
+ # )
25
+ # end
26
+ #
27
+ # Slice 2 of the ADR-13 envelope (this file) ships the base
28
+ # class + manifest hook + registry aggregation. The parser-
29
+ # side wiring that actually consults the resolver chain
30
+ # arrives in slice 3, when {Rigor::TypeNode::NameScope} and
31
+ # the dispatcher between {Rigor::Builtins::ImportedRefinements::Parser}
32
+ # and the chain land. Until then resolvers can be unit-tested
33
+ # in isolation but never run for a real `%a{rigor:v1:...}`
34
+ # payload.
35
+ #
36
+ # Resolvers SHOULD be stateless and re-entrant; the registry
37
+ # builds the chain once per `Analysis::Runner.run` and may
38
+ # consult any resolver multiple times for the same node.
39
+ class TypeNodeResolver
40
+ # @param node [Rigor::TypeNode::Identifier, Rigor::TypeNode::Generic]
41
+ # the parser-emitted node the chain is asking about.
42
+ # @param scope [Rigor::TypeNode::NameScope] companion
43
+ # value object (slice 3); slice 2 invocations MAY pass
44
+ # `nil` because the chain doesn't exist yet.
45
+ # @return [Rigor::Type::Base, nil] resolved type, or `nil`
46
+ # to fall through.
47
+ def resolve(node, scope) # rubocop:disable Lint/UnusedMethodArgument
48
+ nil
49
+ end
50
+ end
51
+ end
52
+ end
data/lib/rigor/plugin.rb CHANGED
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "plugin/type_node_resolver"
3
4
  require_relative "plugin/manifest"
4
5
  require_relative "plugin/access_denied_error"
5
6
  require_relative "plugin/trust_policy"
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rigor
4
+ module RbsExtended
5
+ # ADR-13 slice 3b — per-run accumulator for `RBS::Extended`
6
+ # diagnostic events that the parser / resolver cannot surface
7
+ # at the point of failure (the parsers are fail-soft, returning
8
+ # `nil` so call sites fall back to the RBS-declared type).
9
+ #
10
+ # Owns two event streams:
11
+ #
12
+ # - `#unresolved_payloads` — `rigor:v1:*` directive payloads
13
+ # the resolver could not turn into a {Rigor::Type}. Surface
14
+ # as `dynamic.rbs-extended.unresolved` `:info` diagnostics.
15
+ # - `#lossy_projections` — shape-projection type functions
16
+ # (`pick_of` / `omit_of` / `partial_of` / `required_of` /
17
+ # `readonly_of`) applied to a carrier that does not preserve
18
+ # shape information (anything other than `Type::HashShape`
19
+ # / `Type::Tuple`). Surface as
20
+ # `dynamic.shape.lossy-projection` `:info` diagnostics.
21
+ #
22
+ # Mutable through the run; consumed once by
23
+ # {Rigor::Analysis::Runner} at end-of-run. Each event is
24
+ # deduplicated by `(payload, source_location)` for unresolved
25
+ # and `(head, source_location)` for lossy-projection so a
26
+ # single annotation read from many call sites yields one
27
+ # diagnostic.
28
+ #
29
+ # The reporter is intentionally thread-safe via a coarse
30
+ # `Mutex` because the inference engine may read the same
31
+ # method definition from multiple files in parallel; the
32
+ # critical sections are short (Array#include? + Array#<<) so
33
+ # the lock contention is negligible.
34
+ class Reporter
35
+ UnresolvedEntry = Data.define(:payload, :source_location)
36
+ LossyProjectionEntry = Data.define(:head, :source_location)
37
+
38
+ def initialize
39
+ @unresolved_payloads = []
40
+ @lossy_projections = []
41
+ @mutex = Mutex.new
42
+ end
43
+
44
+ # @return [Array<UnresolvedEntry>] frozen snapshot of the
45
+ # accumulated unresolved-payload events.
46
+ def unresolved_payloads
47
+ @mutex.synchronize { @unresolved_payloads.dup.freeze }
48
+ end
49
+
50
+ # @return [Array<LossyProjectionEntry>] frozen snapshot of
51
+ # the accumulated lossy-projection events.
52
+ def lossy_projections
53
+ @mutex.synchronize { @lossy_projections.dup.freeze }
54
+ end
55
+
56
+ # Records a `dynamic.rbs-extended.unresolved` event. The
57
+ # `source_location` argument is the {RBS::Location} attached
58
+ # to the source annotation (or `nil` when the caller doesn't
59
+ # have one — the diagnostic falls back to a generic
60
+ # location in that case).
61
+ def record_unresolved(payload:, source_location: nil)
62
+ entry = UnresolvedEntry.new(payload: payload.to_s, source_location: source_location)
63
+ @mutex.synchronize do
64
+ return if @unresolved_payloads.include?(entry)
65
+
66
+ @unresolved_payloads << entry
67
+ end
68
+ end
69
+
70
+ # Records a `dynamic.shape.lossy-projection` event for one
71
+ # of the five shape-projection heads. `head` MUST be a
72
+ # String (`"pick_of"`, `"omit_of"`, …); the diagnostic
73
+ # message identifies which projection degraded.
74
+ def record_lossy_projection(head:, source_location: nil)
75
+ entry = LossyProjectionEntry.new(head: head.to_s, source_location: source_location)
76
+ @mutex.synchronize do
77
+ return if @lossy_projections.include?(entry)
78
+
79
+ @lossy_projections << entry
80
+ end
81
+ end
82
+
83
+ # True when no events have accumulated. Used by callers
84
+ # that want to skip the diagnostic-emission pass entirely
85
+ # on the common no-event path.
86
+ def empty?
87
+ @mutex.synchronize { @unresolved_payloads.empty? && @lossy_projections.empty? }
88
+ end
89
+ end
90
+ end
91
+ end