rigortype 0.1.8 → 0.1.10

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 (58) 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/analysis/runner.rb +67 -9
  5. data/lib/rigor/analysis/worker_session.rb +13 -4
  6. data/lib/rigor/cache/rbs_descriptor.rb +21 -2
  7. data/lib/rigor/cache/rbs_environment.rb +2 -1
  8. data/lib/rigor/cli/annotate_command.rb +274 -0
  9. data/lib/rigor/cli/baseline_command.rb +36 -16
  10. data/lib/rigor/cli/coverage_command.rb +126 -0
  11. data/lib/rigor/cli/coverage_renderer.rb +162 -0
  12. data/lib/rigor/cli/coverage_report.rb +75 -0
  13. data/lib/rigor/cli/mcp_command.rb +70 -0
  14. data/lib/rigor/cli/prism_colorizer.rb +111 -0
  15. data/lib/rigor/cli.rb +134 -6
  16. data/lib/rigor/environment/rbs_loader.rb +46 -5
  17. data/lib/rigor/environment/reporters.rb +3 -2
  18. data/lib/rigor/environment.rb +168 -5
  19. data/lib/rigor/inference/builtins/method_catalog.rb +17 -1
  20. data/lib/rigor/inference/builtins/time_catalog.rb +10 -1
  21. data/lib/rigor/inference/def_return_typer.rb +98 -0
  22. data/lib/rigor/inference/expression_typer.rb +308 -18
  23. data/lib/rigor/inference/method_dispatcher/cgi_folding.rb +109 -0
  24. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +178 -10
  25. data/lib/rigor/inference/method_dispatcher/kernel_dispatch.rb +53 -1
  26. data/lib/rigor/inference/method_dispatcher/literal_string_folding.rb +62 -15
  27. data/lib/rigor/inference/method_dispatcher/math_folding.rb +149 -0
  28. data/lib/rigor/inference/method_dispatcher/overload_selector.rb +20 -1
  29. data/lib/rigor/inference/method_dispatcher/regexp_folding.rb +81 -0
  30. data/lib/rigor/inference/method_dispatcher/set_folding.rb +81 -0
  31. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +431 -9
  32. data/lib/rigor/inference/method_dispatcher/shellwords_folding.rb +126 -0
  33. data/lib/rigor/inference/method_dispatcher/time_folding.rb +56 -0
  34. data/lib/rigor/inference/method_dispatcher/uri_folding.rb +67 -0
  35. data/lib/rigor/inference/method_dispatcher.rb +148 -1
  36. data/lib/rigor/inference/method_parameter_binder.rb +67 -10
  37. data/lib/rigor/inference/narrowing.rb +29 -10
  38. data/lib/rigor/inference/precision_scanner.rb +131 -0
  39. data/lib/rigor/inference/statement_evaluator.rb +29 -3
  40. data/lib/rigor/mcp/loop.rb +43 -0
  41. data/lib/rigor/mcp/server.rb +263 -0
  42. data/lib/rigor/mcp.rb +16 -0
  43. data/lib/rigor/plugin/base.rb +67 -5
  44. data/lib/rigor/plugin/loader.rb +22 -1
  45. data/lib/rigor/plugin/manifest.rb +101 -10
  46. data/lib/rigor/plugin/protocol_contract.rb +185 -0
  47. data/lib/rigor/plugin/registry.rb +87 -0
  48. data/lib/rigor/plugin/source_rbs_synthesis_reporter.rb +51 -0
  49. data/lib/rigor/sig_gen/generator.rb +150 -75
  50. data/lib/rigor/triage/catalogue.rb +2 -2
  51. data/lib/rigor/type/combinator.rb +57 -0
  52. data/lib/rigor/type/constant.rb +29 -2
  53. data/lib/rigor/version.rb +1 -1
  54. data/sig/rigor/analysis/baseline.rbs +39 -0
  55. data/sig/rigor/environment.rbs +3 -2
  56. data/sig/rigor/type.rbs +4 -0
  57. data/sig/rigor.rbs +2 -0
  58. metadata +42 -1
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Rigor
6
+ module MCP
7
+ # Reads newline-delimited JSON-RPC 2.0 messages from `input`,
8
+ # dispatches each to `server`, and writes responses to `output`.
9
+ # Runs until input reaches EOF (the client closes the connection).
10
+ class Loop
11
+ def initialize(input:, output:, server:)
12
+ @input = input
13
+ @output = output
14
+ @server = server
15
+ end
16
+
17
+ def run
18
+ @input.each_line do |raw|
19
+ line = raw.chomp
20
+ next if line.empty?
21
+
22
+ begin
23
+ request = JSON.parse(line)
24
+ rescue JSON::ParserError => e
25
+ write_response({ "jsonrpc" => "2.0", "id" => nil,
26
+ "error" => { "code" => -32_700, "message" => "Parse error: #{e.message}" } })
27
+ next
28
+ end
29
+
30
+ response = @server.handle(request)
31
+ write_response(response) if response
32
+ end
33
+ end
34
+
35
+ private
36
+
37
+ def write_response(response)
38
+ @output.puts(JSON.generate(response))
39
+ @output.flush
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,263 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "stringio"
5
+
6
+ module Rigor
7
+ module MCP
8
+ # JSON-RPC 2.0 dispatcher for the MCP server.
9
+ #
10
+ # Each public `handle` call takes a parsed request hash and returns
11
+ # a response hash (or nil for notifications that require no reply).
12
+ # Tool implementations delegate to `CLI.new(argv, out:, err:).run`
13
+ # with StringIO capture — every tool stays in sync with its CLI
14
+ # counterpart automatically (ADR-33 WD4).
15
+ class Server # rubocop:disable Metrics/ClassLength
16
+ PROTOCOL_VERSION = "2024-11-05"
17
+
18
+ TOOLS = [
19
+ {
20
+ name: "rigor_check",
21
+ description: "Analyze Ruby files for type errors, undefined methods, arity mismatches, " \
22
+ "and nil-receiver risks. Returns a JSON diagnostic report.",
23
+ inputSchema: {
24
+ type: "object",
25
+ properties: {
26
+ paths: {
27
+ type: "array",
28
+ items: { type: "string" },
29
+ description: "Files or directories to analyze (required)"
30
+ },
31
+ config: { type: "string", description: "Path to .rigor.yml (optional)" }
32
+ },
33
+ required: ["paths"]
34
+ }
35
+ },
36
+ {
37
+ name: "rigor_type_of",
38
+ description: "Get the inferred type of the expression at a specific location in a Ruby file.",
39
+ inputSchema: {
40
+ type: "object",
41
+ properties: {
42
+ file: { type: "string", description: "Path to the Ruby file" },
43
+ line: { type: "integer", description: "1-based line number" },
44
+ col: { type: "integer", description: "1-based column number" },
45
+ config: { type: "string", description: "Path to .rigor.yml (optional)" }
46
+ },
47
+ required: %w[file line col]
48
+ }
49
+ },
50
+ {
51
+ name: "rigor_triage",
52
+ description: "Summarize a project's diagnostics: rule distribution, per-file hotspots, " \
53
+ "and heuristic hints for the most common error clusters. Returns JSON. " \
54
+ "Useful for understanding the shape of a diagnostic set before deciding what to fix.",
55
+ inputSchema: {
56
+ type: "object",
57
+ properties: {
58
+ paths: {
59
+ type: "array",
60
+ items: { type: "string" },
61
+ description: "Files or directories to analyze (defaults to configured paths)"
62
+ },
63
+ top: { type: "integer", description: "Number of hotspot files to include (default: 10)" },
64
+ config: { type: "string", description: "Path to .rigor.yml (optional)" }
65
+ }
66
+ }
67
+ },
68
+ {
69
+ name: "rigor_annotate",
70
+ description: "Return the given Ruby source file with each line's last-expression type " \
71
+ "appended as a comment. Useful for understanding how Rigor infers types " \
72
+ "through a file.",
73
+ inputSchema: {
74
+ type: "object",
75
+ properties: {
76
+ file: { type: "string", description: "Path to the Ruby file to annotate" },
77
+ config: { type: "string", description: "Path to .rigor.yml (optional)" }
78
+ },
79
+ required: ["file"]
80
+ }
81
+ },
82
+ {
83
+ name: "rigor_sig_gen",
84
+ description: "Generate RBS skeleton signatures inferred from Ruby source files. " \
85
+ "Returns a JSON report of candidates with their classifications " \
86
+ "(new-file, new-method, tighter-return, equivalent, skipped).",
87
+ inputSchema: {
88
+ type: "object",
89
+ properties: {
90
+ paths: {
91
+ type: "array",
92
+ items: { type: "string" },
93
+ description: "Files or directories to generate signatures for (defaults to configured paths)"
94
+ },
95
+ params: {
96
+ type: "string",
97
+ enum: %w[untyped observed],
98
+ description: "Parameter policy: untyped (default) or observed " \
99
+ "(harvests call-site argument types from spec/)"
100
+ },
101
+ config: { type: "string", description: "Path to .rigor.yml (optional)" }
102
+ }
103
+ }
104
+ },
105
+ {
106
+ name: "rigor_explain",
107
+ description: "Look up the description of one or all Rigor diagnostic rules. " \
108
+ "Returns JSON. Without a rule argument, returns the full catalog.",
109
+ inputSchema: {
110
+ type: "object",
111
+ properties: {
112
+ rule: {
113
+ type: "string",
114
+ description: "Rule ID, legacy alias, or family prefix (call, flow, assert, dump, def). " \
115
+ "Omit to list every rule."
116
+ }
117
+ }
118
+ }
119
+ },
120
+ {
121
+ name: "rigor_coverage",
122
+ description: "Report type-precision coverage: the ratio of expressions Rigor types as " \
123
+ "Constant / Nominal / shaped / refined (precise) vs Dynamic or top (opaque). " \
124
+ "Returns JSON. Useful for measuring the impact of adding new fold rules.",
125
+ inputSchema: {
126
+ type: "object",
127
+ properties: {
128
+ paths: {
129
+ type: "array",
130
+ items: { type: "string" },
131
+ description: "Files or directories to scan (required)"
132
+ },
133
+ config: { type: "string", description: "Path to .rigor.yml (optional)" }
134
+ },
135
+ required: ["paths"]
136
+ }
137
+ }
138
+ ].freeze
139
+
140
+ def initialize(config_path: nil, err: $stderr)
141
+ @config_path = config_path
142
+ @err = err
143
+ end
144
+
145
+ # Dispatches a parsed JSON-RPC request hash.
146
+ # Returns nil for notifications (requests without an `id`).
147
+ def handle(request)
148
+ id = request["id"]
149
+ method_name = request["method"]
150
+
151
+ # Notifications carry no `id` and require no response.
152
+ return nil if id.nil?
153
+
154
+ case method_name
155
+ when "initialize" then handle_initialize(id)
156
+ when "ping" then success(id, {})
157
+ when "tools/list" then success(id, { tools: TOOLS })
158
+ when "tools/call"
159
+ call_tool(id,
160
+ request.dig("params", "name"),
161
+ request.dig("params", "arguments") || {})
162
+ else
163
+ error(id, -32_601, "Method not found: #{method_name.inspect}")
164
+ end
165
+ end
166
+
167
+ private
168
+
169
+ def handle_initialize(id)
170
+ require_relative "../version"
171
+ success(id, {
172
+ protocolVersion: PROTOCOL_VERSION,
173
+ capabilities: { tools: { listChanged: false } },
174
+ serverInfo: { name: "rigor", version: Rigor::VERSION }
175
+ })
176
+ end
177
+
178
+ def call_tool(id, name, args)
179
+ argv = build_argv(name, args)
180
+ return error(id, -32_602, "Unknown tool: #{name.inspect}") unless argv
181
+
182
+ out_io = StringIO.new
183
+ err_io = StringIO.new
184
+ require_relative "../cli"
185
+ exit_code = CLI.new(argv, out: out_io, err: err_io).run
186
+
187
+ is_error = exit_code == CLI::EXIT_USAGE
188
+ text = out_io.string
189
+ text = err_io.string if text.empty? && is_error
190
+
191
+ success(id, { content: [{ type: "text", text: text }], isError: is_error })
192
+ rescue StandardError => e
193
+ @err.puts("rigor mcp: #{e.class}: #{e.message}")
194
+ @err.puts(e.backtrace.first(5).join("\n")) if e.backtrace
195
+ success(id, {
196
+ content: [{ type: "text", text: "Internal error: #{e.message}" }],
197
+ isError: true
198
+ })
199
+ end
200
+
201
+ def build_argv(name, args) # rubocop:disable Metrics/MethodLength,Metrics/CyclomaticComplexity,Metrics/AbcSize,Metrics/PerceivedComplexity
202
+ effective_config = args["config"] || @config_path
203
+
204
+ case name
205
+ when "rigor_check"
206
+ argv = ["check", "--format=json", "--no-stats"]
207
+ argv << "--config=#{effective_config}" if effective_config
208
+ argv += Array(args["paths"])
209
+ argv
210
+
211
+ when "rigor_type_of"
212
+ return nil unless args["file"] && args["line"] && args["col"]
213
+
214
+ argv = ["type-of", "--format=json"]
215
+ argv << "--config=#{effective_config}" if effective_config
216
+ argv << "#{args['file']}:#{args['line']}:#{args['col']}"
217
+ argv
218
+
219
+ when "rigor_triage"
220
+ argv = ["triage", "--format=json"]
221
+ argv << "--config=#{effective_config}" if effective_config
222
+ argv << "--top=#{args['top']}" if args["top"]
223
+ argv += Array(args["paths"])
224
+ argv
225
+
226
+ when "rigor_annotate"
227
+ return nil unless args["file"]
228
+
229
+ argv = ["annotate", "--no-color"]
230
+ argv << "--config=#{effective_config}" if effective_config
231
+ argv << args["file"]
232
+ argv
233
+
234
+ when "rigor_sig_gen"
235
+ argv = ["sig-gen", "--print", "--format=json"]
236
+ argv << "--config=#{effective_config}" if effective_config
237
+ argv << "--params=#{args['params']}" if args["params"]
238
+ argv += Array(args["paths"])
239
+ argv
240
+
241
+ when "rigor_explain"
242
+ argv = ["explain", "--format=json"]
243
+ argv << args["rule"] if args["rule"]
244
+ argv
245
+
246
+ when "rigor_coverage"
247
+ argv = ["coverage", "--format=json"]
248
+ argv << "--config=#{effective_config}" if effective_config
249
+ argv += Array(args["paths"])
250
+ argv
251
+ end
252
+ end
253
+
254
+ def success(id, result)
255
+ { "jsonrpc" => "2.0", "id" => id, "result" => result }
256
+ end
257
+
258
+ def error(id, code, message)
259
+ { "jsonrpc" => "2.0", "id" => id, "error" => { "code" => code, "message" => message } }
260
+ end
261
+ end
262
+ end
263
+ end
data/lib/rigor/mcp.rb ADDED
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rigor
4
+ # The MCP (Model Context Protocol) server subsystem.
5
+ # See `docs/adr/33-mcp-server.md` for the design.
6
+ #
7
+ # Entry point: `rigor mcp --transport stdio`.
8
+ # The server exposes Rigor's analysis tools (check, type-of, triage,
9
+ # annotate, sig-gen, explain, coverage) as MCP tool calls over a
10
+ # newline-delimited JSON-RPC 2.0 stdio stream.
11
+ module MCP
12
+ end
13
+ end
14
+
15
+ require_relative "mcp/server"
16
+ require_relative "mcp/loop"
@@ -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
@@ -312,11 +351,6 @@ module Rigor
312
351
  # descriptor from (1) the plugin's PluginEntry template
