rigortype 0.1.17 → 0.1.18
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 +4 -2
- data/lib/rigor/analysis/check_rules/always_truthy_condition_collector.rb +18 -1
- data/lib/rigor/analysis/check_rules/rule_walk.rb +67 -0
- data/lib/rigor/analysis/check_rules/unreachable_clause_collector.rb +18 -1
- data/lib/rigor/analysis/check_rules.rb +34 -6
- data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +580 -0
- data/lib/rigor/analysis/runner/pool_coordinator.rb +569 -0
- data/lib/rigor/analysis/runner/project_pre_passes.rb +318 -0
- data/lib/rigor/analysis/runner/run_snapshots.rb +46 -0
- data/lib/rigor/analysis/runner.rb +160 -1190
- data/lib/rigor/analysis/worker_session.rb +47 -8
- data/lib/rigor/cache/incremental_snapshot.rb +10 -4
- data/lib/rigor/cache/rbs_cache_producer.rb +5 -1
- data/lib/rigor/cache/store.rb +46 -13
- data/lib/rigor/cli/check_command.rb +705 -0
- data/lib/rigor/cli/ci_detector.rb +94 -0
- data/lib/rigor/cli/diagnostic_formats.rb +345 -0
- data/lib/rigor/cli/prism_colorizer.rb +10 -3
- data/lib/rigor/cli/trace_command.rb +143 -0
- data/lib/rigor/cli/trace_renderer.rb +310 -0
- data/lib/rigor/cli.rb +15 -614
- data/lib/rigor/configuration.rb +9 -6
- data/lib/rigor/environment/rbs_loader.rb +53 -68
- data/lib/rigor/environment.rb +1 -1
- data/lib/rigor/inference/acceptance.rb +10 -0
- data/lib/rigor/inference/expression_typer.rb +28 -62
- data/lib/rigor/inference/flow_tracer.rb +180 -0
- data/lib/rigor/inference/macro_block_self_type.rb +10 -11
- data/lib/rigor/inference/method_dispatcher/overload_selector.rb +33 -1
- data/lib/rigor/inference/method_dispatcher.rb +115 -54
- data/lib/rigor/inference/narrowing.rb +60 -0
- data/lib/rigor/inference/scope_indexer.rb +75 -15
- data/lib/rigor/inference/statement_evaluator.rb +35 -52
- data/lib/rigor/plugin/additional_initializer.rb +61 -38
- data/lib/rigor/plugin/base.rb +282 -41
- data/lib/rigor/plugin/node_rule_walk.rb +147 -0
- data/lib/rigor/plugin/registry.rb +263 -35
- data/lib/rigor/plugin.rb +1 -0
- data/lib/rigor/rbs_extended/conformance_checker.rb +86 -1
- data/lib/rigor/scope/discovery_index.rb +58 -0
- data/lib/rigor/scope.rb +67 -198
- data/lib/rigor/sig_gen/observation_collector.rb +6 -6
- data/lib/rigor/source/literals.rb +14 -0
- data/lib/rigor/type/combinator.rb +5 -0
- data/lib/rigor/version.rb +1 -1
- data/lib/rigor.rb +0 -1
- data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/analyzer.rb +1 -2
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +2 -4
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +70 -32
- data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/analyzer.rb +3 -3
- data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage.rb +15 -21
- data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext.rb +1 -1
- data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_discoverer.rb +1 -2
- data/plugins/rigor-graphql/lib/rigor/plugin/graphql/type_scanner.rb +2 -2
- data/plugins/rigor-rspec/lib/rigor/plugin/rspec/let_scope_index.rb +12 -2
- data/plugins/rigor-rspec/lib/rigor/plugin/rspec/matcher_analyzer.rb +1 -1
- data/plugins/rigor-rspec/lib/rigor/plugin/rspec.rb +35 -18
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/absurd_recognizer.rb +8 -29
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/catalog.rb +17 -1
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/sigil_detector.rb +2 -2
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +83 -36
- data/sig/rigor/environment.rbs +0 -2
- data/sig/rigor/inference.rbs +5 -0
- data/sig/rigor/plugin/base.rbs +1 -2
- data/sig/rigor/scope.rbs +41 -29
- data/sig/rigor/source.rbs +1 -0
- data/skills/rigor-ci-setup/SKILL.md +319 -0
- metadata +15 -2
- data/lib/rigor/cache/rbs_instance_definitions.rb +0 -66
|
@@ -3,32 +3,40 @@
|
|
|
3
3
|
module Rigor
|
|
4
4
|
module Plugin
|
|
5
5
|
# ADR-38 declaration: "on `receiver_constraint` (and its
|
|
6
|
-
# subclasses), every method named in `methods`
|
|
7
|
-
#
|
|
8
|
-
# read-before-write nil
|
|
6
|
+
# subclasses), every method named in `methods` (def-form) or
|
|
7
|
+
# `block_methods` (block-form) also establishes instance-variable
|
|
8
|
+
# state — treat it like `initialize` for the read-before-write nil
|
|
9
|
+
# soundness gate."
|
|
10
|
+
#
|
|
11
|
+
# **Def-form** (`methods:`) — applies when the ivar write lives in a
|
|
12
|
+
# named `def` body. Example: Minitest `def setup; @conn = …; end`.
|
|
13
|
+
#
|
|
14
|
+
# **Block-form** (`block_methods:`) — applies when the ivar write
|
|
15
|
+
# lives in a block passed to a method call. Example: RSpec
|
|
16
|
+
# `before { @user = create(:user) }` / `let(:x) { @y = … }`.
|
|
17
|
+
# `ScopeIndexer` descends the block body of any `CallNode` whose
|
|
18
|
+
# method name is in `block_methods`, collecting ivar writes exactly
|
|
19
|
+
# as it would for a def-form initializer.
|
|
20
|
+
#
|
|
21
|
+
# At least one of `methods:` or `block_methods:` must be non-empty.
|
|
9
22
|
#
|
|
10
23
|
# Authored on a plugin manifest:
|
|
11
24
|
#
|
|
12
|
-
#
|
|
13
|
-
#
|
|
14
|
-
#
|
|
15
|
-
#
|
|
16
|
-
#
|
|
17
|
-
#
|
|
18
|
-
#
|
|
19
|
-
#
|
|
20
|
-
#
|
|
25
|
+
# # def-form (Minitest):
|
|
26
|
+
# AdditionalInitializer.new(
|
|
27
|
+
# receiver_constraint: "Minitest::Test",
|
|
28
|
+
# methods: [:setup]
|
|
29
|
+
# )
|
|
30
|
+
#
|
|
31
|
+
# # block-form (RSpec):
|
|
32
|
+
# AdditionalInitializer.new(
|
|
33
|
+
# receiver_constraint: "RSpec::ExampleGroup",
|
|
34
|
+
# block_methods: [:before, :let, :subject]
|
|
21
35
|
# )
|
|
22
36
|
#
|
|
23
37
|
# The Ruby analogue of PHPStan's `AdditionalConstructorsExtension`.
|
|
24
38
|
# `Rigor::Inference::ScopeIndexer` consults the aggregated set at
|
|
25
|
-
# its
|
|
26
|
-
# `methods` on a class that equals or inherits from
|
|
27
|
-
# `receiver_constraint` (matched via `Environment#class_ordering`,
|
|
28
|
-
# the same mechanism ADR-16 Tier A uses), the method's ivar writes
|
|
29
|
-
# are folded into the class's `init_writes` set, so a sibling
|
|
30
|
-
# method reading those ivars no longer gets a `Constant[nil]`
|
|
31
|
-
# widening.
|
|
39
|
+
# its read-before-write gate.
|
|
32
40
|
#
|
|
33
41
|
# The contribution can only ever *suppress* a nil widening — it
|
|
34
42
|
# never makes the analyzer stricter — so a missed or over-broad
|
|
@@ -39,38 +47,47 @@ module Rigor
|
|
|
39
47
|
#
|
|
40
48
|
# - `receiver_constraint` — fully-qualified class name (String).
|
|
41
49
|
# The entry applies to that class and its subclasses.
|
|
42
|
-
# - `methods` — Array of Symbol method names
|
|
43
|
-
#
|
|
50
|
+
# - `methods` — Array of Symbol `def`-form method names (may be
|
|
51
|
+
# empty when only block_methods is used).
|
|
52
|
+
# - `block_methods` — Array of Symbol call-with-block method names
|
|
53
|
+
# (may be empty when only methods is used).
|
|
44
54
|
#
|
|
45
55
|
# ## Ractor-shareability
|
|
46
56
|
#
|
|
47
|
-
#
|
|
48
|
-
# `Ractor.shareable?` returns true after `#initialize
|
|
49
|
-
# value object survives `Plugin::Registry.materialize` into a
|
|
50
|
-
# worker Ractor.
|
|
57
|
+
# All fields are frozen at construction (ADR-15 Phase 1);
|
|
58
|
+
# `Ractor.shareable?` returns true after `#initialize`.
|
|
51
59
|
class AdditionalInitializer
|
|
52
|
-
attr_reader :receiver_constraint, :methods
|
|
60
|
+
attr_reader :receiver_constraint, :methods, :block_methods
|
|
53
61
|
|
|
54
|
-
def initialize(receiver_constraint:, methods:)
|
|
62
|
+
def initialize(receiver_constraint:, methods: [], block_methods: [])
|
|
55
63
|
validate_receiver_constraint!(receiver_constraint)
|
|
56
|
-
|
|
64
|
+
validate_method_list!(methods, :methods)
|
|
65
|
+
validate_method_list!(block_methods, :block_methods)
|
|
66
|
+
validate_at_least_one!(methods, block_methods)
|
|
57
67
|
|
|
58
68
|
@receiver_constraint = receiver_constraint.dup.freeze
|
|
59
69
|
@methods = methods.map(&:to_sym).freeze
|
|
70
|
+
@block_methods = block_methods.map(&:to_sym).freeze
|
|
60
71
|
freeze
|
|
61
72
|
end
|
|
62
73
|
|
|
63
|
-
# True when `method_name` (a Symbol) is declared
|
|
64
|
-
# by this entry.
|
|
65
|
-
# responsibility (it needs the environment's class graph).
|
|
74
|
+
# True when `method_name` (a Symbol) is declared a def-form
|
|
75
|
+
# initializer by this entry.
|
|
66
76
|
def covers_method?(method_name)
|
|
67
77
|
methods.include?(method_name)
|
|
68
78
|
end
|
|
69
79
|
|
|
80
|
+
# True when `method_name` (a Symbol) is declared a block-form
|
|
81
|
+
# initializer by this entry.
|
|
82
|
+
def covers_block_method?(method_name)
|
|
83
|
+
block_methods.include?(method_name)
|
|
84
|
+
end
|
|
85
|
+
|
|
70
86
|
def to_h
|
|
71
87
|
{
|
|
72
88
|
"receiver_constraint" => receiver_constraint,
|
|
73
|
-
"methods" => methods.map(&:to_s)
|
|
89
|
+
"methods" => methods.map(&:to_s),
|
|
90
|
+
"block_methods" => block_methods.map(&:to_s)
|
|
74
91
|
}
|
|
75
92
|
end
|
|
76
93
|
|
|
@@ -93,16 +110,22 @@ module Rigor
|
|
|
93
110
|
"got #{value.inspect}"
|
|
94
111
|
end
|
|
95
112
|
|
|
96
|
-
def
|
|
97
|
-
if value.is_a?(Array) &&
|
|
98
|
-
|
|
99
|
-
return
|
|
100
|
-
end
|
|
113
|
+
def validate_method_list!(value, field)
|
|
114
|
+
return if value.is_a?(Array) &&
|
|
115
|
+
value.all? { |m| m.is_a?(Symbol) || (m.is_a?(String) && !m.empty?) }
|
|
101
116
|
|
|
102
117
|
raise ArgumentError,
|
|
103
|
-
"Plugin::AdditionalInitializer
|
|
118
|
+
"Plugin::AdditionalInitializer##{field} must be an Array of " \
|
|
104
119
|
"Symbol/non-empty String, got #{value.inspect}"
|
|
105
120
|
end
|
|
121
|
+
|
|
122
|
+
def validate_at_least_one!(methods, block_methods)
|
|
123
|
+
return unless methods.empty? && block_methods.empty?
|
|
124
|
+
|
|
125
|
+
raise ArgumentError,
|
|
126
|
+
"Plugin::AdditionalInitializer requires at least one of methods: or block_methods: " \
|
|
127
|
+
"to be non-empty"
|
|
128
|
+
end
|
|
106
129
|
end
|
|
107
130
|
end
|
|
108
131
|
end
|
data/lib/rigor/plugin/base.rb
CHANGED
|
@@ -190,36 +190,140 @@ module Rigor
|
|
|
190
190
|
defined?(@node_file_context_block) ? @node_file_context_block : nil
|
|
191
191
|
end
|
|
192
192
|
|
|
193
|
-
# ADR-37 slice 2 — declares a per-call-site
|
|
194
|
-
# contribution,
|
|
195
|
-
# `return_type` slot of
|
|
193
|
+
# ADR-37 slice 2 / ADR-52 WD2 — declares a per-call-site
|
|
194
|
+
# return-type contribution, gated by receiver class, method name,
|
|
195
|
+
# or both. The narrow successor to the `return_type` slot of the
|
|
196
|
+
# deleted `flow_contribution_for` hook (ADR-52 WD3):
|
|
196
197
|
#
|
|
198
|
+
# # receiver-gated only:
|
|
197
199
|
# dynamic_return receivers: ["ActiveRecord::Base"] do |call_node, scope|
|
|
198
200
|
# # self = plugin instance; return a Rigor::Type or nil
|
|
199
201
|
# end
|
|
200
202
|
#
|
|
203
|
+
# # receiver + method gated (preferred for focused rules):
|
|
204
|
+
# dynamic_return receivers: ["Result"], methods: [:unwrap, :unwrap!] do |call_node, scope|
|
|
205
|
+
# # fires only for Result#unwrap / Result#unwrap!
|
|
206
|
+
# end
|
|
207
|
+
#
|
|
208
|
+
# # method-gated only (ADR-52 WD2 — receiver-independent rules,
|
|
209
|
+
# # e.g. a unit-dimension DSL whose receiver carrier is a
|
|
210
|
+
# # refinement, not a nominal class):
|
|
211
|
+
# dynamic_return methods: [:kilometers, :per_hour, :in_meters] do |call_node, scope|
|
|
212
|
+
# # fires for any receiver when the method name matches;
|
|
213
|
+
# # the block reads the receiver's shape itself
|
|
214
|
+
# end
|
|
215
|
+
#
|
|
201
216
|
# `receivers:` is a non-empty Array of class names; the engine
|
|
202
217
|
# calls the block only when the call's receiver type's class
|
|
203
218
|
# equals or inherits from one of them (via
|
|
204
|
-
# `Environment#class_ordering`).
|
|
205
|
-
#
|
|
206
|
-
#
|
|
207
|
-
#
|
|
208
|
-
#
|
|
209
|
-
#
|
|
210
|
-
|
|
219
|
+
# `Environment#class_ordering`). It MAY be omitted — then the rule
|
|
220
|
+
# is receiver-independent and fires on `methods:` alone.
|
|
221
|
+
#
|
|
222
|
+
# `methods:` is an Array of Symbol method names. When provided, the
|
|
223
|
+
# block is skipped unless `call_node.name` is in the list —
|
|
224
|
+
# declarative and cheaper than an in-block guard (the engine
|
|
225
|
+
# compiles it into the registry's contribution table, ADR-52 WD1).
|
|
226
|
+
# It is REQUIRED when `receivers:` is omitted: a rule gated on
|
|
227
|
+
# neither would fire on every dispatch, which is exactly the
|
|
228
|
+
# ungated cost the `flow_contribution_for` escape valve carries —
|
|
229
|
+
# `dynamic_return` declines to reintroduce it.
|
|
230
|
+
#
|
|
231
|
+
# Method-name and type-shape refinement can still be done inside
|
|
232
|
+
# the block. The block runs through `instance_exec`, so `config`
|
|
233
|
+
# / `services` are in scope.
|
|
234
|
+
# ADR-52 slice 3 — `receivers:` may also be a **callable**
|
|
235
|
+
# (a `-> { ... }` resolved once per run, lazily, the first time
|
|
236
|
+
# the rule is consulted — always after `#prepare`) for a receiver
|
|
237
|
+
# set the plugin only knows at run time:
|
|
238
|
+
#
|
|
239
|
+
# dynamic_return receivers: -> { attachment_index.model_names } do |call_node, scope|
|
|
240
|
+
# # fires when the receiver class is one a `prepare`-time scan
|
|
241
|
+
# # found; the block does the precise per-call lookup
|
|
242
|
+
# end
|
|
243
|
+
#
|
|
244
|
+
# The callable runs through `instance_exec`, so it reads the
|
|
245
|
+
# plugin's own `#prepare`-built indexes. It MUST be idempotent and
|
|
246
|
+
# post-`#prepare`-safe — reference a lazily-built / memoised index
|
|
247
|
+
# (as activestorage's `attachment_index` and activerecord's
|
|
248
|
+
# `model_index` are), never a value captured at class-definition
|
|
249
|
+
# time. The resolved set is a safe over-approximation of the
|
|
250
|
+
# block's own filter (it admits subclasses too), so the block
|
|
251
|
+
# stays the precise gate and diagnostics are unchanged.
|
|
252
|
+
#
|
|
253
|
+
# ADR-52 slice 4 — `methods:` may ALSO be a callable, for a
|
|
254
|
+
# method-name set the plugin only knows at run time (a Sorbet
|
|
255
|
+
# catalog's keys, a config-derived DSL method name):
|
|
256
|
+
#
|
|
257
|
+
# dynamic_return methods: -> { catalog.method_names } do |call_node, scope|
|
|
258
|
+
# ...
|
|
259
|
+
# end
|
|
260
|
+
#
|
|
261
|
+
# Same contract as a callable `receivers:` — `instance_exec`'d,
|
|
262
|
+
# resolved lazily after `#prepare`, memoised, idempotent. A
|
|
263
|
+
# callable method set cannot be compiled into the registry's
|
|
264
|
+
# name gate (it is unknown at registry-build time), so the
|
|
265
|
+
# plugin is consulted on every dispatch and the name filter runs
|
|
266
|
+
# in this instance path instead — the block still only fires for
|
|
267
|
+
# a listed name, so diagnostics are unchanged.
|
|
268
|
+
# ADR-52 slice 5a — `file_methods:` is the per-file
|
|
269
|
+
# specialisation of the run-time `methods:` callable, for a name
|
|
270
|
+
# set that varies per analysed file (rigor-rspec's `let` names —
|
|
271
|
+
# the names depend on each file's `describe`/`let` structure, so
|
|
272
|
+
# one run-wide set cannot exist). The callable receives the file
|
|
273
|
+
# path, runs through `instance_exec`, and is memoised per
|
|
274
|
+
# `(rule, path)`:
|
|
275
|
+
#
|
|
276
|
+
# dynamic_return file_methods: ->(path) { let_names_for(path) } do |call_node, scope|
|
|
277
|
+
# ...
|
|
278
|
+
# end
|
|
279
|
+
#
|
|
280
|
+
# Same idempotence contract as the other callables, plus: it MUST
|
|
281
|
+
# tolerate any path the engine analyses (return `[]` / nil for a
|
|
282
|
+
# file it has no names for — never raise). Like a callable
|
|
283
|
+
# `methods:`, it cannot compile into the registry name gate, so
|
|
284
|
+
# the plugin is consulted on every dispatch and filtered here.
|
|
285
|
+
# `file_methods:` replaces `methods:` (declaring both is
|
|
286
|
+
# rejected — they are the same gate at two scopes); it MAY
|
|
287
|
+
# combine with `receivers:`.
|
|
288
|
+
def dynamic_return(receivers: nil, methods: nil, file_methods: nil, &block)
|
|
211
289
|
raise ArgumentError, "Plugin::Base.dynamic_return requires a block body" if block.nil?
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
290
|
+
|
|
291
|
+
validate_dynamic_return_gate!(receivers, methods, file_methods)
|
|
292
|
+
validate_dynamic_return_receivers!(receivers) unless receivers.nil?
|
|
293
|
+
validate_dynamic_return_methods!(methods)
|
|
294
|
+
validate_dynamic_return_file_methods!(file_methods, methods)
|
|
217
295
|
|
|
218
296
|
@dynamic_returns ||= []
|
|
219
|
-
@dynamic_returns << {
|
|
297
|
+
@dynamic_returns << {
|
|
298
|
+
receivers: normalize_dynamic_return_receivers(receivers),
|
|
299
|
+
methods: normalize_dynamic_return_methods(methods),
|
|
300
|
+
file_methods: file_methods,
|
|
301
|
+
block: block
|
|
302
|
+
}.freeze
|
|
220
303
|
nil
|
|
221
304
|
end
|
|
222
305
|
|
|
306
|
+
# A class-name Array is frozen element-wise; a run-time callable
|
|
307
|
+
# (ADR-52 slice 3) is stored verbatim and resolved per instance.
|
|
308
|
+
def normalize_dynamic_return_receivers(receivers)
|
|
309
|
+
return nil if receivers.nil?
|
|
310
|
+
return receivers if receivers.respond_to?(:call)
|
|
311
|
+
|
|
312
|
+
receivers.map { |r| r.dup.freeze }.freeze
|
|
313
|
+
end
|
|
314
|
+
private :normalize_dynamic_return_receivers
|
|
315
|
+
|
|
316
|
+
# A method-name Array is symbol-normalised + frozen; a run-time
|
|
317
|
+
# callable (ADR-52 slice 4) is stored verbatim and resolved per
|
|
318
|
+
# instance.
|
|
319
|
+
def normalize_dynamic_return_methods(methods)
|
|
320
|
+
return nil if methods.nil?
|
|
321
|
+
return methods if methods.respond_to?(:call)
|
|
322
|
+
|
|
323
|
+
methods.map(&:to_sym).freeze
|
|
324
|
+
end
|
|
325
|
+
private :normalize_dynamic_return_methods
|
|
326
|
+
|
|
223
327
|
# Frozen snapshot of the declared dynamic-return rules. Memoised:
|
|
224
328
|
# `@dynamic_returns` is built once at class-definition time (via
|
|
225
329
|
# `dynamic_return`) and never mutated during analysis, and every
|
|
@@ -237,9 +341,67 @@ module Rigor
|
|
|
237
341
|
end
|
|
238
342
|
# rubocop:enable Naming/MemoizedInstanceVariableName
|
|
239
343
|
|
|
344
|
+
# ADR-52 WD2 — a rule must gate on something. `receivers:` alone,
|
|
345
|
+
# `methods:` alone, or both are valid; neither is not (it would
|
|
346
|
+
# fire on every dispatch).
|
|
347
|
+
def validate_dynamic_return_gate!(receivers, methods, file_methods)
|
|
348
|
+
return unless receivers.nil? && file_methods.nil?
|
|
349
|
+
return if (methods.is_a?(Array) && !methods.empty?) || methods.respond_to?(:call)
|
|
350
|
+
|
|
351
|
+
raise ArgumentError,
|
|
352
|
+
"Plugin::Base.dynamic_return requires receivers:, methods:, or file_methods: — a rule " \
|
|
353
|
+
"gated on none would fire on every dispatch (that is what flow_contribution_for is for)"
|
|
354
|
+
end
|
|
355
|
+
|
|
356
|
+
# ADR-52 slice 5a — `file_methods:` must be a callable, and is
|
|
357
|
+
# mutually exclusive with `methods:` (one name gate, two scopes —
|
|
358
|
+
# declaring both is a contradiction, not a composition).
|
|
359
|
+
def validate_dynamic_return_file_methods!(file_methods, methods)
|
|
360
|
+
return if file_methods.nil?
|
|
361
|
+
|
|
362
|
+
unless file_methods.respond_to?(:call)
|
|
363
|
+
raise ArgumentError,
|
|
364
|
+
"Plugin::Base.dynamic_return file_methods: must be a callable receiving the file path, " \
|
|
365
|
+
"got #{file_methods.inspect}"
|
|
366
|
+
end
|
|
367
|
+
return if methods.nil?
|
|
368
|
+
|
|
369
|
+
raise ArgumentError,
|
|
370
|
+
"Plugin::Base.dynamic_return file_methods: replaces methods: — declare one name gate, " \
|
|
371
|
+
"not both"
|
|
372
|
+
end
|
|
373
|
+
|
|
374
|
+
def validate_dynamic_return_receivers!(receivers)
|
|
375
|
+
# ADR-52 slice 3 — a run-time callable is resolved per instance
|
|
376
|
+
# after `#prepare`; its shape is checked at resolution time.
|
|
377
|
+
return if receivers.respond_to?(:call)
|
|
378
|
+
return if receivers.is_a?(Array) && !receivers.empty? && receivers.all? { |r| r.is_a?(String) && !r.empty? }
|
|
379
|
+
|
|
380
|
+
raise ArgumentError,
|
|
381
|
+
"Plugin::Base.dynamic_return receivers: must be a non-empty Array of class-name Strings " \
|
|
382
|
+
"or a callable, got #{receivers.inspect}"
|
|
383
|
+
end
|
|
384
|
+
|
|
385
|
+
def validate_dynamic_return_methods!(methods)
|
|
386
|
+
return if methods.nil?
|
|
387
|
+
# ADR-52 slice 4 — a run-time callable resolves to the name set
|
|
388
|
+
# per instance after `#prepare`; its shape is checked then.
|
|
389
|
+
return if methods.respond_to?(:call)
|
|
390
|
+
return if methods.is_a?(Array) && !methods.empty? &&
|
|
391
|
+
methods.all? { |m| m.is_a?(Symbol) || (m.is_a?(String) && !m.empty?) }
|
|
392
|
+
|
|
393
|
+
raise ArgumentError,
|
|
394
|
+
"Plugin::Base.dynamic_return methods: must be a non-empty Array of Symbol/String, a callable, " \
|
|
395
|
+
"or nil, got #{methods.inspect}"
|
|
396
|
+
end
|
|
397
|
+
|
|
398
|
+
private :validate_dynamic_return_gate!, :validate_dynamic_return_receivers!,
|
|
399
|
+
:validate_dynamic_return_methods!, :validate_dynamic_return_file_methods!
|
|
400
|
+
|
|
240
401
|
# ADR-37 slice 2 — declares a predicate/assertion narrowing
|
|
241
402
|
# contribution, method-gated. The narrow successor to the
|
|
242
|
-
# `post_return_facts` slot of `flow_contribution_for
|
|
403
|
+
# `post_return_facts` slot of the deleted `flow_contribution_for`
|
|
404
|
+
# hook (ADR-52 WD3):
|
|
243
405
|
#
|
|
244
406
|
# type_specifier methods: [:assert_kind_of] do |call_node, scope|
|
|
245
407
|
# # return an Array of post-return facts, or nil
|
|
@@ -277,6 +439,12 @@ module Rigor
|
|
|
277
439
|
def initialize(services:, config: {})
|
|
278
440
|
@services = services
|
|
279
441
|
@config = merge_config_defaults(config).freeze
|
|
442
|
+
# ADR-52 slice 3 — per-rule cache of resolved run-time
|
|
443
|
+
# `dynamic_return receivers:` callables. Created here (before any
|
|
444
|
+
# subclass `initialize` freezes the instance) so the lazy
|
|
445
|
+
# memo-on-first-dispatch is a Hash-content mutation, sound even on
|
|
446
|
+
# a self-freezing plugin.
|
|
447
|
+
@dynamic_return_runtime_cache = {}
|
|
280
448
|
end
|
|
281
449
|
|
|
282
450
|
# Override in subclasses to wire any state the plugin needs
|
|
@@ -287,22 +455,13 @@ module Rigor
|
|
|
287
455
|
nil
|
|
288
456
|
end
|
|
289
457
|
|
|
290
|
-
# ADR-
|
|
291
|
-
#
|
|
292
|
-
#
|
|
293
|
-
#
|
|
294
|
-
#
|
|
295
|
-
#
|
|
296
|
-
#
|
|
297
|
-
# `return_type` slot pins the call site's result type.
|
|
298
|
-
#
|
|
299
|
-
# Default returns nil — plugins that don't refine return
|
|
300
|
-
# types skip the override. Failures are isolated: a hook
|
|
301
|
-
# that raises gets its contribution dropped silently for
|
|
302
|
-
# this call so the rest of the dispatch chain continues.
|
|
303
|
-
def flow_contribution_for(call_node:, scope:) # rubocop:disable Lint/UnusedMethodArgument
|
|
304
|
-
nil
|
|
305
|
-
end
|
|
458
|
+
# NOTE: (ADR-52 WD3): the legacy ungated per-call hook
|
|
459
|
+
# `flow_contribution_for` was DELETED here pre-1.0 after its five
|
|
460
|
+
# production users migrated. Per-call return types are declared via
|
|
461
|
+
# the gated {.dynamic_return} DSL (static / run-time / per-file
|
|
462
|
+
# name sets, static / run-time receiver sets); post-return
|
|
463
|
+
# narrowing facts via {.type_specifier}. See the CHANGELOG
|
|
464
|
+
# migration note for the idiom-by-idiom mapping.
|
|
306
465
|
|
|
307
466
|
# ADR-9 slice 3 — per-run preparation hook. The runner
|
|
308
467
|
# invokes `#prepare(services)` on every loaded plugin once
|
|
@@ -379,10 +538,14 @@ module Rigor
|
|
|
379
538
|
|
|
380
539
|
diagnostics = []
|
|
381
540
|
Source::NodeWalker.each_with_ancestors(root) do |node, ancestors|
|
|
541
|
+
# One frozen NodeContext per node, shared across the rules
|
|
542
|
+
# that match it (ADR-52 WD1) — built lazily so non-matching
|
|
543
|
+
# nodes (the vast majority) allocate nothing.
|
|
544
|
+
context = nil
|
|
382
545
|
rules.each do |rule|
|
|
383
546
|
next unless node.is_a?(rule[:node_type])
|
|
384
547
|
|
|
385
|
-
context
|
|
548
|
+
context ||= NodeContext.new(ancestors)
|
|
386
549
|
diagnostics.concat(Array(instance_exec(node, scope, path, file_context, context, &rule[:block])))
|
|
387
550
|
end
|
|
388
551
|
end
|
|
@@ -391,20 +554,22 @@ module Rigor
|
|
|
391
554
|
|
|
392
555
|
# ADR-37 slice 2 — the return type contributed by this plugin's
|
|
393
556
|
# {.dynamic_return} rules for a call, or nil. The engine calls this
|
|
394
|
-
# from `MethodDispatcher
|
|
395
|
-
# `flow_contribution_for`; a rule fires only when `receiver_type`'s
|
|
557
|
+
# from `MethodDispatcher`; a rule fires only when `receiver_type`'s
|
|
396
558
|
# class equals or inherits from one of its declared `receivers:`.
|
|
397
559
|
# First non-nil wins (declaration order). Failures isolate to nil.
|
|
398
560
|
def dynamic_return_type(call_node:, scope:, receiver_type:)
|
|
399
561
|
rules = self.class.dynamic_returns
|
|
400
562
|
return nil if rules.empty? || receiver_type.nil?
|
|
401
563
|
|
|
564
|
+
# `class_name` is nil for a receiver carrier with no nominal
|
|
565
|
+
# class (a refinement dimension, an inferred shape) — fine for a
|
|
566
|
+
# receiver-less (methods-only) rule (ADR-52 WD2), which gates on
|
|
567
|
+
# the method name alone and reads the receiver shape inside its
|
|
568
|
+
# own block.
|
|
402
569
|
class_name = dynamic_return_receiver_class_name(receiver_type)
|
|
403
|
-
return nil if class_name.nil?
|
|
404
|
-
|
|
405
570
|
environment = scope&.environment
|
|
406
571
|
rules.each do |rule|
|
|
407
|
-
next unless rule
|
|
572
|
+
next unless dynamic_return_rule_applies?(rule, call_node, class_name, environment, scope)
|
|
408
573
|
|
|
409
574
|
result = instance_exec(call_node, scope, &rule[:block])
|
|
410
575
|
return result if result
|
|
@@ -416,8 +581,8 @@ module Rigor
|
|
|
416
581
|
|
|
417
582
|
# ADR-37 slice 2 — the post-return narrowing facts contributed by
|
|
418
583
|
# this plugin's {.type_specifier} rules for a call. The engine
|
|
419
|
-
# calls this from `StatementEvaluator
|
|
420
|
-
# `
|
|
584
|
+
# calls this from `StatementEvaluator`; a rule fires only when
|
|
585
|
+
# `call_node.name`
|
|
421
586
|
# is one of its declared `methods:`. Failures isolate to [].
|
|
422
587
|
def type_specifier_facts(call_node:, scope:)
|
|
423
588
|
rules = self.class.type_specifiers
|
|
@@ -659,6 +824,82 @@ module Rigor
|
|
|
659
824
|
end
|
|
660
825
|
end
|
|
661
826
|
|
|
827
|
+
# The gate for one `dynamic_return` rule. Method-name gate first —
|
|
828
|
+
# a Symbol-array probe vs the receiver ancestry resolution below
|
|
829
|
+
# (ADR-52 WD1); both are pure predicates, so order only affects
|
|
830
|
+
# cost. A receiver-less rule (ADR-52 WD2) skips the ancestry check
|
|
831
|
+
# entirely and fires on the method name alone.
|
|
832
|
+
def dynamic_return_rule_applies?(rule, call_node, class_name, environment, scope)
|
|
833
|
+
return false if rule[:methods] && !resolved_dynamic_return_methods(rule).include?(call_node.name)
|
|
834
|
+
|
|
835
|
+
if rule[:file_methods]
|
|
836
|
+
# The path is read here, not in `dynamic_return_type`, so a
|
|
837
|
+
# spec-double scope without `source_path` only affects
|
|
838
|
+
# `file_methods:` rules (other gate forms never touch it).
|
|
839
|
+
path = scope.respond_to?(:source_path) ? scope.source_path : nil
|
|
840
|
+
return false unless resolved_dynamic_return_file_methods(rule, path).include?(call_node.name)
|
|
841
|
+
end
|
|
842
|
+
|
|
843
|
+
receivers = resolved_dynamic_return_receivers(rule)
|
|
844
|
+
return true if receivers.nil?
|
|
845
|
+
return false if class_name.nil?
|
|
846
|
+
|
|
847
|
+
receivers.any? { |c| class_matches_receiver?(class_name, c, environment) }
|
|
848
|
+
end
|
|
849
|
+
|
|
850
|
+
# ADR-52 slice 4 — the rule's method-name set. A static Array is
|
|
851
|
+
# returned as-is (`#include?` over Symbols); a run-time callable is
|
|
852
|
+
# `instance_exec`'d against this plugin and memoised as a Symbol Set,
|
|
853
|
+
# same lazy/idempotent contract as a callable `receivers:`. The
|
|
854
|
+
# cache key is namespaced so a rule that makes both `methods:` and
|
|
855
|
+
# `receivers:` callable keeps two distinct memo slots.
|
|
856
|
+
def resolved_dynamic_return_methods(rule)
|
|
857
|
+
methods = rule[:methods]
|
|
858
|
+
return methods unless methods.respond_to?(:call)
|
|
859
|
+
|
|
860
|
+
(@dynamic_return_runtime_cache ||= {})[[:methods, rule]] ||=
|
|
861
|
+
Array(instance_exec(&methods)).to_set(&:to_sym).freeze
|
|
862
|
+
end
|
|
863
|
+
|
|
864
|
+
# ADR-52 slice 5a — the rule's per-file method-name set. The
|
|
865
|
+
# `file_methods:` callable is `instance_exec`'d with the file path
|
|
866
|
+
# and memoised per `(rule, path)` — one resolution per analysed
|
|
867
|
+
# file, the per-file analogue of the run-wide `methods:` memo. A
|
|
868
|
+
# nil path (synthetic call sites with no file context) resolves to
|
|
869
|
+
# the empty set: the gate has nothing to key on, so the rule
|
|
870
|
+
# declines — fail-closed, consistent with the gate's purpose. A
|
|
871
|
+
# raising callable degrades to "declines this dispatch" via
|
|
872
|
+
# `dynamic_return_type`'s surrounding rescue.
|
|
873
|
+
EMPTY_NAME_SET = Set.new.freeze
|
|
874
|
+
private_constant :EMPTY_NAME_SET
|
|
875
|
+
|
|
876
|
+
def resolved_dynamic_return_file_methods(rule, path)
|
|
877
|
+
return EMPTY_NAME_SET if path.nil?
|
|
878
|
+
|
|
879
|
+
(@dynamic_return_runtime_cache ||= {})[[:file_methods, rule, path]] ||=
|
|
880
|
+
Array(instance_exec(path, &rule[:file_methods])).to_set(&:to_sym).freeze
|
|
881
|
+
end
|
|
882
|
+
|
|
883
|
+
# ADR-52 slice 3 — the rule's receiver class-name Array. A static
|
|
884
|
+
# Array is returned as-is; a run-time callable is `instance_exec`'d
|
|
885
|
+
# against this plugin (so it reads the `#prepare`-built indexes) and
|
|
886
|
+
# memoised per rule for the run. Resolution is lazy — first reached
|
|
887
|
+
# during file analysis, always after `#prepare` — and the callable
|
|
888
|
+
# is required to be idempotent, so the memoised set is stable. A
|
|
889
|
+
# callable that raises degrades to "no receivers match" (the rule
|
|
890
|
+
# declines), never a crash, consistent with the surrounding rescue.
|
|
891
|
+
def resolved_dynamic_return_receivers(rule)
|
|
892
|
+
receivers = rule[:receivers]
|
|
893
|
+
return receivers unless receivers.respond_to?(:call)
|
|
894
|
+
|
|
895
|
+
# `||= {}` keeps the path correct even when a caller bypassed
|
|
896
|
+
# `initialize` (`allocate` in unit specs that inject a fake
|
|
897
|
+
# index); a self-freezing plugin already has the Hash from
|
|
898
|
+
# `initialize`, so the `||=` is a no-op there (never a FrozenError).
|
|
899
|
+
(@dynamic_return_runtime_cache ||= {})[rule] ||=
|
|
900
|
+
Array(instance_exec(&receivers)).map { |c| c.to_s.dup.freeze }.freeze
|
|
901
|
+
end
|
|
902
|
+
|
|
662
903
|
# True when `class_name` equals or inherits from `constraint`,
|
|
663
904
|
# matched through `Environment#class_ordering` (the mechanism
|
|
664
905
|
# `MacroBlockSelfType` / `additional_initializers` use). Degrades to
|