rigortype 0.1.4 → 0.1.5

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 (63) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +40 -13
  3. data/lib/rigor/analysis/fact_store.rb +15 -3
  4. data/lib/rigor/analysis/result.rb +11 -3
  5. data/lib/rigor/analysis/run_stats.rb +193 -0
  6. data/lib/rigor/analysis/runner.rb +387 -12
  7. data/lib/rigor/analysis/worker_session.rb +327 -0
  8. data/lib/rigor/builtins/imported_refinements.rb +6 -2
  9. data/lib/rigor/builtins/regex_refinement.rb +17 -12
  10. data/lib/rigor/cache/rbs_descriptor.rb +3 -1
  11. data/lib/rigor/cache/store.rb +40 -7
  12. data/lib/rigor/cli.rb +52 -2
  13. data/lib/rigor/configuration.rb +131 -6
  14. data/lib/rigor/environment/bundle_sig_discovery.rb +198 -0
  15. data/lib/rigor/environment/class_registry.rb +12 -3
  16. data/lib/rigor/environment/lockfile_resolver.rb +125 -0
  17. data/lib/rigor/environment/rbs_collection_discovery.rb +126 -0
  18. data/lib/rigor/environment/rbs_coverage_report.rb +112 -0
  19. data/lib/rigor/environment/rbs_loader.rb +194 -6
  20. data/lib/rigor/environment/reflection.rb +152 -0
  21. data/lib/rigor/environment.rb +78 -6
  22. data/lib/rigor/inference/acceptance.rb +35 -1
  23. data/lib/rigor/inference/builtins/method_catalog.rb +12 -5
  24. data/lib/rigor/inference/builtins/numeric_catalog.rb +15 -4
  25. data/lib/rigor/inference/expression_typer.rb +12 -2
  26. data/lib/rigor/inference/macro_block_self_type.rb +96 -0
  27. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +29 -29
  28. data/lib/rigor/inference/method_dispatcher/kernel_dispatch.rb +4 -4
  29. data/lib/rigor/inference/method_dispatcher/method_folding.rb +18 -1
  30. data/lib/rigor/inference/method_dispatcher/overload_selector.rb +1 -1
  31. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +46 -40
  32. data/lib/rigor/inference/method_dispatcher.rb +128 -3
  33. data/lib/rigor/inference/method_parameter_binder.rb +21 -11
  34. data/lib/rigor/inference/narrowing.rb +127 -8
  35. data/lib/rigor/inference/synthetic_method.rb +86 -0
  36. data/lib/rigor/inference/synthetic_method_index.rb +82 -0
  37. data/lib/rigor/inference/synthetic_method_scanner.rb +521 -0
  38. data/lib/rigor/plugin/blueprint.rb +60 -0
  39. data/lib/rigor/plugin/loader.rb +3 -1
  40. data/lib/rigor/plugin/macro/block_as_method.rb +131 -0
  41. data/lib/rigor/plugin/macro/external_file.rb +143 -0
  42. data/lib/rigor/plugin/macro/heredoc_template.rb +201 -0
  43. data/lib/rigor/plugin/macro/trait_registry.rb +198 -0
  44. data/lib/rigor/plugin/macro.rb +31 -0
  45. data/lib/rigor/plugin/manifest.rb +78 -7
  46. data/lib/rigor/plugin/registry.rb +32 -2
  47. data/lib/rigor/plugin.rb +1 -0
  48. data/lib/rigor/trinary.rb +15 -11
  49. data/lib/rigor/type/bot.rb +6 -3
  50. data/lib/rigor/type/combinator.rb +12 -1
  51. data/lib/rigor/type/integer_range.rb +7 -7
  52. data/lib/rigor/type/refined.rb +18 -12
  53. data/lib/rigor/type/top.rb +4 -3
  54. data/lib/rigor/type_node/generic.rb +7 -1
  55. data/lib/rigor/type_node/identifier.rb +9 -1
  56. data/lib/rigor/type_node/string_literal.rb +4 -1
  57. data/lib/rigor/version.rb +1 -1
  58. data/sig/rigor/environment.rbs +5 -2
  59. data/sig/rigor/plugin/blueprint.rbs +7 -0
  60. data/sig/rigor/plugin/manifest.rbs +1 -1
  61. data/sig/rigor/plugin/registry.rbs +14 -1
  62. data/sig/rigor.rbs +35 -2
  63. metadata +39 -1