313
352
  # and (2) the IoBoundary's accumulated FileEntry rows.
314
353
  def build_plugin_cache_descriptor
315
- plugin_entry = Cache::Descriptor::PluginEntry.new(
316
- id: manifest.id,
317
- version: manifest.version,
318
- config_hash: digest_config(config)
319
- )
320
354
  boundary_descriptor = io_boundary.cache_descriptor
321
355
  Cache::Descriptor.new(
322
356
  plugins: [plugin_entry],
@@ -324,6 +358,34 @@ module Rigor
324
358
  )
325
359
  end
326
360
 
361
+ public
362
+
363
+ # ADR-32 WD5 — the `Cache::Descriptor::PluginEntry`
364
+ # template carrying this plugin's id, version, and a
365
+ # SHA-256 digest of its (canonicalised) config hash.
366
+ # Callers outside the plugin (e.g. `Environment.for_project`
367
+ # caching per-file synthesizer output) compose this entry
368
+ # into their own cache descriptor so a config change to
369
+ # the plugin (e.g. flipping `require_magic_comment:`)
370
+ # invalidates the dependent cache.
371
+ def plugin_entry
372
+ # Built fresh on each call rather than memoised so a
373
+ # plugin subclass that freezes itself in `initialize`
374
+ # (e.g. `Rigor::Plugin::RbsInline` per ADR-32) doesn't
375
+ # trip a FrozenError on first read. The construction
376
+ # cost is a single `Data.define`-backed value-object
377
+ # build; the cache key derivation downstream is the
378
+ # expensive step, and it's already memoised inside
379
+ # `Cache::Store`.
380
+ Cache::Descriptor::PluginEntry.new(
381
+ id: manifest.id,
382
+ version: manifest.version,
383
+ config_hash: digest_config(config)
384
+ )
385
+ end
386
+
387
+ private
388
+
327
389
  # ADR-7 § "Slice 6" follow-up — composes the auto-built
