rigortype 0.1.5 → 0.1.7

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 (72) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +76 -79
  3. data/lib/rigor/analysis/baseline.rb +347 -0
  4. data/lib/rigor/analysis/buffer_binding.rb +36 -0
  5. data/lib/rigor/analysis/check_rules.rb +68 -3
  6. data/lib/rigor/analysis/dependency_source_inference/index.rb +14 -1
  7. data/lib/rigor/analysis/dependency_source_inference/return_type_heuristic.rb +105 -0
  8. data/lib/rigor/analysis/dependency_source_inference/walker.rb +32 -12
  9. data/lib/rigor/analysis/project_scan.rb +39 -0
  10. data/lib/rigor/analysis/runner.rb +309 -22
  11. data/lib/rigor/analysis/worker_session.rb +14 -2
  12. data/lib/rigor/builtins/hkt_builtins.rb +342 -0
  13. data/lib/rigor/builtins/static_return_refinements.rb +142 -0
  14. data/lib/rigor/cache/store.rb +33 -3
  15. data/lib/rigor/cli/baseline_command.rb +377 -0
  16. data/lib/rigor/cli/lsp_command.rb +129 -0
  17. data/lib/rigor/cli/type_of_command.rb +44 -5
  18. data/lib/rigor/cli.rb +142 -13
  19. data/lib/rigor/configuration.rb +58 -2
  20. data/lib/rigor/environment/hkt_registry_holder.rb +33 -0
  21. data/lib/rigor/environment/rbs_coverage_report.rb +1 -1
  22. data/lib/rigor/environment/rbs_loader.rb +67 -2
  23. data/lib/rigor/environment/reporters.rb +40 -0
  24. data/lib/rigor/environment.rb +119 -9
  25. data/lib/rigor/flow_contribution/fact.rb +20 -10
  26. data/lib/rigor/inference/acceptance.rb +48 -3
  27. data/lib/rigor/inference/expression_typer.rb +64 -2
  28. data/lib/rigor/inference/hkt_body.rb +171 -0
  29. data/lib/rigor/inference/hkt_body_parser.rb +363 -0
  30. data/lib/rigor/inference/hkt_reducer.rb +256 -0
  31. data/lib/rigor/inference/hkt_registry.rb +223 -0
  32. data/lib/rigor/inference/method_dispatcher/overload_selector.rb +125 -30
  33. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +32 -11
  34. data/lib/rigor/inference/method_dispatcher/receiver_affinity.rb +87 -0
  35. data/lib/rigor/inference/method_dispatcher.rb +174 -6
  36. data/lib/rigor/inference/narrowing.rb +103 -1
  37. data/lib/rigor/inference/project_patched_methods.rb +70 -0
  38. data/lib/rigor/inference/project_patched_scanner.rb +210 -0
  39. data/lib/rigor/inference/scope_indexer.rb +209 -19
  40. data/lib/rigor/inference/statement_evaluator.rb +172 -11
  41. data/lib/rigor/inference/synthetic_method_scanner.rb +94 -16
  42. data/lib/rigor/language_server/buffer_table.rb +63 -0
  43. data/lib/rigor/language_server/completion_provider.rb +438 -0
  44. data/lib/rigor/language_server/debouncer.rb +86 -0
  45. data/lib/rigor/language_server/diagnostic_publisher.rb +167 -0
  46. data/lib/rigor/language_server/document_symbol_provider.rb +142 -0
  47. data/lib/rigor/language_server/folding_range_provider.rb +75 -0
  48. data/lib/rigor/language_server/hover_provider.rb +74 -0
  49. data/lib/rigor/language_server/hover_renderer.rb +312 -0
  50. data/lib/rigor/language_server/loop.rb +71 -0
  51. data/lib/rigor/language_server/project_context.rb +145 -0
  52. data/lib/rigor/language_server/selection_range_provider.rb +93 -0
  53. data/lib/rigor/language_server/server.rb +384 -0
  54. data/lib/rigor/language_server/signature_help_provider.rb +249 -0
  55. data/lib/rigor/language_server/synchronized_writer.rb +28 -0
  56. data/lib/rigor/language_server/uri.rb +40 -0
  57. data/lib/rigor/language_server.rb +29 -0
  58. data/lib/rigor/plugin/base.rb +63 -0
  59. data/lib/rigor/plugin/macro/heredoc_template.rb +127 -13
  60. data/lib/rigor/plugin/macro/trait_registry.rb +1 -1
  61. data/lib/rigor/plugin/manifest.rb +54 -7
  62. data/lib/rigor/plugin/registry.rb +19 -0
  63. data/lib/rigor/rbs_extended/hkt_directives.rb +326 -0
  64. data/lib/rigor/rbs_extended.rb +82 -2
  65. data/lib/rigor/sig_gen/generator.rb +12 -3
  66. data/lib/rigor/type/app.rb +107 -0
  67. data/lib/rigor/type.rb +1 -0
  68. data/lib/rigor/version.rb +1 -1
  69. data/sig/rigor/environment.rbs +10 -4
  70. data/sig/rigor/inference.rbs +2 -0
  71. data/sig/rigor.rbs +4 -1
  72. metadata +56 -1