@@ -0,0 +1,131 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rigor
4
+ module Plugin
5
+ module Macro
6
+ # ADR-16 Tier A declaration: "the block passed to a
7
+ # class-level DSL call of one of `verbs` runs as an instance
8
+ # method on `receiver_constraint`'s subclass tree, with
9
+ # `self` typed accordingly."
10
+ #
11
+ # Authored on a plugin manifest:
12
+ #
13
+ # manifest(
14
+ # id: "sinatra",
15
+ # version: "0.1.0",
16
+ # block_as_methods: [
17
+ # Rigor::Plugin::Macro::BlockAsMethod.new(
18
+ # receiver_constraint: "Sinatra::Base",
19
+ # verbs: %i[get post put delete head options patch link unlink]
20
+ # )
21
+ # ]
22
+ # )
23
+ #
24
+ # Sinatra is the canonical worked target (`Sinatra::Base#generate_method`
25
+ # at `lib/sinatra/base.rb:1788-1793` literally does
26
+ # `define_method(name, &block); remove_method` — the block IS
27
+ # the method body, byte-for-byte). The substrate adopts the
28
+ # same contract: declare the receiver constraint + the
29
+ # class-level methods whose block argument runs as if it were
30
+ # an instance method of the receiver.
31
+ #
32
+ # Slice 1a (this file) is **the contract only**. The engine
33
+ # hook that consults registered entries and narrows
34
+ # `Scope#self_type` for a block whose enclosing call matches
35
+ # arrives in slice 1b.
36
+ #
37
+ # ## Fields
38
+ #
39
+ # - `receiver_constraint` — fully-qualified class name (String)
40
+ # that the call's lexical receiver MUST be (or inherit from)
41
+ # for the entry to fire. For Sinatra modular-style this is
42
+ # `"Sinatra::Base"`; the substrate's class-context match
43
+ # accepts every subclass.
44
+ # - `verbs` — Array of Symbol method names. A call shape
45
+ # `<receiver_subclass>.get('/path') { ... }` matches when
46
+ # `:get` is in this list.
47
+ # - `self_type` — Symbol selecting the kind of `self`-binding
48
+ # the substrate applies inside the block. Slice 1a accepts
49
+ # only `:receiver_instance` (the block runs as an instance
50
+ # method of the receiver class). Other kinds (`:receiver_singleton`,
51
+ # `:dsl_recorder`) are reserved for later slices.
52
+ #
53
+ # ## Ractor-shareability
54
+ #
55
+ # All fields are frozen at construction (ADR-15 Phase 1).
56
+ # `verbs` is dup-frozen so the caller's mutable array does
57
+ # not leak into the value. `Ractor.shareable?` returns true
58
+ # after `#initialize`.
59
+ class BlockAsMethod
60
+ SELF_TYPE_RECEIVER_INSTANCE = :receiver_instance
61
+ VALID_SELF_TYPES = [SELF_TYPE_RECEIVER_INSTANCE].freeze
62
+
63
+ attr_reader :receiver_constraint, :verbs, :self_type
64
+
65
+ def initialize(receiver_constraint:, verbs:, self_type: SELF_TYPE_RECEIVER_INSTANCE)
66
+ validate_receiver_constraint!(receiver_constraint)
67
+ validate_verbs!(verbs)
68
+ validate_self_type!(self_type)
69
+
70
+ @receiver_constraint = receiver_constraint.dup.freeze
71
+ @verbs = verbs.map(&:to_sym).freeze
72
+ @self_type = self_type
73
+ freeze
74
+ end
75
+
76
+ def to_h
77
+ {
78
+ "receiver_constraint" => receiver_constraint,
79
+ "verbs" => verbs.map(&:to_s),
80
+ "self_type" => self_type.to_s
81
+ }
82
+ end
83
+
84
+ def ==(other)
85
+ other.is_a?(BlockAsMethod) &&
86
+ receiver_constraint == other.receiver_constraint &&
87
+ verbs == other.verbs &&
88
+ self_type == other.self_type
89
+ end
90
+ alias eql? ==
91
+
92
+ def hash
93
+ [receiver_constraint, verbs, self_type].hash
94
+ end
95
+
96
+ private
97
+
98
+ def validate_receiver_constraint!(value)
99
+ return if value.is_a?(String) && !value.empty?
100
+
101
+ raise ArgumentError,
102
+ "Plugin::Macro::BlockAsMethod#receiver_constraint must be a non-empty String, " \
103
+ "got #{value.inspect}"
104
+ end
105
+
106
+ def validate_verbs!(verbs)
107
+ unless verbs.is_a?(Array) && !verbs.empty?
108
+ raise ArgumentError,
109
+ "Plugin::Macro::BlockAsMethod#verbs must be a non-empty Array, got #{verbs.inspect}"
110
+ end
111
+
112
+ verbs.each do |v|
113
+ next if v.is_a?(Symbol) || (v.is_a?(String) && !v.empty?)
114
+
115
+ raise ArgumentError,
116
+ "Plugin::Macro::BlockAsMethod#verbs entries must be Symbol/non-empty String, " \
117
+ "got #{v.inspect}"
118
+ end
119
+ end
120
+
121
+ def validate_self_type!(self_type)
122
+ return if VALID_SELF_TYPES.include?(self_type)
123
+
124
+ raise ArgumentError,
125
+ "Plugin::Macro::BlockAsMethod#self_type must be one of #{VALID_SELF_TYPES.inspect}, " \
126
+ "got #{self_type.inspect}"
127
+ end
128
+ end
129
+ end
130
+ end
131
+ end
@@ -0,0 +1,143 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rigor
4
+ module Plugin
5
+ module Macro
6
+ # ADR-16 Tier D declaration: "files matching `glob` are
7
+ # analysed as if their body were pasted at a call site whose
8
+ # `self` is an instance of `receiver_type` (and whose `@ivar`
9
+ # facts come from `bound_ivars`)."
10
+ #
11
+ # Worked motivating cases (per the per-library survey):
12
+ #
13
+ # - Redmine's `WebhookPayload#instance_eval(File.read(path), path, 1)`
14
+ # at `app/models/webhook_payload.rb:71`. The payload templates
15
+ # under `config/webhooks/*.rb` run with `self` typed as
16
+ # `Redmine::WebhookPayload` and ivars like `@event` / `@issue`
17
+ # / `@user` pre-bound by the caller.
18
+ # - tDiary Core's plugin loader pattern — `misc/plugin/*.rb`
19
+ # files loaded under `instance_eval` with the tDiary plugin
20
+ # instance as `self`.
21
+ #
22
+ # ## Authoring shape
23
+ #
24
+ # manifest(
25
+ # id: "redmine-webhook-payloads",
26
+ # version: "0.1.0",
27
+ # external_files: [
28
+ # Rigor::Plugin::Macro::ExternalFile.new(
29
+ # glob: "config/webhooks/*.rb",
30
+ # receiver_type: "Redmine::WebhookPayload",
31
+ # bound_ivars: {
32
+ # "@event" => "Symbol",
33
+ # "@issue" => "Issue?",
34
+ # "@user" => "User"
35
+ # }
36
+ # )
37
+ # ]
38
+ # )
39
+ #
40
+ # ## Fields
41
+ #
42
+ # - `glob` — non-empty String pattern. Interpreted relative
43
+ # to the project root (the directory containing `.rigor.yml`)
44
+ # at scan time. Slice 5a accepts any non-empty glob
45
+ # pattern syntactically; the engine integration (slice 5b)
46
+ # pins the resolution rule.
47
+ # - `receiver_type` — non-empty String. The class name `self`
48
+ # inside the loaded file binds to. Engine integration (slice
49
+ # 5b) narrows the file-entry scope's `self_type` to
50
+ # `Nominal[receiver_type]`.
51
+ # - `bound_ivars` — Hash<String, String>. Each key MUST start
52
+ # with `@`; each value is a non-empty type-name String. The
53
+ # engine pre-binds these as ivar facts in the file-entry
54
+ # scope (slice 5b).
55
+ #
56
+ # ## Slice 5a scope
57
+ #
58
+ # **This file ships the value class + manifest hook ONLY.**
59
+ # The engine integration that (a) adds matched files to the
60
+ # analysis set, (b) narrows the file-entry `self_type`, and
61
+ # (c) pre-binds `bound_ivars` as ivar facts is **queued for
62
+ # slice 5b**, gated on demonstrated demand. The survey
63
+ # identifies only Redmine + tDiary as concrete consumers;
64
+ # premature engine work is deferred until those cases (or
65
+ # equivalents) materialise as committed plugin targets.
66
+ #
67
+ # With only this slice landed, plugin authors CAN declare a
68
+ # Tier D manifest entry today — the declaration round-trips
69
+ # through `Manifest#to_h` (cache-key stable) and is exposed
70
+ # on `Manifest#external_files` — but the substrate does not
71
+ # yet act on it. The contract is forward-compatible: when
72
+ # slice 5b lands, the engine reads the same declarations and
73
+ # plugin gems do not need to change.
74
+ class ExternalFile
75
+ attr_reader :glob, :receiver_type, :bound_ivars
76
+
77
+ def initialize(glob:, receiver_type:, bound_ivars: {})
78
+ validate_glob!(glob)
79
+ validate_receiver_type!(receiver_type)
80
+ validate_bound_ivars!(bound_ivars)
81
+
82
+ @glob = glob.dup.freeze
83
+ @receiver_type = receiver_type.dup.freeze
84
+ @bound_ivars = bound_ivars.to_h { |k, v| [k.dup.freeze, v.dup.freeze] }.freeze
85
+ freeze
86
+ end
87
+
88
+ def to_h
89
+ {
90
+ "glob" => glob,
91
+ "receiver_type" => receiver_type,
92
+ "bound_ivars" => bound_ivars
93
+ }
94
+ end
95
+
96
+ def ==(other)
97
+ other.is_a?(ExternalFile) && to_h == other.to_h
98
+ end
99
+ alias eql? ==
100
+
101
+ def hash
102
+ to_h.hash
103
+ end
104
+
105
+ private
106
+
107
+ def validate_glob!(value)
108
+ return if value.is_a?(String) && !value.empty?
109
+
110
+ raise ArgumentError,
111
+ "Plugin::Macro::ExternalFile#glob must be a non-empty String, got #{value.inspect}"
112
+ end
113
+
114
+ def validate_receiver_type!(value)
115
+ return if value.is_a?(String) && !value.empty?
116
+
117
+ raise ArgumentError,
118
+ "Plugin::Macro::ExternalFile#receiver_type must be a non-empty String, got #{value.inspect}"
119
+ end
120
+
121
+ def validate_bound_ivars!(value)
122
+ unless value.is_a?(Hash)
123
+ raise ArgumentError,
124
+ "Plugin::Macro::ExternalFile#bound_ivars must be a Hash, got #{value.inspect}"
125
+ end
126
+
127
+ value.each do |k, v|
128
+ unless k.is_a?(String) && k.start_with?("@") && k.length > 1
129
+ raise ArgumentError,
130
+ "Plugin::Macro::ExternalFile#bound_ivars key must be a String starting with `@`, " \
131
+ "got #{k.inspect}"
132
+ end
133
+ next if v.is_a?(String) && !v.empty?
134
+
135
+ raise ArgumentError,
136
+ "Plugin::Macro::ExternalFile#bound_ivars value must be a non-empty String, " \
137
+ "got #{v.inspect}"
138
+ end
139
+ end
140
+ end
141
+ end
142
+ end
143
+ end
@@ -0,0 +1,201 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rigor
4
+ module Plugin
5
+ module Macro
6
+ # ADR-16 Tier C declaration: "the class-level DSL call
7
+ # `<receiver_constraint>.<method_name>(name_arg, ...)` emits
8
+ # synthetic methods on the calling class, with names
9
+ # interpolating the source-visible literal argument at
10
+ # `symbol_arg_position`."
11
+ #
12
+ # Textbook target — dry-struct's `attribute :name, T` and
13
+ # ActiveStorage's `has_one_attached :avatar` both have this
14
+ # shape: a class-level call enumerates a literal Symbol
15
+ # argument; the framework `class_eval`s a heredoc
16
+ # interpolating that Symbol; the emit table is fixed.
17
+ #
18
+ # ## Authoring shape
19
+ #
20
+ # manifest(
21
+ # id: "activestorage",
22
+ # version: "0.1.0",
23
+ # heredoc_templates: [
24
+ # Rigor::Plugin::Macro::HeredocTemplate.new(
25
+ # receiver_constraint: "ActiveRecord::Base",
26
+ # method_name: :has_one_attached,
27
+ # symbol_arg_position: 0,
28
+ # emit: [
29
+ # { name: "#{name}", returns: "ActiveStorage::Attached::One" },
30
+ # { name: "#{name}_attachment", returns: "ActiveStorage::Attachment" },
31
+ # { name: "#{name}_blob", returns: "ActiveStorage::Blob" }
32
+ # ],
33
+ # class_level_emit: [
34
+ # { name: "with_attached_#{name}", returns: "ActiveRecord::Relation" }
35
+ # ]
36
+ # )
37
+ # ]
38
+ # )
39
+ #
40
+ # ## Fields
41
+ #
42
+ # - `receiver_constraint` — fully-qualified class name (String).
43
+ # Synthesis fires when the call's lexical receiver class
44
+ # equals or inherits from this constraint.
45
+ # - `method_name` — Symbol naming the DSL method (e.g.
46
+ # `:has_one_attached`, `:attribute`).
47
+ # - `symbol_arg_position` — Integer (default 0) — the
48
+ # argument index whose literal Symbol value becomes the
49
+ # `name` interpolated into each emit row's `name:`
50
+ # template. Slice 2a accepts non-negative integers only.
51
+ # - `emit` — Array of `Emit` (or coerced Hash) — instance
52
+ # methods to synthesise on the calling class.
53
+ # - `class_level_emit` — same shape, but the synthesised
54
+ # methods are singleton (class-level) methods.
55
+ #
56
+ # ## Floor / ceiling per ADR-16 WD13
57
+ #
58
+ # Slice 2 ships at the **floor**: each emit row's `name:`
59
+ # is the source of truth for the synthetic method's name
60
+ # (a single `"\#{name}"` placeholder gets interpolated with
61
+ # the literal symbol argument at `symbol_arg_position`).
62
+ # The `returns:` strings are **recorded in the manifest but
63
+ # not resolved**; the engine emits synthetic methods with
64
+ # `Dynamic[T]` returns plus a
65
+ # `macro.tier_c.unresolved-return` provenance marker.
66
+ # Precise return-type resolution via ADR-13's
67
+ # `Plugin::TypeNodeResolver` is the **ceiling**, deferred
68
+ # to a later slice — the `returns:` declarations cost
69
+ # nothing to write today and unlock precision then.
70
+ #
71
+ # ## Slice 2a scope
72
+ #
73
+ # This file ships the value class only. Slice 2b wires the
74
+ # pre-pass that scans Tier C call sites + the
75
+ # `SyntheticMethodIndex` the dispatcher consults; slice 2c
76
+ # authors `examples/rigor-dry-struct/` and
77
+ # `examples/rigor-dry-types/` as the worked consumers.
78
+ class HeredocTemplate
79
+ NAME_PLACEHOLDER = "\#{name}"
80
+
81
+ attr_reader :receiver_constraint, :method_name, :symbol_arg_position, :emit, :class_level_emit
82
+
83
+ def initialize(receiver_constraint:, method_name:, symbol_arg_position: 0, emit: [], class_level_emit: [])
84
+ validate_receiver_constraint!(receiver_constraint)
85
+ validate_method_name!(method_name)
86
+ validate_symbol_arg_position!(symbol_arg_position)
87
+
88
+ @receiver_constraint = receiver_constraint.dup.freeze
89
+ @method_name = method_name.to_sym
90
+ @symbol_arg_position = symbol_arg_position
91
+ @emit = coerce_emit_list!(emit, "emit")
92
+ @class_level_emit = coerce_emit_list!(class_level_emit, "class_level_emit")
93
+ freeze
94
+ end
95
+
96
+ def to_h
97
+ {
98
+ "receiver_constraint" => receiver_constraint,
99
+ "method_name" => method_name.to_s,
100
+ "symbol_arg_position" => symbol_arg_position,
101
+ "emit" => emit.map(&:to_h),
102
+ "class_level_emit" => class_level_emit.map(&:to_h)
103
+ }
104
+ end
105
+
106
+ def ==(other)
107
+ other.is_a?(HeredocTemplate) && to_h == other.to_h
108
+ end
109
+ alias eql? ==
110
+
111
+ def hash
112
+ to_h.hash
113
+ end
114
+
115
+ # One row of an emit table: the synthetic method's
116
+ # name-template (the analyzer interpolates `\#{name}` with
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).
120
+ class Emit
121
+ attr_reader :name, :returns
122
+
123
+ def initialize(name:, returns:)
124
+ unless name.is_a?(String) && !name.empty?
125
+ raise ArgumentError,
126
+ "Macro::HeredocTemplate::Emit#name must be a non-empty String, got #{name.inspect}"
127
+ end
128
+ unless returns.is_a?(String) && !returns.empty?
129
+ raise ArgumentError,
130
+ "Macro::HeredocTemplate::Emit#returns must be a non-empty String, got #{returns.inspect}"
131
+ end
132
+
133
+ @name = name.dup.freeze
134
+ @returns = returns.dup.freeze
135
+ freeze
136
+ end
137
+
138
+ def to_h
139
+ { "name" => name, "returns" => returns }
140
+ end
141
+
142
+ def ==(other)
143
+ other.is_a?(Emit) && name == other.name && returns == other.returns
144
+ end
145
+ alias eql? ==
146
+
147
+ def hash
148
+ [name, returns].hash
149
+ end
150
+ end
151
+
152
+ private
153
+
154
+ def validate_receiver_constraint!(value)
155
+ return if value.is_a?(String) && !value.empty?
156
+
157
+ raise ArgumentError,
158
+ "Plugin::Macro::HeredocTemplate#receiver_constraint must be a non-empty String, " \
159
+ "got #{value.inspect}"
160
+ end
161
+
162
+ def validate_method_name!(value)
163
+ return if value.is_a?(Symbol) || (value.is_a?(String) && !value.empty?)
164
+
165
+ raise ArgumentError,
166
+ "Plugin::Macro::HeredocTemplate#method_name must be Symbol or non-empty String, " \
167
+ "got #{value.inspect}"
168
+ end
169
+
170
+ def validate_symbol_arg_position!(value)
171
+ return if value.is_a?(Integer) && value >= 0
172
+
173
+ raise ArgumentError,
174
+ "Plugin::Macro::HeredocTemplate#symbol_arg_position must be a non-negative Integer, " \
175
+ "got #{value.inspect}"
176
+ end
177
+
178
+ def coerce_emit_list!(entries, label)
179
+ unless entries.is_a?(Array)
180
+ raise ArgumentError,
181
+ "Plugin::Macro::HeredocTemplate##{label} must be an Array, got #{entries.inspect}"
182
+ end
183
+
184
+ entries.map { |entry| coerce_emit_entry!(entry, label) }.freeze
185
+ end
186
+
187
+ def coerce_emit_entry!(entry, label)
188
+ case entry
189
+ when Emit then entry
190
+ when Hash
191
+ Emit.new(name: entry[:name] || entry["name"], returns: entry[:returns] || entry["returns"])
192
+ else
193
+ raise ArgumentError,
194
+ "Plugin::Macro::HeredocTemplate##{label} entry must be an Emit or Hash, " \
195
+ "got #{entry.inspect}"
196
+ end
197
+ end
198
+ end
199
+ end
200
+ end
201
+ end
@@ -0,0 +1,198 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rigor
4
+ module Plugin
5
+ module Macro
6
+ # ADR-16 Tier B declaration: "the class-level DSL call
7
+ # `<receiver_constraint>.<method_name>(:trait_a, :trait_b, ...)`
8
+ # effectively includes the modules named in
9
+ # `modules_by_symbol[:trait_a]` + `[:trait_b]` (plus any
10
+ # `always_included` modules) on the calling class."
11
+ #
12
+ # Worked target: Devise's model-side `devise :database_authenticatable,
13
+ # :recoverable` DSL (per-library survey § Devise). The bundled
14
+ # registry mirrors `lib/devise/modules.rb`'s symbol → module table;
15
+ # `always_included` carries the modules Devise always mixes in
16
+ # regardless of selection (e.g. `Devise::Models::Authenticatable`).
17
+ #
18
+ # ## Authoring shape
19
+ #
20
+ # manifest(
21
+ # id: "devise",
22
+ # version: "0.1.0",
23
+ # trait_registries: [
24
+ # Rigor::Plugin::Macro::TraitRegistry.new(
25
+ # receiver_constraint: "ActiveRecord::Base",
26
+ # method_name: :devise,
27
+ # symbol_arg_position: :rest,
28
+ # modules_by_symbol: {
29
+ # database_authenticatable: "Devise::Models::DatabaseAuthenticatable",
30
+ # recoverable: "Devise::Models::Recoverable",
31
+ # rememberable: "Devise::Models::Rememberable"
32
+ # },
33
+ # always_included: ["Devise::Models::Authenticatable"]
34
+ # )
35
+ # ]
36
+ # )
37
+ #
38
+ # ## Fields
39
+ #
40
+ # - `receiver_constraint` — fully-qualified class name (String).
41
+ # Synthesis fires when the call's lexical receiver class
42
+ # equals or inherits from this constraint.
43
+ # - `method_name` — Symbol naming the DSL method
44
+ # (e.g. `:devise`).
45
+ # - `symbol_arg_position` — `:rest` (all positional Symbol args
46
+ # are traits, slice 3a's only supported form) or a
47
+ # non-negative Integer (the index of a single trait symbol —
48
+ # reserved for future shapes; not yet honoured by the
49
+ # scanner).
50
+ # - `modules_by_symbol` — Hash<Symbol, String>. Maps each
51
+ # recognised trait symbol to a fully-qualified module name.
52
+ # Symbols not in the table fall through (silent skip; the
53
+ # scanner emits a `macro.tier_b.unknown-trait` `:info`
54
+ # provenance marker per WD9 / WD13).
55
+ # - `always_included` — Array<String>. Fully-qualified module
56
+ # names that are added to every call site (even when no
57
+ # symbols match). Mirrors Devise's `always_include` modules.
58
+ #
59
+ # ## Floor / ceiling per ADR-16 WD13
60
+ #
61
+ # Slice 3 ships at the **floor**: the substrate per-method-
62
+ # explodes each included module's RBS instance methods into
63
+ # the existing `SyntheticMethodIndex` (slice 2b primitive).
64
+ # The synthesised methods adopt the module's authored RBS
65
+ # return types — Tier B is NOT subject to the Tier C
66
+ # `Dynamic[T]` floor because the source-of-truth (the
67
+ # module's authored RBS) is not a manifest-declared string.
68
+ # Per ADR-5 robustness, the substrate does not fabricate
69
+ # precision; it simply replays the modules's signatures.
70
+ #
71
+ # **Out of scope for slice 3** (deferred follow-ups):
72
+ # - `class_methods_module:` per-trait (Devise's `ClassMethods`
73
+ # extend-pattern); slice 3 covers instance methods only.
74
+ # - `sort_key:` for controlled include ordering across traits;
75
+ # slice 3 uses plugin-registration order then registry
76
+ # declaration order.
77
+ # - `included_do_digest:` — the per-module `included do` block
78
+ # facts (attr_reader / after_save / etc.); slice 3 emits
79
+ # only the module's plain instance methods.
80
+ #
81
+ # ## Slice 3a scope
82
+ #
83
+ # This file ships the value class only. Slice 3b wires the
84
+ # scanner that walks Tier B call sites + the per-method
85
+ # explosion via `SyntheticMethodIndex`; slice 3c authors
86
+ # `examples/rigor-devise/` model side as the worked consumer.
87
+ class TraitRegistry
88
+ REST_POSITION = :rest
89
+
90
+ attr_reader :receiver_constraint, :method_name, :symbol_arg_position, :modules_by_symbol, :always_included
91
+
92
+ def initialize(receiver_constraint:, method_name:, symbol_arg_position: REST_POSITION,
93
+ modules_by_symbol: {}, always_included: [])
94
+ validate_receiver_constraint!(receiver_constraint)
95
+ validate_method_name!(method_name)
96
+ validate_symbol_arg_position!(symbol_arg_position)
97
+ validate_modules_by_symbol!(modules_by_symbol)
98
+ validate_always_included!(always_included)
99
+
100
+ @receiver_constraint = receiver_constraint.dup.freeze
101
+ @method_name = method_name.to_sym
102
+ @symbol_arg_position = symbol_arg_position
103
+ @modules_by_symbol = modules_by_symbol.to_h { |k, v| [k.to_sym, v.dup.freeze] }.freeze
104
+ @always_included = always_included.map { |m| m.dup.freeze }.freeze
105
+ freeze
106
+ end
107
+
108
+ def to_h
109
+ {
110
+ "receiver_constraint" => receiver_constraint,
111
+ "method_name" => method_name.to_s,
112
+ "symbol_arg_position" => symbol_arg_position.to_s,
113
+ "modules_by_symbol" => modules_by_symbol.to_h { |k, v| [k.to_s, v] },
114
+ "always_included" => always_included
115
+ }
116
+ end
117
+
118
+ def ==(other)
119
+ other.is_a?(TraitRegistry) && to_h == other.to_h
120
+ end
121
+ alias eql? ==
122
+
123
+ def hash
124
+ to_h.hash
125
+ end
126
+
127
+ # @return [String, nil] fully-qualified module name for the
128
+ # given trait symbol, or nil when the registry doesn't
129
+ # know the symbol (caller emits a tier_b.unknown-trait
130
+ # provenance marker and falls through).
131
+ def module_for(symbol)
132
+ modules_by_symbol[symbol.to_sym]
133
+ end
134
+
135
+ private
136
+
137
+ def validate_receiver_constraint!(value)
138
+ return if value.is_a?(String) && !value.empty?
139
+
140
+ raise ArgumentError,
141
+ "Plugin::Macro::TraitRegistry#receiver_constraint must be a non-empty String, " \
142
+ "got #{value.inspect}"
143
+ end
144
+
145
+ def validate_method_name!(value)
146
+ return if value.is_a?(Symbol) || (value.is_a?(String) && !value.empty?)
147
+
148
+ raise ArgumentError,
149
+ "Plugin::Macro::TraitRegistry#method_name must be Symbol or non-empty String, " \
150
+ "got #{value.inspect}"
151
+ end
152
+
153
+ def validate_symbol_arg_position!(value)
154
+ return if value == REST_POSITION || (value.is_a?(Integer) && value >= 0)
155
+
156
+ raise ArgumentError,
157
+ "Plugin::Macro::TraitRegistry#symbol_arg_position must be :rest or a non-negative Integer, " \
158
+ "got #{value.inspect}"
159
+ end
160
+
161
+ def validate_modules_by_symbol!(value)
162
+ unless value.is_a?(Hash)
163
+ raise ArgumentError,
164
+ "Plugin::Macro::TraitRegistry#modules_by_symbol must be a Hash, got #{value.inspect}"
165
+ end
166
+
167
+ value.each do |k, v|
168
+ unless k.is_a?(Symbol) || (k.is_a?(String) && !k.empty?)
169
+ raise ArgumentError,
170
+ "Plugin::Macro::TraitRegistry#modules_by_symbol key must be Symbol/non-empty String, " \
171
+ "got #{k.inspect}"
172
+ end
173
+ next if v.is_a?(String) && !v.empty?
174
+
175
+ raise ArgumentError,
176
+ "Plugin::Macro::TraitRegistry#modules_by_symbol value must be a non-empty String, " \
177
+ "got #{v.inspect}"
178
+ end
179
+ end
180
+
181
+ def validate_always_included!(value)
182
+ unless value.is_a?(Array)
183
+ raise ArgumentError,
184
+ "Plugin::Macro::TraitRegistry#always_included must be an Array, got #{value.inspect}"
185
+ end
186
+
187
+ value.each do |m|
188
+ next if m.is_a?(String) && !m.empty?
189
+
190
+ raise ArgumentError,
191
+ "Plugin::Macro::TraitRegistry#always_included entry must be a non-empty String, " \
192
+ "got #{m.inspect}"
193
+ end
194
+ end
195
+ end
196
+ end
197
+ end
198
+ end