328
390
  # cache descriptor with an optional plugin-author-supplied
329
391
  # extension. Extra `GemEntry` / `FileEntry` / `ConfigEntry`
@@ -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,17 @@ 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, :source_rbs_synthesizer
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: [],
54
+ source_rbs_synthesizer: nil
52
55
  )
53
56
  validate_id!(id)
54
57
  validate_version!(version)
@@ -56,6 +59,7 @@ module Rigor
56
59
  validate_config_schema!(config_schema)
57
60
  validate_produces!(produces)
58
61
  validate_owns_receivers!(owns_receivers)
62
+ validate_open_receivers!(open_receivers)
59
63
  validate_type_node_resolvers!(type_node_resolvers)
60
64
  validate_block_as_methods!(block_as_methods)
61
65
  validate_heredoc_templates!(heredoc_templates)
@@ -63,10 +67,14 @@ module Rigor
63
67
  validate_external_files!(external_files)
64
68
  validate_hkt_registrations!(hkt_registrations)
65
69
  validate_hkt_definitions!(hkt_definitions)
70
+ validate_signature_paths!(signature_paths)
71
+ validate_protocol_contracts!(protocol_contracts)
72
+ validate_source_rbs_synthesizer!(source_rbs_synthesizer)
66
73
 
67
74
  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)