@@ -0,0 +1,249 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+
5
+ require_relative "uri"
6
+ require_relative "../environment"
7
+ require_relative "../reflection"
8
+ require_relative "../scope"
9
+ require_relative "../source/node_locator"
10
+ require_relative "../inference/scope_indexer"
11
+ require_relative "../type/nominal"
12
+ require_relative "../type/singleton"
13
+ require_relative "../type/constant"
14
+ require_relative "../type/refined"
15
+ require_relative "../type/difference"
16
+ require_relative "../type/tuple"
17
+ require_relative "../type/hash_shape"
18
+
19
+ module Rigor
20
+ module LanguageServer
21
+ # Answers `textDocument/signatureHelp` requests. When the user
22
+ # types `(` inside a method call (`obj.foo(|`) editors fire
23
+ # `signatureHelp` to show the method's parameter signature
24
+ # inline. The provider parses the buffer, locates the enclosing
25
+ # `CallNode`, infers the receiver's type, and returns the
26
+ # method's first overload as a `SignatureInformation`.
27
+ #
28
+ # Slice C1 (this commit) ships:
29
+ # - Sentinel patching for `obj.foo(|` so Prism's parse
30
+ # succeeds (mirrors `CompletionProvider`'s slice B4 pattern).
31
+ # - First-overload signature only.
32
+ # - Active parameter = comma count before cursor.
33
+ #
34
+ # Multi-overload presentation + `documentation` field +
35
+ # active-parameter override per overload land in follow-up
36
+ # slices (queued in the design doc § "Out of scope for v2").
37
+ class SignatureHelpProvider
38
+ ARG_SENTINEL = "__rigor_lsp_arg_sentinel__"
39
+ private_constant :ARG_SENTINEL
40
+
41
+ def initialize(buffer_table:, project_context:)
42
+ @buffer_table = buffer_table
43
+ @project_context = project_context
44
+ end
45
+
46
+ # @return [Hash, nil] LSP `SignatureHelp` payload or nil
47
+ # when the cursor isn't inside a resolvable method call.
48
+ def provide(uri:, line:, character:, context: nil)
49
+ _ = context # Trigger info accepted but not routed in v1.
50
+ path = Uri.to_path(uri)
51
+ return nil if path.nil?
52
+
53
+ entry = @buffer_table[uri]
54
+ return nil if entry.nil?
55
+
56
+ bytes, locate_at = parse_attempt_bytes(entry.bytes, line, character)
57
+ parse_result = Prism.parse(bytes, filepath: path,
58
+ version: @project_context.configuration.target_ruby)
59
+ return nil unless parse_result.errors.empty?
60
+
61
+ cursor_offset = byte_offset_for(bytes, locate_at[0], locate_at[1])
62
+ return nil if cursor_offset.nil?
63
+
64
+ call_node = enclosing_call_for_offset(parse_result.value, cursor_offset)
65
+ return nil if call_node.nil?
66
+
67
+ build_signature(call_node, parse_result.value, path, bytes, locate_at[0], locate_at[1])
68
+ end
69
+
70
+ private
71
+
72
+ def parse_attempt_bytes(original_bytes, line, character)
73
+ return [original_bytes, [line, character]] if Prism.parse(original_bytes).errors.empty?
74
+
75
+ patch_with_arg_sentinel(original_bytes, line, character)
76
+ end
77
+
78
+ # Mid-edit buffer at `obj.foo(|` or `obj.foo(1,|`: truncate
79
+ # everything from the cursor onwards and append `SENTINEL)`
80
+ # so the call is syntactically complete. The truncation is
81
+ # aggressive — signatureHelp only cares about the enclosing
82
+ # call's signature; downstream content (closing parens,
83
+ # subsequent statements) is irrelevant.
84
+ def patch_with_arg_sentinel(original_bytes, line, character)
85
+ prefix_offset = byte_offset_for(original_bytes, line, character)
86
+ return [original_bytes, [line, character]] if prefix_offset.nil?
87
+
88
+ prefix = original_bytes.byteslice(0, prefix_offset)
89
+ stripped = prefix.rstrip
90
+ return [original_bytes, [line, character]] unless stripped.end_with?("(") || stripped.end_with?(",")
91
+
92
+ patched = "#{prefix}#{ARG_SENTINEL})\n"
93
+ [patched, [line, character]]
94
+ end
95
+
96
+ # Walks the AST for the smallest CallNode whose `arguments`
97
+ # location encloses the cursor offset. Prism doesn't expose
98
+ # parent pointers, so NodeLocator's leaf-returning shape
99
+ # isn't enough; we re-walk. For LSP usage this is cheap —
100
+ # the buffer is parsed once per request.
101
+ def enclosing_call_for_offset(root, cursor_offset)
102
+ result = nil
103
+ walk = lambda do |n|
104
+ next unless n.is_a?(Prism::Node)
105
+
106
+ if n.is_a?(Prism::CallNode) && n.arguments && offset_in?(n.arguments.location, cursor_offset)
107
+ result = n # Innermost-wins because we keep walking children.
108
+ end
109
+ n.compact_child_nodes.each(&walk)
110
+ end
111
+ walk.call(root)
112
+ result
113
+ end
114
+
115
+ def offset_in?(location, offset)
116
+ offset.between?(location.start_offset, location.end_offset)
117
+ end
118
+
119
+ def build_signature(call_node, root, path, bytes, line, character)
120
+ scope_index = build_scope_index(root, path)
121
+ receiver_node = call_node.receiver
122
+ return nil if receiver_node.nil?
123
+
124
+ receiver_type = scope_index[receiver_node].type_of(receiver_node)
125
+ definition = lookup_method(receiver_type, call_node.name, scope_index[receiver_node])
126
+ return nil if definition.nil? || definition.method_types.empty?
127
+
128
+ active_param = active_parameter_index(call_node, bytes, line, character)
129
+ doc = rbs_documentation(definition)
130
+ signatures = definition.method_types.map do |method_type|
131
+ info = {
132
+ label: "#{call_node.name}#{method_type}",
133
+ parameters: parameter_information(method_type)
134
+ }
135
+ info[:documentation] = { kind: "markdown", value: doc } if doc
136
+ info
137
+ end
138
+ {
139
+ signatures: signatures,
140
+ # `activeSignature` is the index editors highlight by
141
+ # default. Slice C2 picks the first overload uniformly;
142
+ # a future slice could choose the overload that best
143
+ # matches the current argument shape.
144
+ activeSignature: 0,
145
+ activeParameter: active_param
146
+ }
147
+ end
148
+
149
+ # Builds the LSP `ParameterInformation[]` for a method type's
150
+ # parameter list. Each entry's `label` is a STRING form
151
+ # (e.g. `"::int width"`, `"?::string pad_string"`); LSP's
152
+ # offset-tuple form (`[start, end]`) for in-signature
153
+ # highlighting is queued. Order matches `activeParameter`'s
154
+ # index expectation: required positionals, then optionals,
155
+ # then rest, then trailing, then required keywords, then
156
+ # optional keywords, then rest keywords.
157
+ def parameter_information(method_type) # rubocop:disable Metrics/AbcSize
158
+ func = method_type.type
159
+ return [] unless func.respond_to?(:required_positionals)
160
+
161
+ params = func.required_positionals.map { |p| { label: format_param(p) } }
162
+ func.optional_positionals.each { |p| params << { label: "?#{format_param(p)}" } }
163
+ params << { label: "*#{format_param(func.rest_positionals)}" } if func.rest_positionals
164
+ func.trailing_positionals.each { |p| params << { label: format_param(p) } }
165
+ func.required_keywords.each { |name, p| params << { label: "#{name}: #{format_param(p)}" } }
166
+ func.optional_keywords.each { |name, p| params << { label: "?#{name}: #{format_param(p)}" } }
167
+ params << { label: "**#{format_param(func.rest_keywords)}" } if func.rest_keywords
168
+ params
169
+ end
170
+
171
+ def format_param(param)
172
+ param.name ? "#{param.type} #{param.name}" : param.type.to_s
173
+ end
174
+
175
+ # Identical contract to HoverRenderer#rbs_documentation —
176
+ # surfaces the method's RBS comment text or nil. Kept inline
177
+ # rather than extracted to a shared mixin because the two
178
+ # call sites are small and the shape may diverge (signatureHelp
179
+ # might want per-parameter docs split out; hover wants the
180
+ # full paragraph).
181
+ def rbs_documentation(definition)
182
+ comments = definition.respond_to?(:comments) ? definition.comments : nil
183
+ return nil if comments.nil? || comments.empty?
184
+
185
+ text = comments.map(&:string).join("\n\n").strip
186
+ text.empty? ? nil : text
187
+ end
188
+
189
+ def lookup_method(receiver_type, method_name, scope)
190
+ case receiver_type
191
+ when Type::Singleton
192
+ Reflection.singleton_method_definition(receiver_type.class_name, method_name, scope: scope)
193
+ when Type::Refined, Type::Difference
194
+ lookup_method(receiver_type.base, method_name, scope)
195
+ else
196
+ class_name = nominal_class_name(receiver_type)
197
+ return nil if class_name.nil?
198
+
199
+ Reflection.instance_method_definition(class_name, method_name, scope: scope)
200
+ end
201
+ end
202
+
203
+ # Mirrors CompletionProvider's receiver-type mapping. Tuple →
204
+ # Array, HashShape → Hash, Refined / Difference unwrap to
205
+ # their base (handled in `lookup_method` above for clarity).
206
+ def nominal_class_name(type)
207
+ case type
208
+ when Type::Nominal then type.class_name
209
+ when Type::Constant then type.value.class.name
210
+ when Type::Tuple then "Array"
211
+ when Type::HashShape then "Hash"
212
+ end
213
+ end
214
+
215
+ def build_scope_index(root, _path)
216
+ scope = Scope.empty(environment: @project_context.environment)
217
+ Inference::ScopeIndexer.index(root, default_scope: scope)
218
+ end
219
+
220
+ # Counts commas in the buffer between the call's opening `(`
221
+ # and the cursor position. Cursor on the first argument → 0;
222
+ # after one comma → 1; etc. Bounded by the call's arguments
223
+ # location so commas in nested expressions don't bleed in.
224
+ def active_parameter_index(call_node, bytes, line, character)
225
+ return 0 if call_node.arguments.nil?
226
+
227
+ cursor_offset = byte_offset_for(bytes, line, character)
228
+ args_loc = call_node.arguments.location
229
+ return 0 if args_loc.nil? || cursor_offset.nil?
230
+
231
+ scan_start = args_loc.start_offset
232
+ scan_end = [cursor_offset, args_loc.end_offset].min
233
+ return 0 if scan_end <= scan_start
234
+
235
+ bytes.byteslice(scan_start, scan_end - scan_start).count(",")
236
+ end
237
+
238
+ def byte_offset_for(bytes, line, character)
239
+ offset = 0
240
+ bytes.each_line.with_index do |line_bytes, idx|
241
+ return offset + character if idx == line
242
+
243
+ offset += line_bytes.bytesize
244
+ end
245
+ nil
246
+ end
247
+ end
248
+ end
249
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rigor
4
+ module LanguageServer
5
+ # Wraps the LSP gem's `Io::Writer` with a Mutex so concurrent
6
+ # writes (the dispatch loop's response writes + the Debouncer's
7
+ # async `publishDiagnostics` writes) don't interleave on the
8
+ # shared STDOUT.
9
+ #
10
+ # Pass-through proxy: `#write(message)` is the only call site
11
+ # the rest of the LSP uses; `#close` is forwarded for
12
+ # completeness.
13
+ class SynchronizedWriter
14
+ def initialize(inner)
15
+ @inner = inner
16
+ @mutex = Mutex.new
17
+ end
18
+
19
+ def write(message)
20
+ @mutex.synchronize { @inner.write(message) }
21
+ end
22
+
23
+ def close
24
+ @mutex.synchronize { @inner.close }
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rigor
4
+ module LanguageServer
5
+ # LSP DocumentUri ↔ filesystem path conversions. v1 supports
6
+ # only `file://` URIs; other schemes (e.g. `untitled:`) return
7
+ # nil from `#to_path` so the caller can short-circuit.
8
+ #
9
+ # Windows drive-letter handling: `file:///C:/path` → `C:/path`.
10
+ # The leading slash after the scheme is dropped on Windows; on
11
+ # POSIX it stays. v1 ships POSIX behaviour; Windows specifics
12
+ # land when Windows CI is wired (see design doc § "Open
13
+ # questions").
14
+ module Uri
15
+ module_function
16
+
17
+ FILE_SCHEME = "file://"
18
+ private_constant :FILE_SCHEME
19
+
20
+ # @return [String, nil] absolute filesystem path for a
21
+ # `file://` URI, or nil for unsupported schemes.
22
+ def to_path(uri)
23
+ return nil unless uri.is_a?(String) && uri.start_with?(FILE_SCHEME)
24
+
25
+ # Percent-decode at the BYTE level so multi-byte UTF-8
26
+ # escapes (`%E6%97%A5` → `日`) reassemble correctly. Each
27
+ # `%xx` decodes to one raw byte; the result is a byte string
28
+ # we re-interpret as UTF-8. `delete_prefix` always returns
29
+ # a String (vs `byteslice` whose RBS return is `String?`).
30
+ uri.delete_prefix(FILE_SCHEME).b
31
+ .gsub(/%([0-9A-Fa-f]{2})/) { ::Regexp.last_match(1).hex.chr }
32
+ .force_encoding(Encoding::UTF_8)
33
+ end
34
+
35
+ def from_path(path)
36
+ "#{FILE_SCHEME}#{path}"
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rigor
4
+ # The Language Server subsystem. See
5
+ # `docs/design/20260517-language-server.md` for the design.
6
+ # Slice 1 ships the namespace + a minimal {Server} lifecycle the
7
+ # `rigor lsp` CLI subcommand can drive. Later slices add the
8
+ # stdio JSON-RPC transport (slice 2), the BufferTable (slice 3),
9
+ # `publishDiagnostics` (slice 4), and the rest of the v1 capability
10
+ # surface.
11
+ module LanguageServer
12
+ end
13
+ end
14
+
15
+ require_relative "language_server/buffer_table"
16
+ require_relative "language_server/uri"
17
+ require_relative "language_server/project_context"
18
+ require_relative "language_server/debouncer"
19
+ require_relative "language_server/synchronized_writer"
20
+ require_relative "language_server/diagnostic_publisher"
21
+ require_relative "language_server/hover_renderer"
22
+ require_relative "language_server/hover_provider"
23
+ require_relative "language_server/completion_provider"
24
+ require_relative "language_server/signature_help_provider"
25
+ require_relative "language_server/document_symbol_provider"
26
+ require_relative "language_server/folding_range_provider"
27
+ require_relative "language_server/selection_range_provider"
28
+ require_relative "language_server/server"
29
+ require_relative "language_server/loop"
@@ -243,8 +243,71 @@ module Rigor
243
243
  end
