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.
- checksums.yaml +4 -4
- data/README.md +40 -13
- data/lib/rigor/analysis/fact_store.rb +15 -3
- data/lib/rigor/analysis/result.rb +11 -3
- data/lib/rigor/analysis/run_stats.rb +193 -0
- data/lib/rigor/analysis/runner.rb +387 -12
- data/lib/rigor/analysis/worker_session.rb +327 -0
- data/lib/rigor/builtins/imported_refinements.rb +6 -2
- data/lib/rigor/builtins/regex_refinement.rb +17 -12
- data/lib/rigor/cache/rbs_descriptor.rb +3 -1
- data/lib/rigor/cache/store.rb +40 -7
- data/lib/rigor/cli.rb +52 -2
- data/lib/rigor/configuration.rb +131 -6
- data/lib/rigor/environment/bundle_sig_discovery.rb +198 -0
- data/lib/rigor/environment/class_registry.rb +12 -3
- data/lib/rigor/environment/lockfile_resolver.rb +125 -0
- data/lib/rigor/environment/rbs_collection_discovery.rb +126 -0
- data/lib/rigor/environment/rbs_coverage_report.rb +112 -0
- data/lib/rigor/environment/rbs_loader.rb +194 -6
- data/lib/rigor/environment/reflection.rb +152 -0
- data/lib/rigor/environment.rb +78 -6
- data/lib/rigor/inference/acceptance.rb +35 -1
- data/lib/rigor/inference/builtins/method_catalog.rb +12 -5
- data/lib/rigor/inference/builtins/numeric_catalog.rb +15 -4
- data/lib/rigor/inference/expression_typer.rb +12 -2
- data/lib/rigor/inference/macro_block_self_type.rb +96 -0
- data/lib/rigor/inference/method_dispatcher/constant_folding.rb +29 -29
- data/lib/rigor/inference/method_dispatcher/kernel_dispatch.rb +4 -4
- data/lib/rigor/inference/method_dispatcher/method_folding.rb +18 -1
- data/lib/rigor/inference/method_dispatcher/overload_selector.rb +1 -1
- data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +46 -40
- data/lib/rigor/inference/method_dispatcher.rb +128 -3
- data/lib/rigor/inference/method_parameter_binder.rb +21 -11
- data/lib/rigor/inference/narrowing.rb +127 -8
- data/lib/rigor/inference/synthetic_method.rb +86 -0
- data/lib/rigor/inference/synthetic_method_index.rb +82 -0
- data/lib/rigor/inference/synthetic_method_scanner.rb +521 -0
- data/lib/rigor/plugin/blueprint.rb +60 -0
- data/lib/rigor/plugin/loader.rb +3 -1
- data/lib/rigor/plugin/macro/block_as_method.rb +131 -0
- data/lib/rigor/plugin/macro/external_file.rb +143 -0
- data/lib/rigor/plugin/macro/heredoc_template.rb +201 -0
- data/lib/rigor/plugin/macro/trait_registry.rb +198 -0
- data/lib/rigor/plugin/macro.rb +31 -0
- data/lib/rigor/plugin/manifest.rb +78 -7
- data/lib/rigor/plugin/registry.rb +32 -2
- data/lib/rigor/plugin.rb +1 -0
- data/lib/rigor/trinary.rb +15 -11
- data/lib/rigor/type/bot.rb +6 -3
- data/lib/rigor/type/combinator.rb +12 -1
- data/lib/rigor/type/integer_range.rb +7 -7
- data/lib/rigor/type/refined.rb +18 -12
- data/lib/rigor/type/top.rb +4 -3
- data/lib/rigor/type_node/generic.rb +7 -1
- data/lib/rigor/type_node/identifier.rb +9 -1
- data/lib/rigor/type_node/string_literal.rb +4 -1
- data/lib/rigor/version.rb +1 -1
- data/sig/rigor/environment.rbs +5 -2
- data/sig/rigor/plugin/blueprint.rbs +7 -0
- data/sig/rigor/plugin/manifest.rbs +1 -1
- data/sig/rigor/plugin/registry.rbs +14 -1
- data/sig/rigor.rbs +35 -2
- metadata +39 -1
|
@@ -0,0 +1,521 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "prism"
|
|
4
|
+
|
|
5
|
+
require_relative "../plugin/macro/heredoc_template"
|
|
6
|
+
require_relative "../plugin/macro/trait_registry"
|
|
7
|
+
require_relative "synthetic_method"
|
|
8
|
+
require_relative "synthetic_method_index"
|
|
9
|
+
|
|
10
|
+
module Rigor
|
|
11
|
+
module Inference
|
|
12
|
+
# ADR-16 slice 2b pre-pass — scans the project's source paths
|
|
13
|
+
# for class-level DSL calls that match any registered plugin's
|
|
14
|
+
# `Plugin::Macro::HeredocTemplate` entry, instantiates the
|
|
15
|
+
# corresponding {SyntheticMethod} records, and returns a frozen
|
|
16
|
+
# {SyntheticMethodIndex} the dispatcher consults below the RBS
|
|
17
|
+
# tier (per WD13 — user-authored RBS overrides substrate
|
|
18
|
+
# synthesis).
|
|
19
|
+
#
|
|
20
|
+
# Two-phase walk:
|
|
21
|
+
#
|
|
22
|
+
# 1. **Hierarchy collection.** Visit every `class X < Y` decl
|
|
23
|
+
# in the project source set and record the parent chain in
|
|
24
|
+
# a lexical inheritance map. Cross-file ordering does not
|
|
25
|
+
# matter — every class in `paths:` is observed before
|
|
26
|
+
# matching starts.
|
|
27
|
+
# 2. **Match + emit.** Re-walk each class body looking for
|
|
28
|
+
# `Prism::CallNode` whose name matches a template's
|
|
29
|
+
# `method_name` and whose argument at
|
|
30
|
+
# `symbol_arg_position` is a literal Symbol. The enclosing
|
|
31
|
+
# class must equal or inherit (lexically OR through the
|
|
32
|
+
# RBS env) from the template's `receiver_constraint`.
|
|
33
|
+
#
|
|
34
|
+
# Per WD4 the pre-pass mechanism is "scan all files once at
|
|
35
|
+
# startup, populate the index before per-file inference."
|
|
36
|
+
# Slice 2b ships this strategy; future iterations may revisit
|
|
37
|
+
# to lazy emit (per WD4 alternatives) if the warm-cache profile
|
|
38
|
+
# justifies it.
|
|
39
|
+
#
|
|
40
|
+
# Per WD13 floor — `return_type` is recorded but not resolved.
|
|
41
|
+
# `Macro::HeredocTemplate::Emit#returns` strings round-trip
|
|
42
|
+
# through {SyntheticMethod#return_type} verbatim; the
|
|
43
|
+
# dispatcher's slice-2b tier translates every match to
|
|
44
|
+
# `Dynamic[T]`. Precise resolution via the ADR-13 resolver
|
|
45
|
+
# chain is the ceiling, deferred.
|
|
46
|
+
module SyntheticMethodScanner # rubocop:disable Metrics/ModuleLength
|
|
47
|
+
module_function
|
|
48
|
+
|
|
49
|
+
# @param plugin_registry [Rigor::Plugin::Registry]
|
|
50
|
+
# @param paths [Array<String>] absolute paths to the project
|
|
51
|
+
# source files to scan.
|
|
52
|
+
# @param environment [Rigor::Environment, nil] used for
|
|
53
|
+
# inheritance resolution against RBS-known classes
|
|
54
|
+
# (ActiveRecord::Base, Dry::Struct, etc.) that aren't
|
|
55
|
+
# declared in project source.
|
|
56
|
+
# @return [Rigor::Inference::SyntheticMethodIndex]
|
|
57
|
+
def scan(plugin_registry:, paths:, environment: nil)
|
|
58
|
+
templates = collect_templates(plugin_registry)
|
|
59
|
+
registries = collect_trait_registries(plugin_registry)
|
|
60
|
+
return SyntheticMethodIndex::EMPTY if templates.empty? && registries.empty?
|
|
61
|
+
|
|
62
|
+
asts = parse_paths(paths)
|
|
63
|
+
hierarchy = build_hierarchy(asts)
|
|
64
|
+
concern_index = build_concern_index(asts)
|
|
65
|
+
|
|
66
|
+
entries = []
|
|
67
|
+
asts.each do |path, ast|
|
|
68
|
+
walk_class_bodies(ast) do |class_name, call_node|
|
|
69
|
+
collect_entries(entries, templates, class_name, call_node, hierarchy, environment, path)
|
|
70
|
+
collect_trait_entries(entries, registries, class_name, call_node, hierarchy, environment, path)
|
|
71
|
+
collect_concern_re_targeted_entries(
|
|
72
|
+
entries, call_node, class_name, concern_index,
|
|
73
|
+
templates, registries, hierarchy, environment, path
|
|
74
|
+
)
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
SyntheticMethodIndex.new(entries: entries)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Aggregates `(plugin_id, template)` pairs across every
|
|
82
|
+
# plugin's `manifest.heredoc_templates` in registration
|
|
83
|
+
# order. Empty when no plugin contributes Tier C entries.
|
|
84
|
+
def collect_templates(plugin_registry)
|
|
85
|
+
return [] if plugin_registry.nil? || plugin_registry.empty?
|
|
86
|
+
|
|
87
|
+
plugin_registry.plugins.flat_map do |plugin|
|
|
88
|
+
# rigor:disable undefined-method
|
|
89
|
+
plugin.manifest.heredoc_templates.map do |template|
|
|
90
|
+
[plugin.manifest.id, template]
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# ADR-16 Tier B (slice 3b). Aggregates `(plugin_id, registry)`
|
|
96
|
+
# pairs across every plugin's `manifest.trait_registries` in
|
|
97
|
+
# registration order. Empty when no plugin contributes Tier B
|
|
98
|
+
# entries.
|
|
99
|
+
def collect_trait_registries(plugin_registry)
|
|
100
|
+
return [] if plugin_registry.nil? || plugin_registry.empty?
|
|
101
|
+
|
|
102
|
+
plugin_registry.plugins.flat_map do |plugin|
|
|
103
|
+
# rigor:disable undefined-method
|
|
104
|
+
plugin.manifest.trait_registries.map do |registry|
|
|
105
|
+
[plugin.manifest.id, registry]
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def parse_paths(paths)
|
|
111
|
+
paths.to_h do |path|
|
|
112
|
+
source = File.read(path)
|
|
113
|
+
[path, Prism.parse(source).value]
|
|
114
|
+
rescue StandardError
|
|
115
|
+
[path, nil]
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# ADR-16 slice 4 — Concern re-targeting index.
|
|
120
|
+
#
|
|
121
|
+
# Walks every top-level / nested `module M` decl looking for
|
|
122
|
+
# the ActiveSupport::Concern shape:
|
|
123
|
+
#
|
|
124
|
+
# module M
|
|
125
|
+
# extend ActiveSupport::Concern
|
|
126
|
+
# included do
|
|
127
|
+
# # deferred DSL calls — fire on the *includer*, not on M
|
|
128
|
+
# devise :database_authenticatable
|
|
129
|
+
# has_one_attached :avatar
|
|
130
|
+
# end
|
|
131
|
+
# end
|
|
132
|
+
#
|
|
133
|
+
# The returned Hash maps `module_name => [deferred_call_node, ...]`.
|
|
134
|
+
# When a class body later contains `include M`, the substrate
|
|
135
|
+
# replays each deferred call against the including class.
|
|
136
|
+
#
|
|
137
|
+
# Slice 4 scope (floor):
|
|
138
|
+
# - constant-path `include M` only (not `include some_var`).
|
|
139
|
+
# - one-hop: nested concerns (M's `included do; include N; end`)
|
|
140
|
+
# are NOT transitively replayed; deferred. Concrete demand
|
|
141
|
+
# is the trigger for adding the second hop.
|
|
142
|
+
# - `class_methods do ... end` blocks are NOT yet handled —
|
|
143
|
+
# singleton-level emission is out of scope per the slice-3
|
|
144
|
+
# floor framing.
|
|
145
|
+
CONCERN_NAME = "ActiveSupport::Concern"
|
|
146
|
+
|
|
147
|
+
def build_concern_index(asts)
|
|
148
|
+
index = {}
|
|
149
|
+
asts.each_value do |ast|
|
|
150
|
+
next if ast.nil?
|
|
151
|
+
|
|
152
|
+
walk_module_decls(ast, []) do |module_name, body|
|
|
153
|
+
next if module_name.nil? || body.nil?
|
|
154
|
+
next unless concern_module_body?(body)
|
|
155
|
+
|
|
156
|
+
deferred_calls = collect_included_do_calls(body)
|
|
157
|
+
index[module_name] = deferred_calls.freeze if deferred_calls.any?
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
index.freeze
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def walk_module_decls(node, scope_stack, &)
|
|
164
|
+
return unless node.respond_to?(:compact_child_nodes)
|
|
165
|
+
|
|
166
|
+
case node
|
|
167
|
+
when Prism::ModuleNode
|
|
168
|
+
name = class_name_from(node, scope_stack)
|
|
169
|
+
yield name, node.body
|
|
170
|
+
new_stack = scope_stack + [node]
|
|
171
|
+
node.body&.compact_child_nodes&.each { |child| walk_module_decls(child, new_stack, &) }
|
|
172
|
+
when Prism::ClassNode
|
|
173
|
+
new_stack = scope_stack + [node]
|
|
174
|
+
node.body&.compact_child_nodes&.each { |child| walk_module_decls(child, new_stack, &) }
|
|
175
|
+
else
|
|
176
|
+
node.compact_child_nodes.each { |child| walk_module_decls(child, scope_stack, &) }
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# Recognises a module body that begins with (or contains at
|
|
181
|
+
# top level) an `extend ActiveSupport::Concern` statement.
|
|
182
|
+
def concern_module_body?(body)
|
|
183
|
+
return false unless body.respond_to?(:body)
|
|
184
|
+
|
|
185
|
+
body.body.any? do |stmt|
|
|
186
|
+
next false unless stmt.is_a?(Prism::CallNode) && stmt.receiver.nil? && stmt.name == :extend
|
|
187
|
+
|
|
188
|
+
args = stmt.arguments&.arguments || []
|
|
189
|
+
args.any? { |arg| const_name_string(arg) == CONCERN_NAME }
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def collect_included_do_calls(body)
|
|
194
|
+
body.body.flat_map do |stmt|
|
|
195
|
+
next [] unless stmt.is_a?(Prism::CallNode) && stmt.receiver.nil? && stmt.name == :included && stmt.block
|
|
196
|
+
|
|
197
|
+
block_body = stmt.block.body
|
|
198
|
+
next [] unless block_body.respond_to?(:body)
|
|
199
|
+
|
|
200
|
+
block_body.body.select { |inner| inner.is_a?(Prism::CallNode) && inner.receiver.nil? }
|
|
201
|
+
end
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
# Slice 4 hook. When the current class body contains
|
|
205
|
+
# `include M` and M is a Concern with deferred DSL calls,
|
|
206
|
+
# replay each deferred call against the including class.
|
|
207
|
+
# Acts as a re-targeting walker — no new manifest entries
|
|
208
|
+
# needed; downstream `collect_entries` /
|
|
209
|
+
# `collect_trait_entries` fire just as if the calls had been
|
|
210
|
+
# written directly in X's body.
|
|
211
|
+
def collect_concern_re_targeted_entries(entries, call_node, class_name, concern_index, # rubocop:disable Metrics/ParameterLists
|
|
212
|
+
templates, registries, hierarchy, environment, path)
|
|
213
|
+
return unless call_node.name == :include && call_node.receiver.nil?
|
|
214
|
+
return if concern_index.empty?
|
|
215
|
+
|
|
216
|
+
args = call_node.arguments&.arguments || []
|
|
217
|
+
args.each do |arg|
|
|
218
|
+
name = const_name_string(arg)
|
|
219
|
+
deferred = name && concern_index[name]
|
|
220
|
+
next unless deferred
|
|
221
|
+
|
|
222
|
+
deferred.each do |inner_call|
|
|
223
|
+
collect_entries(entries, templates, class_name, inner_call, hierarchy, environment, path)
|
|
224
|
+
collect_trait_entries(entries, registries, class_name, inner_call, hierarchy, environment, path)
|
|
225
|
+
end
|
|
226
|
+
end
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
# Builds a lexical inheritance map `class_name => parent_class_name`
|
|
230
|
+
# by walking every top-level / nested `class X < Y` decl
|
|
231
|
+
# across the AST set.
|
|
232
|
+
def build_hierarchy(asts)
|
|
233
|
+
hierarchy = {}
|
|
234
|
+
asts.each_value do |ast|
|
|
235
|
+
next if ast.nil?
|
|
236
|
+
|
|
237
|
+
walk_class_decls(ast, []) do |class_name, parent_name|
|
|
238
|
+
hierarchy[class_name] = parent_name if parent_name && !hierarchy.key?(class_name)
|
|
239
|
+
end
|
|
240
|
+
end
|
|
241
|
+
hierarchy.freeze
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
def walk_class_decls(node, scope_stack, &) # rubocop:disable Metrics/PerceivedComplexity
|
|
245
|
+
return unless node.respond_to?(:compact_child_nodes)
|
|
246
|
+
|
|
247
|
+
if node.is_a?(Prism::ClassNode)
|
|
248
|
+
name = class_name_from(node, scope_stack)
|
|
249
|
+
parent = parent_name_from(node, scope_stack)
|
|
250
|
+
yield name, parent if name
|
|
251
|
+
new_stack = scope_stack + [node]
|
|
252
|
+
node.body&.compact_child_nodes&.each { |child| walk_class_decls(child, new_stack, &) }
|
|
253
|
+
elsif node.is_a?(Prism::ModuleNode)
|
|
254
|
+
new_stack = scope_stack + [node]
|
|
255
|
+
node.body&.compact_child_nodes&.each { |child| walk_class_decls(child, new_stack, &) }
|
|
256
|
+
else
|
|
257
|
+
node.compact_child_nodes.each { |child| walk_class_decls(child, scope_stack, &) }
|
|
258
|
+
end
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
# Yields `(class_name, call_node)` for every Prism::CallNode
|
|
262
|
+
# at class-body top level (singleton-context calls). Nested
|
|
263
|
+
# method bodies, blocks, and conditionals are skipped — the
|
|
264
|
+
# Tier C call shapes the substrate targets all live at the
|
|
265
|
+
# class body's top level.
|
|
266
|
+
def walk_class_bodies(node, scope_stack = [], &) # rubocop:disable Metrics/AbcSize,Metrics/CyclomaticComplexity,Metrics/PerceivedComplexity
|
|
267
|
+
return unless node.respond_to?(:compact_child_nodes)
|
|
268
|
+
|
|
269
|
+
if node.is_a?(Prism::ClassNode)
|
|
270
|
+
name = class_name_from(node, scope_stack)
|
|
271
|
+
new_stack = scope_stack + [node]
|
|
272
|
+
if name && node.body.respond_to?(:body)
|
|
273
|
+
node.body.body.each do |stmt|
|
|
274
|
+
yield name, stmt if stmt.is_a?(Prism::CallNode) && stmt.receiver.nil?
|
|
275
|
+
end
|
|
276
|
+
end
|
|
277
|
+
node.body&.compact_child_nodes&.each { |child| walk_class_bodies(child, new_stack, &) }
|
|
278
|
+
elsif node.is_a?(Prism::ModuleNode)
|
|
279
|
+
new_stack = scope_stack + [node]
|
|
280
|
+
node.body&.compact_child_nodes&.each { |child| walk_class_bodies(child, new_stack, &) }
|
|
281
|
+
else
|
|
282
|
+
node.compact_child_nodes.each { |child| walk_class_bodies(child, scope_stack, &) }
|
|
283
|
+
end
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
def class_name_from(class_node, scope_stack)
|
|
287
|
+
local = const_name_string(class_node.constant_path)
|
|
288
|
+
return nil unless local
|
|
289
|
+
|
|
290
|
+
prefix = scope_stack.filter_map do |ancestor|
|
|
291
|
+
case ancestor
|
|
292
|
+
when Prism::ClassNode, Prism::ModuleNode
|
|
293
|
+
const_name_string(ancestor.constant_path)
|
|
294
|
+
end
|
|
295
|
+
end.join("::")
|
|
296
|
+
prefix.empty? ? local : "#{prefix}::#{local}"
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
def parent_name_from(class_node, _scope_stack)
|
|
300
|
+
return nil if class_node.superclass.nil?
|
|
301
|
+
|
|
302
|
+
const_name_string(class_node.superclass)
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
def const_name_string(node)
|
|
306
|
+
case node
|
|
307
|
+
when Prism::ConstantReadNode then node.name.to_s
|
|
308
|
+
when Prism::ConstantPathNode then constant_path_string(node)
|
|
309
|
+
end
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
def constant_path_string(node)
|
|
313
|
+
parent = node.parent
|
|
314
|
+
name = node.name.to_s
|
|
315
|
+
return name if parent.nil?
|
|
316
|
+
|
|
317
|
+
parent_str = const_name_string(parent)
|
|
318
|
+
parent_str ? "#{parent_str}::#{name}" : name
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
def collect_entries(entries, templates, class_name, call_node, hierarchy, environment, path)
|
|
322
|
+
templates.each do |(plugin_id, template)|
|
|
323
|
+
next unless call_node.name == template.method_name
|
|
324
|
+
next unless class_inherits_from?(class_name, template.receiver_constraint, hierarchy, environment)
|
|
325
|
+
|
|
326
|
+
symbol_arg = literal_symbol_arg(call_node, template.symbol_arg_position)
|
|
327
|
+
next if symbol_arg.nil?
|
|
328
|
+
|
|
329
|
+
emit_entries_for(entries, class_name, symbol_arg, template, plugin_id, path, call_node)
|
|
330
|
+
end
|
|
331
|
+
end
|
|
332
|
+
|
|
333
|
+
# ADR-16 Tier B (slice 3b). For each matching call like
|
|
334
|
+
# `<X>.<method_name>(:trait_a, :trait_b)` where X inherits
|
|
335
|
+
# from the registry's receiver_constraint: collect every
|
|
336
|
+
# registered trait symbol's module (silently skipping
|
|
337
|
+
# unknown traits per design decision (2)) plus the
|
|
338
|
+
# always_included modules, then per-method-explode each
|
|
339
|
+
# module's RBS instance methods into the index.
|
|
340
|
+
#
|
|
341
|
+
# Per slice 3 floor (per user agreement): the synthesised
|
|
342
|
+
# methods adopt `return_type: "untyped"` (Dynamic[T] at
|
|
343
|
+
# dispatch). Precision promotion — looking up the module's
|
|
344
|
+
# actual RBS return type — is reserved for the ceiling slice.
|
|
345
|
+
def collect_trait_entries(entries, registries, class_name, call_node, hierarchy, environment, path)
|
|
346
|
+
registries.each do |(plugin_id, registry)|
|
|
347
|
+
next unless call_node.name == registry.method_name
|
|
348
|
+
next unless class_inherits_from?(class_name, registry.receiver_constraint, hierarchy, environment)
|
|
349
|
+
|
|
350
|
+
modules = resolve_trait_modules(registry, call_node)
|
|
351
|
+
next if modules.empty?
|
|
352
|
+
|
|
353
|
+
emit_trait_module_entries(entries, class_name, modules, registry, plugin_id, path, call_node, environment)
|
|
354
|
+
end
|
|
355
|
+
end
|
|
356
|
+
|
|
357
|
+
# Resolves the set of modules to include from a Tier B
|
|
358
|
+
# call site:
|
|
359
|
+
#
|
|
360
|
+
# - `always_included` modules (unconditional);
|
|
361
|
+
# - one module per literal Symbol argument the call carries
|
|
362
|
+
# (resolved through `registry.modules_by_symbol`; unknown
|
|
363
|
+
# symbols silently skipped per design decision (2)).
|
|
364
|
+
#
|
|
365
|
+
# Returns an Array<String> of module names in
|
|
366
|
+
# `always_included` order followed by argument order.
|
|
367
|
+
def resolve_trait_modules(registry, call_node)
|
|
368
|
+
modules = registry.always_included.dup
|
|
369
|
+
positional_symbols(call_node, registry).each do |symbol|
|
|
370
|
+
module_name = registry.module_for(symbol)
|
|
371
|
+
modules << module_name if module_name
|
|
372
|
+
end
|
|
373
|
+
modules
|
|
374
|
+
end
|
|
375
|
+
|
|
376
|
+
def positional_symbols(call_node, registry)
|
|
377
|
+
args_node = call_node.arguments
|
|
378
|
+
return [] if args_node.nil?
|
|
379
|
+
|
|
380
|
+
if registry.symbol_arg_position == Rigor::Plugin::Macro::TraitRegistry::REST_POSITION
|
|
381
|
+
args_node.arguments.filter_map { |arg| literal_symbol_value(arg) }
|
|
382
|
+
else
|
|
383
|
+
symbol_arg = literal_symbol_arg(call_node, registry.symbol_arg_position)
|
|
384
|
+
symbol_arg ? [symbol_arg] : []
|
|
385
|
+
end
|
|
386
|
+
end
|
|
387
|
+
|
|
388
|
+
def literal_symbol_value(node)
|
|
389
|
+
case node
|
|
390
|
+
when Prism::SymbolNode, Prism::StringNode then node.unescaped.to_sym
|
|
391
|
+
end
|
|
392
|
+
end
|
|
393
|
+
|
|
394
|
+
def emit_trait_module_entries(entries, class_name, modules, registry, plugin_id, path, call_node, environment) # rubocop:disable Metrics/ParameterLists
|
|
395
|
+
modules.each do |module_name|
|
|
396
|
+
method_names = module_instance_method_names(module_name, environment)
|
|
397
|
+
method_names.each do |method_name|
|
|
398
|
+
entries << build_trait_synthetic_method(
|
|
399
|
+
class_name: class_name, method_name: method_name, module_name: module_name,
|
|
400
|
+
registry: registry, plugin_id: plugin_id, path: path, call_node: call_node
|
|
401
|
+
)
|
|
402
|
+
end
|
|
403
|
+
end
|
|
404
|
+
end
|
|
405
|
+
|
|
406
|
+
# Returns the Symbol method-name list defined on `module_name`'s
|
|
407
|
+
# RBS instance definition. Empty Array when the module is not
|
|
408
|
+
# in the RBS env (silent skip — the synthetic emit produces
|
|
409
|
+
# nothing rather than fabricating method names).
|
|
410
|
+
def module_instance_method_names(module_name, environment)
|
|
411
|
+
return [] if environment.nil?
|
|
412
|
+
|
|
413
|
+
loader = environment.rbs_loader
|
|
414
|
+
return [] if loader.nil?
|
|
415
|
+
|
|
416
|
+
definition = loader.instance_definition(module_name)
|
|
417
|
+
return [] if definition.nil?
|
|
418
|
+
|
|
419
|
+
definition.methods.keys
|
|
420
|
+
rescue StandardError
|
|
421
|
+
[]
|
|
422
|
+
end
|
|
423
|
+
|
|
424
|
+
def build_trait_synthetic_method(class_name:, method_name:, module_name:, registry:, plugin_id:, path:,
|
|
425
|
+
call_node:)
|
|
426
|
+
SyntheticMethod.new(
|
|
427
|
+
class_name: class_name,
|
|
428
|
+
method_name: method_name,
|
|
429
|
+
return_type: "untyped",
|
|
430
|
+
kind: SyntheticMethod::INSTANCE,
|
|
431
|
+
provenance: {
|
|
432
|
+
plugin_id: plugin_id,
|
|
433
|
+
origin_module: module_name,
|
|
434
|
+
trait_method: registry.method_name.to_s,
|
|
435
|
+
template_constraint: registry.receiver_constraint,
|
|
436
|
+
source_path: path,
|
|
437
|
+
source_line: call_node.location.start_line
|
|
438
|
+
}
|
|
439
|
+
)
|
|
440
|
+
end
|
|
441
|
+
|
|
442
|
+
def emit_entries_for(entries, class_name, symbol_arg, template, plugin_id, path, call_node)
|
|
443
|
+
template.emit.each do |row|
|
|
444
|
+
entries << build_synthetic_method(
|
|
445
|
+
class_name: class_name, name_arg: symbol_arg, row: row,
|
|
446
|
+
template: template, plugin_id: plugin_id, path: path, call_node: call_node,
|
|
447
|
+
kind: SyntheticMethod::INSTANCE
|
|
448
|
+
)
|
|
449
|
+
end
|
|
450
|
+
template.class_level_emit.each do |row|
|
|
451
|
+
entries << build_synthetic_method(
|
|
452
|
+
class_name: class_name, name_arg: symbol_arg, row: row,
|
|
453
|
+
template: template, plugin_id: plugin_id, path: path, call_node: call_node,
|
|
454
|
+
kind: SyntheticMethod::SINGLETON
|
|
455
|
+
)
|
|
456
|
+
end
|
|
457
|
+
end
|
|
458
|
+
|
|
459
|
+
def build_synthetic_method(class_name:, name_arg:, row:, template:, plugin_id:, path:, call_node:, kind:) # rubocop:disable Metrics/ParameterLists
|
|
460
|
+
SyntheticMethod.new(
|
|
461
|
+
class_name: class_name,
|
|
462
|
+
method_name: interpolate(row.name, name_arg).to_sym,
|
|
463
|
+
return_type: row.returns,
|
|
464
|
+
kind: kind,
|
|
465
|
+
provenance: {
|
|
466
|
+
plugin_id: plugin_id,
|
|
467
|
+
template_method: template.method_name.to_s,
|
|
468
|
+
template_constraint: template.receiver_constraint,
|
|
469
|
+
source_path: path,
|
|
470
|
+
source_line: call_node.location.start_line
|
|
471
|
+
}
|
|
472
|
+
)
|
|
473
|
+
end
|
|
474
|
+
|
|
475
|
+
def interpolate(template_name, name_arg)
|
|
476
|
+
template_name.gsub(Rigor::Plugin::Macro::HeredocTemplate::NAME_PLACEHOLDER, name_arg.to_s)
|
|
477
|
+
end
|
|
478
|
+
|
|
479
|
+
def class_inherits_from?(class_name, constraint, hierarchy, environment)
|
|
480
|
+
return true if class_name == constraint
|
|
481
|
+
|
|
482
|
+
# Walk the project-side lexical chain.
|
|
483
|
+
current = class_name
|
|
484
|
+
visited = Set.new
|
|
485
|
+
while (parent = hierarchy[current]) && !visited.include?(parent)
|
|
486
|
+
return true if parent == constraint
|
|
487
|
+
|
|
488
|
+
visited << parent
|
|
489
|
+
current = parent
|
|
490
|
+
end
|
|
491
|
+
|
|
492
|
+
# Fall back to the env's RBS-aware ordering for the case
|
|
493
|
+
# where the chain terminates at an RBS-known class
|
|
494
|
+
# (ActiveRecord::Base, Dry::Struct, Sinatra::Base, …).
|
|
495
|
+
return false if environment.nil?
|
|
496
|
+
|
|
497
|
+
candidates = [class_name] + visited.to_a + [current]
|
|
498
|
+
candidates.uniq.any? { |name| rbs_subtype?(name, constraint, environment) }
|
|
499
|
+
end
|
|
500
|
+
|
|
501
|
+
def rbs_subtype?(class_name, constraint, environment)
|
|
502
|
+
ordering = environment.class_ordering(class_name, constraint)
|
|
503
|
+
%i[equal subclass].include?(ordering)
|
|
504
|
+
rescue StandardError
|
|
505
|
+
false
|
|
506
|
+
end
|
|
507
|
+
|
|
508
|
+
def literal_symbol_arg(call_node, index)
|
|
509
|
+
args_node = call_node.arguments
|
|
510
|
+
return nil if args_node.nil?
|
|
511
|
+
|
|
512
|
+
arg = args_node.arguments[index]
|
|
513
|
+
return nil unless arg
|
|
514
|
+
|
|
515
|
+
case arg
|
|
516
|
+
when Prism::SymbolNode, Prism::StringNode then arg.unescaped.to_sym
|
|
517
|
+
end
|
|
518
|
+
end
|
|
519
|
+
end
|
|
520
|
+
end
|
|
521
|
+
end
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rigor
|
|
4
|
+
module Plugin
|
|
5
|
+
# Frozen, `Ractor.shareable?` description of how to materialise
|
|
6
|
+
# a single plugin instance inside a worker.
|
|
7
|
+
# [ADR-15](../../../docs/adr/15-ractor-concurrency.md) Phase 3
|
|
8
|
+
# introduces the carrier so the eventual worker pool can pass
|
|
9
|
+
# `Array<Blueprint>` across a Ractor boundary verbatim; each
|
|
10
|
+
# worker calls {#materialize} once at startup, then owns its
|
|
11
|
+
# plugin instances (and their mutable per-run accumulators)
|
|
12
|
+
# for the lifetime of the worker.
|
|
13
|
+
#
|
|
14
|
+
# Holds the constant path (`String`) of the plugin class — NOT
|
|
15
|
+
# the class object itself. Plugin gems are required from the
|
|
16
|
+
# main Ractor BEFORE any worker spawns, so every Ractor
|
|
17
|
+
# resolves the same constant via `Object.const_get`.
|
|
18
|
+
#
|
|
19
|
+
# The `config` Hash is deep-copied + made shareable at
|
|
20
|
+
# construction so the Blueprint stays decoupled from whatever
|
|
21
|
+
# Hash the project configuration emitted. The original config
|
|
22
|
+
# Hash held by the loader is therefore unaffected by Blueprint
|
|
23
|
+
# construction.
|
|
24
|
+
class Blueprint
|
|
25
|
+
attr_reader :klass_name, :config
|
|
26
|
+
|
|
27
|
+
def initialize(klass_name:, config: {})
|
|
28
|
+
@klass_name = normalise_klass_name(klass_name)
|
|
29
|
+
@config = Ractor.make_shareable(Marshal.load(Marshal.dump(config)))
|
|
30
|
+
freeze
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Resolves the plugin class via `Object.const_get`, builds a
|
|
34
|
+
# fresh instance bound to the supplied services container,
|
|
35
|
+
# and calls `#init(services)`. Mirrors
|
|
36
|
+
# {Rigor::Plugin::Loader#instantiate} bit-for-bit so the
|
|
37
|
+
# blueprint-driven path stays consistent with the
|
|
38
|
+
# configuration-driven load path.
|
|
39
|
+
def materialize(services:)
|
|
40
|
+
klass = Object.const_get(@klass_name)
|
|
41
|
+
plugin = klass.new(services: services, config: @config)
|
|
42
|
+
plugin.init(services)
|
|
43
|
+
plugin
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
def normalise_klass_name(name)
|
|
49
|
+
case name
|
|
50
|
+
when String
|
|
51
|
+
name.dup.freeze
|
|
52
|
+
when Module
|
|
53
|
+
name.name.dup.freeze
|
|
54
|
+
else
|
|
55
|
+
raise ArgumentError, "Blueprint klass_name must be a String or Module, got #{name.class}"
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
data/lib/rigor/plugin/loader.rb
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "blueprint"
|
|
3
4
|
require_relative "registry"
|
|
4
5
|
require_relative "load_error"
|
|
5
6
|
|
|
@@ -72,7 +73,8 @@ module Rigor
|
|
|
72
73
|
plugins, sort_errors = topo_sort_plugins(plugins)
|
|
73
74
|
load_errors.concat(sort_errors)
|
|
74
75
|
|
|
75
|
-
|
|
76
|
+
blueprints = plugins.map { |plugin| Blueprint.new(klass_name: plugin.class.name, config: plugin.config) }
|
|
77
|
+
Registry.new(plugins: plugins, blueprints: blueprints, load_errors: load_errors)
|
|
76
78
|
end
|
|
77
79
|
|
|
78
80
|
private
|