75
+ open_receivers, type_node_resolvers, block_as_methods, heredoc_templates, trait_registries,
76
+ external_files, hkt_registrations, hkt_definitions, signature_paths, protocol_contracts,
77
+ source_rbs_synthesizer)
70
78
  freeze
71
79
  end
72
80
 
@@ -74,8 +82,9 @@ module Rigor
74
82
 
75
83
  # rubocop:disable Metrics/ParameterLists, Metrics/AbcSize
76
84
  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)
85
+ open_receivers, type_node_resolvers, block_as_methods, heredoc_templates, trait_registries,
86
+ external_files, hkt_registrations, hkt_definitions, signature_paths, protocol_contracts,
87
+ source_rbs_synthesizer)
79
88
  @id = id.dup.freeze
80
89
  @version = version.dup.freeze
81
90
  @description = description.nil? ? nil : description.to_s.dup.freeze
@@ -84,6 +93,7 @@ module Rigor
84
93
  @produces = produces.map(&:to_sym).freeze
85
94
  @consumes = coerce_consumes(consumes)
86
95
  @owns_receivers = owns_receivers.map { |c| c.to_s.dup.freeze }.freeze
96
+ @open_receivers = open_receivers.map { |c| c.to_s.dup.freeze }.freeze
87
97
  @type_node_resolvers = type_node_resolvers.dup.freeze