244
244
  end
245
245
 
246
+ # Builds a `Cache::Descriptor` covering every file matched by
247
+ # `pattern` (a glob, e.g. `"**/*.rb"`) under any of `roots`.
248
+ # Each matching file contributes a `:digest`-comparator
249
+ # `FileEntry` so the cache invalidates on any content change,
250
+ # any addition (a newly-glob-matched file appears in the
251
+ # descriptor), or any removal (the previously-matched file
252
+ # drops out).
253
+ #
254
+ # Pass the returned descriptor as `cache_for(..., descriptor: …)`
255
+ # so the cache key reflects the project files the producer
256
+ # reads from. Without it, `Plugin::Base#cache_for`'s
257
+ # auto-built descriptor only includes files the
258
+ # {Plugin::IoBoundary} has already read in the current
259
+ # process — empty on the first call of a fresh process — so
260
+ # the cache key is identical regardless of project state and
261
+ # warm runs return stale producer output when files have
262
+ # changed between sessions.
263
+ #
264
+ # Discovery-style producers (`actioncable`'s `:channel_index`,
265
+ # `actionmailer`'s `:mailer_index`, `rails-i18n`'s
266
+ # `:locale_index`) all follow the same pattern: walk a glob
267
+ # under one or more search roots, parse / read every match,
268
+ # build a typed index. They MUST call this helper at the
269
+ # `cache_for(descriptor: …)` site to be cache-correct under
270
+ # the persistent `Cache::Store` `rigor check` uses by
271
+ # default.
272
+ #
273
+ # The helper pays one SHA-256 read per matched file at
274
+ # call time; the producer block typically re-reads through
275
+ # `io_boundary.read_file` so the cost is doubled. For
276
+ # discovery globs in the 10-100 file range this is
277
+ # negligible (~ms) relative to the parse + walk the
278
+ # producer does on cache miss.
279
+ #
280
+ # @param roots [Array<String>] search roots (relative to
281
+ # the project root, or absolute paths)
282
+ # @param patterns [Array<String>] glob suffixes joined under
283
+ # each root via `File.join(root, pattern)`. Multiple
284
+ # patterns union into one descriptor (`"**/*.erb",
285
+ # "**/*.html"` etc.).
286
+ # @return [Rigor::Cache::Descriptor]
287
+ def glob_descriptor(roots, *patterns)
288
+ files = collect_glob_files(Array(roots), patterns)
289
+ entries = files.map do |path|
290
+ Cache::Descriptor::FileEntry.new(
291
+ path: path,
292
+ comparator: :digest,
293
+ value: Digest::SHA256.file(path).hexdigest
294
+ )
295
+ end
296
+ Cache::Descriptor.new(files: entries)
297
+ end
298
+
246
299
  private
