rigortype 0.1.16 → 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/self_closedness_scanner.rb +100 -0
- data/lib/rigor/analysis/check_rules/unreachable_clause_collector.rb +226 -0
- data/lib/rigor/analysis/check_rules.rb +180 -73
- data/lib/rigor/analysis/dependency_recorder.rb +122 -0
- data/lib/rigor/analysis/diagnostic.rb +18 -0
- data/lib/rigor/analysis/incremental.rb +162 -0
- data/lib/rigor/analysis/incremental_session.rb +337 -0
- data/lib/rigor/analysis/rule_catalog.rb +48 -0
- 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 +477 -1110
- data/lib/rigor/analysis/self_call_resolution_recorder.rb +121 -0
- data/lib/rigor/analysis/worker_session.rb +47 -8
- data/lib/rigor/builtins/static_return_refinements.rb +7 -1
- data/lib/rigor/cache/descriptor.rb +50 -49
- data/lib/rigor/cache/incremental_snapshot.rb +153 -0
- data/lib/rigor/cache/rbs_cache_producer.rb +34 -0
- data/lib/rigor/cache/rbs_class_ancestor_table.rb +2 -8
- data/lib/rigor/cache/rbs_class_type_param_names.rb +2 -8
- data/lib/rigor/cache/rbs_constant_table.rb +2 -8
- data/lib/rigor/cache/rbs_environment.rb +2 -8
- data/lib/rigor/cache/rbs_known_class_names.rb +2 -8
- data/lib/rigor/cache/store.rb +145 -14
- data/lib/rigor/cli/annotate_command.rb +2 -7
- data/lib/rigor/cli/baseline_command.rb +2 -7
- data/lib/rigor/cli/check_command.rb +705 -0
- data/lib/rigor/cli/ci_detector.rb +94 -0
- data/lib/rigor/cli/command.rb +47 -0
- data/lib/rigor/cli/coverage_command.rb +3 -23
- data/lib/rigor/cli/coverage_renderer.rb +3 -8
- data/lib/rigor/cli/diagnostic_formats.rb +345 -0
- data/lib/rigor/cli/diff_command.rb +3 -7
- data/lib/rigor/cli/explain_command.rb +2 -7
- data/lib/rigor/cli/lsp_command.rb +3 -7
- data/lib/rigor/cli/mcp_command.rb +3 -7
- data/lib/rigor/cli/options.rb +57 -0
- data/lib/rigor/cli/plugin_command.rb +3 -7
- data/lib/rigor/cli/plugins_command.rb +2 -7
- data/lib/rigor/cli/prism_colorizer.rb +10 -3
- data/lib/rigor/cli/renderable.rb +26 -0
- data/lib/rigor/cli/sig_gen_command.rb +2 -7
- data/lib/rigor/cli/skill_command.rb +3 -7
- data/lib/rigor/cli/trace_command.rb +143 -0
- data/lib/rigor/cli/trace_renderer.rb +310 -0
- data/lib/rigor/cli/triage_command.rb +2 -7
- data/lib/rigor/cli/type_of_command.rb +5 -38
- data/lib/rigor/cli/type_of_renderer.rb +4 -9
- data/lib/rigor/cli/type_scan_command.rb +3 -23
- data/lib/rigor/cli/type_scan_renderer.rb +4 -9
- data/lib/rigor/cli.rb +15 -532
- data/lib/rigor/configuration/dependencies.rb +18 -1
- data/lib/rigor/configuration/severity_profile.rb +22 -3
- data/lib/rigor/configuration.rb +16 -3
- data/lib/rigor/environment/rbs_loader.rb +129 -71
- data/lib/rigor/environment.rb +1 -1
- data/lib/rigor/inference/acceptance.rb +10 -0
- data/lib/rigor/inference/block_parameter_binder.rb +1 -2
- data/lib/rigor/inference/builtins/array_catalog.rb +2 -5
- data/lib/rigor/inference/builtins/comparable_catalog.rb +2 -5
- data/lib/rigor/inference/builtins/complex_catalog.rb +2 -5
- data/lib/rigor/inference/builtins/date_catalog.rb +2 -5
- data/lib/rigor/inference/builtins/encoding_catalog.rb +2 -5
- data/lib/rigor/inference/builtins/enumerable_catalog.rb +2 -5
- data/lib/rigor/inference/builtins/exception_catalog.rb +2 -5
- data/lib/rigor/inference/builtins/hash_catalog.rb +2 -5
- data/lib/rigor/inference/builtins/method_catalog.rb +15 -0
- data/lib/rigor/inference/builtins/numeric_catalog.rb +21 -93
- data/lib/rigor/inference/builtins/pathname_catalog.rb +2 -5
- data/lib/rigor/inference/builtins/proc_catalog.rb +2 -5
- data/lib/rigor/inference/builtins/random_catalog.rb +2 -5
- data/lib/rigor/inference/builtins/range_catalog.rb +2 -5
- data/lib/rigor/inference/builtins/rational_catalog.rb +2 -5
- data/lib/rigor/inference/builtins/re_catalog.rb +2 -5
- data/lib/rigor/inference/builtins/set_catalog.rb +2 -5
- data/lib/rigor/inference/builtins/string_catalog.rb +2 -5
- data/lib/rigor/inference/builtins/struct_catalog.rb +2 -5
- data/lib/rigor/inference/builtins/time_catalog.rb +2 -5
- data/lib/rigor/inference/expression_typer.rb +149 -63
- 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/block_folding.rb +5 -1
- data/lib/rigor/inference/method_dispatcher/call_context.rb +65 -0
- data/lib/rigor/inference/method_dispatcher/cgi_folding.rb +11 -10
- data/lib/rigor/inference/method_dispatcher/constant_folding.rb +12 -6
- data/lib/rigor/inference/method_dispatcher/data_folding.rb +246 -0
- data/lib/rigor/inference/method_dispatcher/file_folding.rb +6 -2
- data/lib/rigor/inference/method_dispatcher/iterator_dispatch.rb +6 -2
- data/lib/rigor/inference/method_dispatcher/kernel_dispatch.rb +4 -1
- data/lib/rigor/inference/method_dispatcher/literal_string_folding.rb +4 -1
- data/lib/rigor/inference/method_dispatcher/math_folding.rb +6 -6
- data/lib/rigor/inference/method_dispatcher/method_folding.rb +12 -7
- data/lib/rigor/inference/method_dispatcher/overload_selector.rb +33 -1
- data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +23 -13
- data/lib/rigor/inference/method_dispatcher/regexp_folding.rb +9 -9
- data/lib/rigor/inference/method_dispatcher/set_folding.rb +6 -6
- data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +120 -9
- data/lib/rigor/inference/method_dispatcher/shellwords_folding.rb +12 -12
- data/lib/rigor/inference/method_dispatcher/singleton_folding.rb +49 -0
- data/lib/rigor/inference/method_dispatcher/time_folding.rb +6 -6
- data/lib/rigor/inference/method_dispatcher/uri_folding.rb +9 -9
- data/lib/rigor/inference/method_dispatcher.rb +185 -84
- data/lib/rigor/inference/narrowing.rb +262 -5
- data/lib/rigor/inference/scope_indexer.rb +208 -21
- data/lib/rigor/inference/statement_evaluator.rb +110 -48
- data/lib/rigor/language_server/buffer_resolution.rb +33 -0
- data/lib/rigor/language_server/completion_provider.rb +4 -4
- data/lib/rigor/language_server/document_symbol_provider.rb +4 -4
- data/lib/rigor/language_server/folding_range_provider.rb +4 -4
- data/lib/rigor/language_server/hover_provider.rb +4 -4
- data/lib/rigor/language_server/selection_range_provider.rb +4 -4
- data/lib/rigor/language_server/signature_help_provider.rb +4 -4
- data/lib/rigor/plugin/additional_initializer.rb +61 -38
- data/lib/rigor/plugin/base.rb +302 -45
- data/lib/rigor/plugin/node_rule_walk.rb +147 -0
- data/lib/rigor/plugin/registry.rb +281 -15
- data/lib/rigor/plugin.rb +1 -0
- data/lib/rigor/rbs_extended/conformance_checker.rb +293 -0
- data/lib/rigor/rbs_extended.rb +39 -0
- data/lib/rigor/scope/discovery_index.rb +58 -0
- data/lib/rigor/scope.rb +150 -167
- data/lib/rigor/sig_gen/observation_collector.rb +6 -6
- data/lib/rigor/source/literals.rb +14 -0
- data/lib/rigor/type/acceptance_router.rb +19 -0
- data/lib/rigor/type/accepts_result.rb +3 -10
- data/lib/rigor/type/app.rb +3 -7
- data/lib/rigor/type/bot.rb +2 -3
- data/lib/rigor/type/bound_method.rb +5 -12
- data/lib/rigor/type/combinator.rb +22 -0
- data/lib/rigor/type/constant.rb +2 -3
- data/lib/rigor/type/data_class.rb +80 -0
- data/lib/rigor/type/data_instance.rb +100 -0
- data/lib/rigor/type/difference.rb +5 -10
- data/lib/rigor/type/dynamic.rb +5 -10
- data/lib/rigor/type/hash_shape.rb +5 -15
- data/lib/rigor/type/integer_range.rb +5 -10
- data/lib/rigor/type/intersection.rb +5 -10
- data/lib/rigor/type/nominal.rb +5 -10
- data/lib/rigor/type/refined.rb +5 -10
- data/lib/rigor/type/singleton.rb +5 -10
- data/lib/rigor/type/top.rb +2 -3
- data/lib/rigor/type/tuple.rb +5 -10
- data/lib/rigor/type/union.rb +5 -10
- data/lib/rigor/type.rb +2 -0
- data/lib/rigor/value_semantics.rb +77 -0
- data/lib/rigor/version.rb +1 -1
- data/lib/rigor.rb +1 -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-rails-routes/lib/rigor/plugin/rails_routes.rb +12 -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/cache.rbs +19 -0
- data/sig/rigor/environment.rbs +0 -2
- data/sig/rigor/inference.rbs +27 -0
- data/sig/rigor/plugin/base.rbs +1 -2
- data/sig/rigor/rbs_extended.rbs +2 -0
- data/sig/rigor/scope.rbs +42 -25
- data/sig/rigor/source.rbs +1 -0
- data/sig/rigor/type.rbs +58 -1
- data/sig/rigor.rbs +6 -1
- data/skills/rigor-ci-setup/SKILL.md +319 -0
- metadata +36 -2
- data/lib/rigor/cache/rbs_instance_definitions.rb +0 -79
data/lib/rigor/plugin/base.rb
CHANGED
|
@@ -190,44 +190,218 @@ 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
|
|
|
223
|
-
#
|
|
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
|
+
|
|
327
|
+
# Frozen snapshot of the declared dynamic-return rules. Memoised:
|
|
328
|
+
# `@dynamic_returns` is built once at class-definition time (via
|
|
329
|
+
# `dynamic_return`) and never mutated during analysis, and every
|
|
330
|
+
# element is already frozen, so a fresh `dup.freeze` per call was
|
|
331
|
+
# pure waste — the engine calls this for every plugin on every
|
|
332
|
+
# dispatch (`collect_plugin_contributions`), making it a top
|
|
333
|
+
# allocation site on plugin-heavy projects. The cached frozen
|
|
334
|
+
# array is immutable, so sharing one instance across callers is
|
|
335
|
+
# safe.
|
|
336
|
+
# rubocop:disable Naming/MemoizedInstanceVariableName -- the
|
|
337
|
+
# natural name `@dynamic_returns` is the canonical (mutable-at-
|
|
338
|
+
# definition) store this snapshots; the memo must be distinct.
|
|
224
339
|
def dynamic_returns
|
|
225
|
-
(@dynamic_returns || []).dup.freeze
|
|
340
|
+
@dynamic_returns_snapshot ||= (@dynamic_returns || []).dup.freeze
|
|
226
341
|
end
|
|
342
|
+
# rubocop:enable Naming/MemoizedInstanceVariableName
|
|
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!
|
|
227
400
|
|
|
228
401
|
# ADR-37 slice 2 — declares a predicate/assertion narrowing
|
|
229
402
|
# contribution, method-gated. The narrow successor to the
|
|
230
|
-
# `post_return_facts` slot of `flow_contribution_for
|
|
403
|
+
# `post_return_facts` slot of the deleted `flow_contribution_for`
|
|
404
|
+
# hook (ADR-52 WD3):
|
|
231
405
|
#
|
|
232
406
|
# type_specifier methods: [:assert_kind_of] do |call_node, scope|
|
|
233
407
|
# # return an Array of post-return facts, or nil
|
|
@@ -250,10 +424,14 @@ module Rigor
|
|
|
250
424
|
nil
|
|
251
425
|
end
|
|
252
426
|
|
|
253
|
-
# Frozen snapshot of the declared type-specifier rules.
|
|
427
|
+
# Frozen snapshot of the declared type-specifier rules. Memoised
|
|
428
|
+
# for the same reason as {dynamic_returns} — consulted per plugin
|
|
429
|
+
# per dispatch, over an array fixed at class-definition time.
|
|
430
|
+
# rubocop:disable Naming/MemoizedInstanceVariableName -- see dynamic_returns
|
|
254
431
|
def type_specifiers
|
|
255
|
-
(@type_specifiers || []).dup.freeze
|
|
432
|
+
@type_specifiers_snapshot ||= (@type_specifiers || []).dup.freeze
|
|
256
433
|
end
|
|
434
|
+
# rubocop:enable Naming/MemoizedInstanceVariableName
|
|
257
435
|
end
|
|
258
436
|
|
|
259
437
|
attr_reader :services, :config
|
|
@@ -261,6 +439,12 @@ module Rigor
|
|
|
261
439
|
def initialize(services:, config: {})
|
|
262
440
|
@services = services
|
|
263
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 = {}
|
|
264
448
|
end
|
|
265
449
|
|
|
266
450
|
# Override in subclasses to wire any state the plugin needs
|
|
@@ -271,22 +455,13 @@ module Rigor
|
|
|
271
455
|
nil
|
|
272
456
|
end
|
|
273
457
|
|
|
274
|
-
# ADR-
|
|
275
|
-
#
|
|
276
|
-
#
|
|
277
|
-
#
|
|
278
|
-
#
|
|
279
|
-
#
|
|
280
|
-
#
|
|
281
|
-
# `return_type` slot pins the call site's result type.
|
|
282
|
-
#
|
|
283
|
-
# Default returns nil — plugins that don't refine return
|
|
284
|
-
# types skip the override. Failures are isolated: a hook
|
|
285
|
-
# that raises gets its contribution dropped silently for
|
|
286
|
-
# this call so the rest of the dispatch chain continues.
|
|
287
|
-
def flow_contribution_for(call_node:, scope:) # rubocop:disable Lint/UnusedMethodArgument
|
|
288
|
-
nil
|
|
289
|
-
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.
|
|
290
465
|
|
|
291
466
|
# ADR-9 slice 3 — per-run preparation hook. The runner
|
|
292
467
|
# invokes `#prepare(services)` on every loaded plugin once
|
|
@@ -363,10 +538,14 @@ module Rigor
|
|
|
363
538
|
|
|
364
539
|
diagnostics = []
|
|
365
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
|
|
366
545
|
rules.each do |rule|
|
|
367
546
|
next unless node.is_a?(rule[:node_type])
|
|
368
547
|
|
|
369
|
-
context
|
|
548
|
+
context ||= NodeContext.new(ancestors)
|
|
370
549
|
diagnostics.concat(Array(instance_exec(node, scope, path, file_context, context, &rule[:block])))
|
|
371
550
|
end
|
|
372
551
|
end
|
|
@@ -375,20 +554,22 @@ module Rigor
|
|
|
375
554
|
|
|
376
555
|
# ADR-37 slice 2 — the return type contributed by this plugin's
|
|
377
556
|
# {.dynamic_return} rules for a call, or nil. The engine calls this
|
|
378
|
-
# from `MethodDispatcher
|
|
379
|
-
# `flow_contribution_for`; a rule fires only when `receiver_type`'s
|
|
557
|
+
# from `MethodDispatcher`; a rule fires only when `receiver_type`'s
|
|
380
558
|
# class equals or inherits from one of its declared `receivers:`.
|
|
381
559
|
# First non-nil wins (declaration order). Failures isolate to nil.
|
|
382
560
|
def dynamic_return_type(call_node:, scope:, receiver_type:)
|
|
383
561
|
rules = self.class.dynamic_returns
|
|
384
562
|
return nil if rules.empty? || receiver_type.nil?
|
|
385
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.
|
|
386
569
|
class_name = dynamic_return_receiver_class_name(receiver_type)
|
|
387
|
-
return nil if class_name.nil?
|
|
388
|
-
|
|
389
570
|
environment = scope&.environment
|
|
390
571
|
rules.each do |rule|
|
|
391
|
-
next unless rule
|
|
572
|
+
next unless dynamic_return_rule_applies?(rule, call_node, class_name, environment, scope)
|
|
392
573
|
|
|
393
574
|
result = instance_exec(call_node, scope, &rule[:block])
|
|
394
575
|
return result if result
|
|
@@ -400,8 +581,8 @@ module Rigor
|
|
|
400
581
|
|
|
401
582
|
# ADR-37 slice 2 — the post-return narrowing facts contributed by
|
|
402
583
|
# this plugin's {.type_specifier} rules for a call. The engine
|
|
403
|
-
# calls this from `StatementEvaluator
|
|
404
|
-
# `
|
|
584
|
+
# calls this from `StatementEvaluator`; a rule fires only when
|
|
585
|
+
# `call_node.name`
|
|
405
586
|
# is one of its declared `methods:`. Failures isolate to [].
|
|
406
587
|
def type_specifier_facts(call_node:, scope:)
|
|
407
588
|
rules = self.class.type_specifiers
|
|
@@ -643,6 +824,82 @@ module Rigor
|
|
|
643
824
|
end
|
|
644
825
|
end
|
|
645
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
|
+
|
|
646
903
|
# True when `class_name` equals or inherits from `constraint`,
|
|
647
904
|
# matched through `Environment#class_ordering` (the mechanism
|
|
648
905
|
# `MacroBlockSelfType` / `additional_initializers` use). Degrades to
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "node_context"
|
|
4
|
+
require_relative "../source/node_walker"
|
|
5
|
+
|
|
6
|
+
module Rigor
|
|
7
|
+
module Plugin
|
|
8
|
+
# ADR-52 WD4 — one engine-owned AST walk per file for node rules.
|
|
9
|
+
#
|
|
10
|
+
# Before this, every plugin that declared a {Base.node_rule} walked
|
|
11
|
+
# the file's AST itself (`Base#node_rule_diagnostics` →
|
|
12
|
+
# `Source::NodeWalker.each_with_ancestors`), so a project with N
|
|
13
|
+
# node-rule plugins paid N walks per file. This folds them into a
|
|
14
|
+
# single walk that dispatches each visited node to every matching
|
|
15
|
+
# `(plugin, rule)` pair.
|
|
16
|
+
#
|
|
17
|
+
# Behaviour is preserved exactly so the diagnostics stay
|
|
18
|
+
# byte-identical (the WD6 gate):
|
|
19
|
+
#
|
|
20
|
+
# * Each plugin's `node_file_context` block runs once per file,
|
|
21
|
+
# before any of its rules fire, `instance_exec`'d on that plugin —
|
|
22
|
+
# same as the per-plugin walk.
|
|
23
|
+
# * One frozen {NodeContext} is built per node, lazily, only when at
|
|
24
|
+
# least one rule matches it. Because it wraps only the ancestors it
|
|
25
|
+
# is safe to share across plugins for the same node.
|
|
26
|
+
# * Each rule block is `instance_exec`'d on its own plugin instance
|
|
27
|
+
# with the same five arguments `(node, scope, path, file_context,
|
|
28
|
+
# context)`.
|
|
29
|
+
# * A plugin whose context block or any rule block raises has its
|
|
30
|
+
# whole node-rule contribution isolated — the walk records the
|
|
31
|
+
# error against that plugin and continues, matching the runner's
|
|
32
|
+
# per-plugin rescue around the old `#node_rule_diagnostics` call.
|
|
33
|
+
# * Diagnostics are bucketed per plugin and returned in the
|
|
34
|
+
# registry order the runner already iterates, so emission order is
|
|
35
|
+
# unchanged (plugin-major, not node-major) — order preservation is
|
|
36
|
+
# what keeps the gate byte-identical in this slice.
|
|
37
|
+
#
|
|
38
|
+
# The result is an ordered Array of {Result}, one per node-rule
|
|
39
|
+
# plugin (registry order). `Result#error` is non-nil iff that
|
|
40
|
+
# plugin's context or a rule block raised, in which case
|
|
41
|
+
# `#diagnostics` is empty; the runner turns the error into the same
|
|
42
|
+
# per-plugin `runtime-error` envelope it produced before.
|
|
43
|
+
class NodeRuleWalk
|
|
44
|
+
# One plugin's node-rule outcome for a single file. `error` is the
|
|
45
|
+
# exception raised by the plugin's context block or a rule block
|
|
46
|
+
# (nil on success); when set, `diagnostics` is empty.
|
|
47
|
+
Result = Struct.new(:plugin, :diagnostics, :error)
|
|
48
|
+
|
|
49
|
+
# Plugins that declare at least one `node_rule`, paired with their
|
|
50
|
+
# frozen rule list, in registry order. Built once per run and
|
|
51
|
+
# reused for every file.
|
|
52
|
+
def initialize(plugins)
|
|
53
|
+
@entries = plugins.filter_map do |plugin|
|
|
54
|
+
rules = plugin.class.node_rules
|
|
55
|
+
rules.empty? ? nil : [plugin, rules]
|
|
56
|
+
end.freeze
|
|
57
|
+
freeze
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def empty?
|
|
61
|
+
@entries.empty?
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Walk `root` once, dispatching every node to each matching
|
|
65
|
+
# `(plugin, rule)`. Returns an Array of {Result} in plugin
|
|
66
|
+
# (registry) order. `root` nil yields one empty Result per plugin.
|
|
67
|
+
def diagnostics_for_file(path:, scope:, root:)
|
|
68
|
+
return @entries.map { |plugin, _| Result.new(plugin, [], nil) } if root.nil?
|
|
69
|
+
|
|
70
|
+
states = @entries.map { |plugin, rules| State.new(plugin, rules, scope, root) }
|
|
71
|
+
walk(path, scope, root, states)
|
|
72
|
+
states.map(&:result)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
private
|
|
76
|
+
|
|
77
|
+
def walk(path, scope, root, states)
|
|
78
|
+
Source::NodeWalker.each_with_ancestors(root) do |node, ancestors|
|
|
79
|
+
context = nil
|
|
80
|
+
states.each do |state|
|
|
81
|
+
next if state.failed?
|
|
82
|
+
|
|
83
|
+
matched = state.rules_for(node)
|
|
84
|
+
next if matched.empty?
|
|
85
|
+
|
|
86
|
+
# One frozen NodeContext per node, built lazily and shared
|
|
87
|
+
# across every plugin that matches this node.
|
|
88
|
+
context ||= NodeContext.new(ancestors)
|
|
89
|
+
state.run_rules(matched, node, scope, path, context)
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Mutable per-(plugin, file) walk state. Kept private to the walk —
|
|
95
|
+
# holds the diagnostics bucket, the file context, the per-concrete-
|
|
96
|
+
# class match memo, and the isolation flag.
|
|
97
|
+
class State
|
|
98
|
+
def initialize(plugin, rules, scope, root)
|
|
99
|
+
@plugin = plugin
|
|
100
|
+
@rules = rules
|
|
101
|
+
@diagnostics = []
|
|
102
|
+
@match_cache = {}.compare_by_identity
|
|
103
|
+
@error = nil
|
|
104
|
+
build_file_context(scope, root)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def failed?
|
|
108
|
+
!@error.nil?
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Rules whose `node_type` this concrete node satisfies, memoised
|
|
112
|
+
# by the node's class so the `is_a?` scan runs once per class.
|
|
113
|
+
# Preserves `is_a?` semantics when a rule's `node_type` is a
|
|
114
|
+
# superclass of the concrete node.
|
|
115
|
+
def rules_for(node)
|
|
116
|
+
@match_cache[node.class] ||=
|
|
117
|
+
@rules.select { |rule| node.is_a?(rule[:node_type]) }
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def run_rules(matched, node, scope, path, context)
|
|
121
|
+
matched.each do |rule|
|
|
122
|
+
result = @plugin.instance_exec(node, scope, path, @file_context, context, &rule[:block])
|
|
123
|
+
@diagnostics.concat(Array(result))
|
|
124
|
+
end
|
|
125
|
+
rescue StandardError => e
|
|
126
|
+
@error = e
|
|
127
|
+
@diagnostics = []
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def result
|
|
131
|
+
NodeRuleWalk::Result.new(@plugin, @diagnostics, @error)
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
private
|
|
135
|
+
|
|
136
|
+
def build_file_context(scope, root)
|
|
137
|
+
block = @plugin.class.node_file_context_block
|
|
138
|
+
@file_context = block ? @plugin.instance_exec(root, scope, &block) : nil
|
|
139
|
+
rescue StandardError => e
|
|
140
|
+
@error = e
|
|
141
|
+
@file_context = nil
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
private_constant :State
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
end
|