88
98
  @block_as_methods = block_as_methods.dup.freeze
89
99
  @heredoc_templates = heredoc_templates.dup.freeze
@@ -91,6 +101,9 @@ module Rigor
91
101
  @external_files = external_files.dup.freeze
92
102
  @hkt_registrations = hkt_registrations.dup.freeze
93
103
  @hkt_definitions = hkt_definitions.dup.freeze
104
+ @signature_paths = signature_paths.map { |p| p.to_s.dup.freeze }.freeze
105
+ @protocol_contracts = protocol_contracts.dup.freeze
106
+ @source_rbs_synthesizer = source_rbs_synthesizer
94
107
  end
95
108
  # rubocop:enable Metrics/ParameterLists, Metrics/AbcSize
96
109
 
@@ -119,7 +132,7 @@ module Rigor
119
132
  errors
120
133
  end
121
134
 
122
- def to_h # rubocop:disable Metrics/AbcSize
135
+ def to_h # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
123
136
  {
124
137
  "id" => id,
125
138
  "version" => version,
@@ -129,13 +142,17 @@ module Rigor
129
142
  "produces" => produces.map(&:to_s),
130
143
  "consumes" => consumes.map { |c| consumption_hash(c) },
131
144
  "owns_receivers" => owns_receivers,
145
+ "open_receivers" => open_receivers,
132
146
  "type_node_resolvers" => type_node_resolvers.map { |r| r.class.name },
133
147
  "block_as_methods" => block_as_methods.map(&:to_h),
134
148
  "heredoc_templates" => heredoc_templates.map(&:to_h),
135
149
  "trait_registries" => trait_registries.map(&:to_h),
136
150
  "external_files" => external_files.map(&:to_h),
137
151
  "hkt_registrations" => hkt_registrations.map(&:to_h),
138
- "hkt_definitions" => hkt_definitions.map { |d| { "uri" => d.uri, "params" => d.params } }
152
+ "hkt_definitions" => hkt_definitions.map { |d| { "uri" => d.uri, "params" => d.params } },
153
+ "signature_paths" => signature_paths,
154
+ "protocol_contracts" => protocol_contracts.map(&:to_h),
155
+ "source_rbs_synthesizer" => source_rbs_synthesizer&.class&.name
139
156
  }