247
300
 
301
+ def collect_glob_files(roots, patterns)
302
+ matched = roots.flat_map do |root|
303
+ absolute = File.expand_path(root.to_s)
304
+ next [] unless File.directory?(absolute)
305
+
306
+ patterns.flat_map { |pattern| Dir.glob(File.join(absolute, pattern.to_s)) }
307
+ end
308
+ matched.uniq.sort.select { |path| File.file?(path) }
309
+ end
310
+
248
311
  # ADR-7 § "Slice 6-B" — composes the per-call cache
249
312
  # descriptor from (1) the plugin's PluginEntry template
250
313
  # and (2) the IoBoundary's accumulated FileEntry rows.
@@ -73,8 +73,8 @@ module Rigor
73
73
  # This file ships the value class only. Slice 2b wires the
74
74
  # pre-pass that scans Tier C call sites + the
75
75
  # `SyntheticMethodIndex` the dispatcher consults; slice 2c
76
- # authors `examples/rigor-dry-struct/` and
77
- # `examples/rigor-dry-types/` as the worked consumers.
76
+ # authors `plugins/rigor-dry-struct/` and
77
+ # `plugins/rigor-dry-types/` as the worked consumers.
78
78
  class HeredocTemplate
79
79
  NAME_PLACEHOLDER = "\#{name}"