140
157
  end
141
158
 
@@ -217,6 +234,24 @@ module Rigor
217
234
  "got #{owns_receivers.inspect}"
218
235
  end
219
236
 
237
+ # ADR-26 — `open_receivers:` declares the class names this
238
+ # plugin marks as "open": statically known to respond beyond
239
+ # their RBS-declared method surface (e.g. `ActiveRecord::Relation`,
240
+ # which delegates an unbounded set of user-defined scopes to
241
+ # its model). `Analysis::CheckRules` skips the
242
+ # `call.undefined-method` rule for a receiver whose class any
243
+ # loaded plugin lists here — flagging a method on a class
244
+ # with an open dynamic surface is unsound. Distinct from
245
+ # `owns_receivers:` (which routes dispatch); this one only
246
+ # suppresses the diagnostic.
247
+ def validate_open_receivers!(open_receivers)
248
+ return if open_receivers.is_a?(Array) && open_receivers.all? { |c| c.is_a?(String) && !c.empty? }
249
+
250
+ raise ArgumentError,
251
+ "plugin manifest open_receivers must be an Array of non-empty String, " \
252
+ "got #{open_receivers.inspect}"
253
+ end
254
+
220
255
  # ADR-13 slice 2 — `type_node_resolvers:` declares the
221
256
  # plugin-supplied `TypeNodeResolver` instances the parser
222
257
  # consults (in slice 3) when an RBS::Extended payload's
@@ -326,6 +361,62 @@ module Rigor
326
361
  "Rigor::Inference::HktRegistry::Definition instances, got #{entries.inspect}"
327
362
  end
328
363
 
364
+ # ADR-25 — `signature_paths:` declares the RBS signature
365
+ # directories this plugin gem ships, as paths relative to the
366
+ # plugin's own gem root (e.g. `["sig"]`). `Plugin::Base#signature_paths`
367
+ # resolves them to absolute dirs against the gem root; the
368
+ # loader validates each exists and `Environment.for_project`
369
+ # merges the resolved set into the RBS environment.
370
+ def validate_signature_paths!(paths)
371
+ return if paths.is_a?(Array) && paths.all? { |p| p.is_a?(String) && !p.empty? }
372
+
373
+ raise ArgumentError,
374
+ "plugin manifest signature_paths must be an Array of non-empty String, " \
375
+ "got #{paths.inspect}"
376
+ end
377
+
378
+ # ADR-28 — `protocol_contracts:` declares the path-scoped
379
+ # method-protocol contracts the plugin contributes. Each
380
+ # entry MUST be a `Rigor::Plugin::ProtocolContract`. The
381
+ # registry aggregator on `Plugin::Registry` flattens
382
+ # contracts across loaded plugins; the engine consults them
383
+ # in two places — `MethodParameterBinder` provides the
384
+ # declared parameter types into matching method bodies, and
385
+ # the contributing plugin's `#diagnostics_for_file` checks
386
+ # method presence + return-type conformance. The manifest
387
+ # field carries the plugin's *default* contracts; a plugin
388
+ # MAY override `Plugin::Base#protocol_contracts` to fold in
389
+ # per-project config (e.g. a custom convention path).
390
+ def validate_protocol_contracts!(entries)
391
+ return if entries.is_a?(Array) && entries.all?(ProtocolContract)
392
+
393
+ raise ArgumentError,
394
+ "plugin manifest protocol_contracts must be an Array of " \
395
+ "Rigor::Plugin::ProtocolContract instances, got #{entries.inspect}"
396
+ end
397
+
398
+ # ADR-32 WD4 — `source_rbs_synthesizer:` declares a callable
399
+ # the engine invokes once per analysed Ruby source file at
400
+ # env-build time. The callable receives a source file path
401
+ # (String) and returns either an RBS source String to merge
402
+ # into the analysis environment or `nil` (no contribution
403
+ # for this file). Distinct from `signature_paths:` (static,
404
+ # bundled RBS): the synthesizer derives RBS from project
405
+ # source on each run.
406
+ #
407
+ # The value is held as-given (no dup / freeze) because a
408
+ # callable instance is opaque to the manifest; the plugin
409
+ # author is responsible for thread-/Ractor-safety of any
410
+ # captured state (per ADR-15).
411
+ def validate_source_rbs_synthesizer!(synthesizer)
412
+ return if synthesizer.nil?
413
+ return if synthesizer.respond_to?(:call)
414
+
415
+ raise ArgumentError,
416
+ "plugin manifest source_rbs_synthesizer must respond to :call, " \
417
+ "got #{synthesizer.inspect}"
418
+ end
419
+
329
420
  def coerce_consumes(consumes)
330
421
  unless consumes.is_a?(Array)
331
422
  raise ArgumentError, "plugin manifest consumes must be an Array, got #{consumes.inspect}"