80
80
 
@@ -115,37 +115,147 @@ module Rigor
115
115
  # One row of an emit table: the synthetic method's
116
116
  # name-template (the analyzer interpolates `\#{name}` with
117
117
  # the call-site literal symbol) and its declared return
118
- # type (recorded as a string in slice 2a, resolved by the
119
- # ceiling slice via ADR-13).
118
+ # type. The return type can be a static String (resolved
119
+ # via `Environment#nominal_for_name` per ADR-16 slice 6b)
120
+ # or a per-call-site lookup ({ReturnsFromArg}) — see
121
+ # [ADR-18](../../../../../docs/adr/18-substrate-per-call-site-return-type.md).
122
+ # When both are nil, the synthesised method's return type
123
+ # falls back to `Dynamic[Top]`.
120
124
  class Emit
121
- attr_reader :name, :returns
125
+ attr_reader :name, :returns, :returns_from_arg
122
126
 
123
- def initialize(name:, returns:)
127
+ def initialize(name:, returns: nil, returns_from_arg: nil)
124
128
  unless name.is_a?(String) && !name.empty?
125
129
  raise ArgumentError,
126
130
  "Macro::HeredocTemplate::Emit#name must be a non-empty String, got #{name.inspect}"
127
131
  end
128
- unless returns.is_a?(String) && !returns.empty?
132
+ unless returns.nil? || (returns.is_a?(String) && !returns.empty?)
129
133
  raise ArgumentError,
130
- "Macro::HeredocTemplate::Emit#returns must be a non-empty String, got #{returns.inspect}"
134
+ "Macro::HeredocTemplate::Emit#returns must be a non-empty String or nil, got #{returns.inspect}"
131
135
  end
132
136
 
133
137
  @name = name.dup.freeze
134
- @returns = returns.dup.freeze
138
+ @returns = returns.nil? ? nil : returns.dup.freeze
139
+ @returns_from_arg = ReturnsFromArg.coerce(returns_from_arg)
135
140
  freeze
136
141
  end
137
142
 
138
143
  def to_h
139
- { "name" => name, "returns" => returns }
144
+ {
145
+ "name" => name,
146
+ "returns" => returns,
147
+ "returns_from_arg" => returns_from_arg&.to_h
148
+ }.compact
140
149
  end
141
150
 
142
151
  def ==(other)
143
- other.is_a?(Emit) && name == other.name && returns == other.returns
152
+ other.is_a?(Emit) && to_h == other.to_h
144
153
  end
145
154
  alias eql? ==
146
155
 
147
156
  def hash
148
- [name, returns].hash
157
+ to_h.hash
158
+ end
159
+ end
160
+
161
+ # ADR-18 — per-call-site return-type DSL. Declares which
162
+ # call-site argument's source representation to look up
163
+ # in a cross-plugin fact channel for the synthesised
164
+ # method's return type.
165
+ #
166
+ # Authoring shape:
167
+ #
168
+ # returns_from_arg: {
169
+ # position: 1,
170
+ # lookup_via: { plugin_id: "dry-types", fact: :dry_type_aliases }
171
+ # }
172
+ #
173
+ # Slice 1 (this file) ships the value class + validation
174
+ # only. The scanner-side arg-position extraction +
175
+ # fact-store lookup land in slice 2 / 3.
176
+ class ReturnsFromArg
177
+ attr_reader :position, :plugin_id, :fact
178
+
179
+ # @return [ReturnsFromArg, nil] coerced value class
180
+ # for a Hash / nil / ReturnsFromArg input. Raises on
181
+ # any other shape so manifest authoring failures
182
+ # surface at construction time.
183
+ def self.coerce(value)
184
+ return nil if value.nil?
185
+ return value if value.is_a?(ReturnsFromArg)
186
+ return new_from_hash(value) if value.is_a?(Hash)
187
+
188
+ raise ArgumentError,
189
+ "Macro::HeredocTemplate::Emit#returns_from_arg must be a Hash or ReturnsFromArg, " \
190
+ "got #{value.inspect}"
191
+ end
192
+
193
+ def self.new_from_hash(hash)
194
+ position = hash[:position] || hash["position"]
195
+ lookup_via = hash[:lookup_via] || hash["lookup_via"]
196
+ unless lookup_via.is_a?(Hash)
197
+ raise ArgumentError,
198
+ "Macro::HeredocTemplate::Emit#returns_from_arg requires a `lookup_via:` Hash, " \
199
+ "got #{hash.inspect}"
200
+ end
201
+ new(
202
+ position: position,
203
+ plugin_id: lookup_via[:plugin_id] || lookup_via["plugin_id"],
204
+ fact: lookup_via[:fact] || lookup_via["fact"]
205
+ )
206
+ end
207
+
208
+ def initialize(position:, plugin_id:, fact:)
209
+ validate_position!(position)
210
+ validate_plugin_id!(plugin_id)
211
+ validate_fact!(fact)
212
+
213
+ @position = position
214
+ @plugin_id = plugin_id.dup.freeze
215
+ @fact = fact.to_sym
216
+ freeze
217
+ end
218
+
219
+ def to_h
220
+ {
221
+ "position" => position,
222
+ "lookup_via" => {
223
+ "plugin_id" => plugin_id,
224
+ "fact" => fact.to_s
225
+ }
226
+ }
227
+ end
228
+
229
+ def ==(other)
230
+ other.is_a?(ReturnsFromArg) && to_h == other.to_h
231
+ end
232
+ alias eql? ==
233
+
234
+ def hash
235
+ to_h.hash
236
+ end
237
+
238
+ private
239
+
240
+ def validate_position!(value)
241
+ return if value.is_a?(Integer) && value >= 0
242
+
243
+ raise ArgumentError,
244
+ "ReturnsFromArg#position must be a non-negative Integer, got #{value.inspect}"
245
+ end
246
+
247
+ def validate_plugin_id!(value)
248
+ return if value.is_a?(String) && !value.empty?
249
+
250
+ raise ArgumentError,
251
+ "ReturnsFromArg#plugin_id must be a non-empty String, got #{value.inspect}"
252
+ end
253
+
254
+ def validate_fact!(value)
255
+ return if value.is_a?(Symbol) || (value.is_a?(String) && !value.empty?)
256
+
257
+ raise ArgumentError,
258
+ "ReturnsFromArg#fact must be a Symbol or non-empty String, got #{value.inspect}"
149
259
  end
150
260
  end
151
261
 
@@ -188,7 +298,11 @@ module Rigor
188
298
  case entry
189
299
  when Emit then entry
190
300
  when Hash
191
- Emit.new(name: entry[:name] || entry["name"], returns: entry[:returns] || entry["returns"])
301
+ Emit.new(
302
+ name: entry[:name] || entry["name"],
303
+ returns: entry[:returns] || entry["returns"],
304
+ returns_from_arg: entry[:returns_from_arg] || entry["returns_from_arg"]
305
+ )
192
306
  else
193
307
  raise ArgumentError,
194
308
  "Plugin::Macro::HeredocTemplate##{label} entry must be an Emit or Hash, " \
@@ -83,7 +83,7 @@ module Rigor
83
83
  # This file ships the value class only. Slice 3b wires the
84
84
  # scanner that walks Tier B call sites + the per-method
85
85
  # explosion via `SyntheticMethodIndex`; slice 3c authors
86
- # `examples/rigor-devise/` model side as the worked consumer.
86
+ # `plugins/rigor-devise/` model side as the worked consumer.
87
87
  class TraitRegistry
88
88
  REST_POSITION = :rest
89
89