rigortype 0.1.17 → 0.1.19
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 +159 -222
- data/lib/rigor/analysis/check_rules/always_truthy_condition_collector.rb +24 -1
- data/lib/rigor/analysis/check_rules/dead_assignment_collector.rb +25 -0
- data/lib/rigor/analysis/check_rules/ivar_write_collector.rb +29 -0
- data/lib/rigor/analysis/check_rules/main_pass_collector.rb +54 -0
- data/lib/rigor/analysis/check_rules/rule_walk.rb +213 -0
- data/lib/rigor/analysis/check_rules/unreachable_clause_collector.rb +24 -1
- data/lib/rigor/analysis/check_rules.rb +275 -44
- data/lib/rigor/analysis/diagnostic.rb +8 -0
- data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +581 -0
- data/lib/rigor/analysis/runner/pool_coordinator.rb +569 -0
- data/lib/rigor/analysis/runner/project_pre_passes.rb +321 -0
- data/lib/rigor/analysis/runner/run_snapshots.rb +46 -0
- data/lib/rigor/analysis/runner.rb +207 -1200
- data/lib/rigor/analysis/worker_session.rb +60 -11
- data/lib/rigor/bleeding_edge.rb +123 -0
- data/lib/rigor/cache/descriptor.rb +86 -8
- data/lib/rigor/cache/incremental_snapshot.rb +10 -4
- data/lib/rigor/cache/rbs_cache_producer.rb +5 -1
- data/lib/rigor/cache/rbs_descriptor.rb +2 -1
- data/lib/rigor/cache/store.rb +46 -13
- data/lib/rigor/cli/annotate_command.rb +100 -15
- data/lib/rigor/cli/check_command.rb +708 -0
- data/lib/rigor/cli/ci_detector.rb +94 -0
- data/lib/rigor/cli/diagnostic_formats.rb +345 -0
- data/lib/rigor/cli/plugins_command.rb +2 -4
- data/lib/rigor/cli/plugins_renderer.rb +0 -2
- data/lib/rigor/cli/prism_colorizer.rb +10 -3
- data/lib/rigor/cli/show_bleedingedge_command.rb +114 -0
- 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 +6 -3
- data/lib/rigor/cli/triage_renderer.rb +15 -1
- data/lib/rigor/cli.rb +21 -612
- data/lib/rigor/configuration/severity_profile.rb +13 -1
- data/lib/rigor/configuration.rb +66 -7
- data/lib/rigor/environment/rbs_loader.rb +78 -68
- data/lib/rigor/environment.rb +1 -1
- data/lib/rigor/inference/acceptance.rb +10 -0
- data/lib/rigor/inference/body_fixpoint.rb +89 -0
- data/lib/rigor/inference/budget_trace.rb +29 -2
- data/lib/rigor/inference/expression_typer.rb +1080 -105
- data/lib/rigor/inference/flow_tracer.rb +180 -0
- data/lib/rigor/inference/macro_block_self_type.rb +11 -12
- data/lib/rigor/inference/method_dispatcher/array_to_h_folding.rb +60 -0
- data/lib/rigor/inference/method_dispatcher/constant_folding.rb +54 -14
- data/lib/rigor/inference/method_dispatcher/overload_selector.rb +33 -1
- data/lib/rigor/inference/method_dispatcher/reduce_folding.rb +281 -0
- data/lib/rigor/inference/method_dispatcher/regexp_folding.rb +71 -0
- data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +148 -10
- data/lib/rigor/inference/method_dispatcher.rb +187 -55
- data/lib/rigor/inference/method_parameter_binder.rb +56 -2
- data/lib/rigor/inference/multi_target_binder.rb +46 -3
- data/lib/rigor/inference/mutation_widening.rb +142 -0
- data/lib/rigor/inference/narrowing.rb +330 -37
- data/lib/rigor/inference/scope_indexer.rb +770 -39
- data/lib/rigor/inference/statement_evaluator.rb +998 -68
- data/lib/rigor/inference/synthetic_method_scanner.rb +1 -1
- data/lib/rigor/plugin/additional_initializer.rb +61 -38
- data/lib/rigor/plugin/base.rb +517 -120
- data/lib/rigor/plugin/macro/block_as_method.rb +22 -21
- data/lib/rigor/plugin/macro/nested_class_template.rb +9 -7
- data/lib/rigor/plugin/macro.rb +2 -3
- data/lib/rigor/plugin/manifest.rb +4 -24
- data/lib/rigor/plugin/node_rule_walk.rb +192 -0
- data/lib/rigor/plugin/registry.rb +264 -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 +60 -0
- data/lib/rigor/scope.rb +199 -204
- data/lib/rigor/sig_gen/generator.rb +8 -0
- data/lib/rigor/sig_gen/observation_collector.rb +6 -6
- data/lib/rigor/source/literals.rb +14 -0
- data/lib/rigor/triage/catalogue.rb +4 -19
- data/lib/rigor/triage.rb +69 -1
- data/lib/rigor/type/combinator.rb +34 -0
- data/lib/rigor/version.rb +1 -1
- data/lib/rigor.rb +0 -1
- data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable.rb +13 -29
- data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer.rb +13 -32
- data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/analyzer.rb +1 -2
- data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +27 -90
- data/plugins/rigor-activejob/lib/rigor/plugin/activejob.rb +13 -30
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +2 -4
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +90 -51
- data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/analyzer.rb +3 -3
- data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage.rb +25 -29
- 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-factorybot/lib/rigor/plugin/factorybot.rb +11 -40
- data/plugins/rigor-graphql/lib/rigor/plugin/graphql/type_scanner.rb +2 -2
- data/plugins/rigor-mangrove/lib/rigor/plugin/mangrove.rb +1 -1
- data/plugins/rigor-pundit/lib/rigor/plugin/pundit.rb +10 -21
- data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +21 -34
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +11 -18
- data/plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline.rb +0 -1
- 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 +37 -31
- data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers.rb +3 -23
- data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq.rb +8 -21
- data/plugins/rigor-sinatra/lib/rigor/plugin/sinatra.rb +1 -1
- 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 +108 -36
- data/sig/rigor/analysis/fact_store.rbs +3 -0
- data/sig/rigor/environment.rbs +0 -2
- data/sig/rigor/inference/builtins/method_catalog.rbs +1 -1
- data/sig/rigor/inference.rbs +5 -0
- data/sig/rigor/plugin/base.rbs +6 -4
- data/sig/rigor/plugin/manifest.rbs +1 -2
- data/sig/rigor/scope.rbs +50 -29
- data/sig/rigor/source.rbs +1 -0
- data/sig/rigor/type.rbs +1 -0
- data/sig/rigor.rbs +1 -1
- data/skills/rigor-baseline-reduce/references/01-classify.md +27 -0
- data/skills/rigor-ci-setup/SKILL.md +319 -0
- data/skills/rigor-plugin-author/SKILL.md +6 -4
- data/skills/rigor-plugin-author/references/02-walker-and-types.md +22 -17
- data/skills/rigor-project-init/references/03-baseline-and-bugs.md +18 -1
- metadata +21 -3
- data/lib/rigor/cache/rbs_instance_definitions.rb +0 -66
- data/lib/rigor/plugin/macro/external_file.rb +0 -143
data/lib/rigor/plugin/base.rb
CHANGED
|
@@ -74,23 +74,59 @@ module Rigor
|
|
|
74
74
|
# argument; the same params Hash mixes into the cache
|
|
75
75
|
# key per `Cache::Descriptor#cache_key_for`.
|
|
76
76
|
#
|
|
77
|
-
# `serialize:` / `deserialize:`
|
|
78
|
-
#
|
|
79
|
-
#
|
|
80
|
-
#
|
|
81
|
-
#
|
|
77
|
+
# `serialize:` / `deserialize:` apply to the producer's
|
|
78
|
+
# return VALUE (the cache layer wraps them around the
|
|
79
|
+
# record-and-validate entry pair itself). Default
|
|
80
|
+
# round-trip is `Marshal.dump` / `Marshal.load` per the
|
|
81
|
+
# v0.0.9 callable surface; producers whose return values
|
|
82
|
+
# are not Marshal-clean must supply their own pair.
|
|
83
|
+
#
|
|
84
|
+
# `watch:` (ADR-60 WD3) declares the glob coverage of a
|
|
85
|
+
# discovery-style producer — the files whose addition /
|
|
86
|
+
# removal / edit must invalidate the cached value even
|
|
87
|
+
# when the producer block never read them individually
|
|
88
|
+
# (e.g. it globbed a directory itself). It is either
|
|
89
|
+
#
|
|
90
|
+
# - a static Array of `[roots, pattern, ...]` tuples
|
|
91
|
+
# (`roots` a String or Array of Strings; one or more
|
|
92
|
+
# glob-pattern suffixes per tuple — the same shape
|
|
93
|
+
# {#glob_descriptor} takes), or
|
|
94
|
+
# - a Proc, run through `instance_exec` on the plugin
|
|
95
|
+
# instance at `cache_for` invocation time (NEVER at
|
|
96
|
+
# class-definition time — search roots are typically
|
|
97
|
+
# computed in `#init` from config), returning the same
|
|
98
|
+
# tuple Array.
|
|
99
|
+
#
|
|
100
|
+
# The evaluated tuples become {Cache::Descriptor::GlobEntry}
|
|
101
|
+
# rows in the dependency descriptor recorded after the
|
|
102
|
+
# block runs; `Descriptor#fresh?` re-globs + re-digests on
|
|
103
|
+
# the next run.
|
|
82
104
|
#
|
|
83
105
|
# Producer ids are auto-prefixed `plugin.<manifest.id>.`
|
|
84
106
|
# at the cache layer (slice 6-C) so plugin-side ids cannot
|
|
85
107
|
# collide with built-in producers.
|
|
86
|
-
def producer(id, serialize: nil, deserialize: nil, &block)
|
|
108
|
+
def producer(id, watch: nil, serialize: nil, deserialize: nil, &block)
|
|
87
109
|
raise ArgumentError, "Plugin::Base.producer requires a block body" if block.nil?
|
|
88
110
|
|
|
111
|
+
validate_producer_watch!(watch)
|
|
89
112
|
@producers ||= {}
|
|
90
|
-
@producers[id.to_sym] = {
|
|
113
|
+
@producers[id.to_sym] = {
|
|
114
|
+
block: block, watch: watch, serialize: serialize, deserialize: deserialize
|
|
115
|
+
}.freeze
|
|
91
116
|
id.to_sym
|
|
92
117
|
end
|
|
93
118
|
|
|
119
|
+
# ADR-60 WD3 — `watch:` is nil (no glob coverage), a static
|
|
120
|
+
# tuple Array, or a Proc evaluated per `cache_for` call.
|
|
121
|
+
def validate_producer_watch!(watch)
|
|
122
|
+
return if watch.nil? || watch.is_a?(Array) || watch.respond_to?(:call)
|
|
123
|
+
|
|
124
|
+
raise ArgumentError,
|
|
125
|
+
"Plugin::Base.producer watch: must be nil, an Array of [roots, pattern, ...] tuples, " \
|
|
126
|
+
"or a Proc returning one, got #{watch.inspect}"
|
|
127
|
+
end
|
|
128
|
+
private :validate_producer_watch!
|
|
129
|
+
|
|
94
130
|
# Frozen snapshot of the producer table. Inherited
|
|
95
131
|
# producers from a superclass are intentionally NOT
|
|
96
132
|
# surfaced — Plugin::Base subclasses do not chain
|
|
@@ -190,36 +226,140 @@ module Rigor
|
|
|
190
226
|
defined?(@node_file_context_block) ? @node_file_context_block : nil
|
|
191
227
|
end
|
|
192
228
|
|
|
193
|
-
# ADR-37 slice 2 — declares a per-call-site
|
|
194
|
-
# contribution,
|
|
195
|
-
# `return_type` slot of
|
|
229
|
+
# ADR-37 slice 2 / ADR-52 WD2 — declares a per-call-site
|
|
230
|
+
# return-type contribution, gated by receiver class, method name,
|
|
231
|
+
# or both. The narrow successor to the `return_type` slot of the
|
|
232
|
+
# deleted `flow_contribution_for` hook (ADR-52 WD3):
|
|
196
233
|
#
|
|
234
|
+
# # receiver-gated only:
|
|
197
235
|
# dynamic_return receivers: ["ActiveRecord::Base"] do |call_node, scope|
|
|
198
236
|
# # self = plugin instance; return a Rigor::Type or nil
|
|
199
237
|
# end
|
|
200
238
|
#
|
|
239
|
+
# # receiver + method gated (preferred for focused rules):
|
|
240
|
+
# dynamic_return receivers: ["Result"], methods: [:unwrap, :unwrap!] do |call_node, scope|
|
|
241
|
+
# # fires only for Result#unwrap / Result#unwrap!
|
|
242
|
+
# end
|
|
243
|
+
#
|
|
244
|
+
# # method-gated only (ADR-52 WD2 — receiver-independent rules,
|
|
245
|
+
# # e.g. a unit-dimension DSL whose receiver carrier is a
|
|
246
|
+
# # refinement, not a nominal class):
|
|
247
|
+
# dynamic_return methods: [:kilometers, :per_hour, :in_meters] do |call_node, scope|
|
|
248
|
+
# # fires for any receiver when the method name matches;
|
|
249
|
+
# # the block reads the receiver's shape itself
|
|
250
|
+
# end
|
|
251
|
+
#
|
|
201
252
|
# `receivers:` is a non-empty Array of class names; the engine
|
|
202
253
|
# calls the block only when the call's receiver type's class
|
|
203
254
|
# equals or inherits from one of them (via
|
|
204
|
-
# `Environment#class_ordering`).
|
|
205
|
-
#
|
|
206
|
-
#
|
|
207
|
-
#
|
|
208
|
-
#
|
|
209
|
-
#
|
|
210
|
-
|
|
255
|
+
# `Environment#class_ordering`). It MAY be omitted — then the rule
|
|
256
|
+
# is receiver-independent and fires on `methods:` alone.
|
|
257
|
+
#
|
|
258
|
+
# `methods:` is an Array of Symbol method names. When provided, the
|
|
259
|
+
# block is skipped unless `call_node.name` is in the list —
|
|
260
|
+
# declarative and cheaper than an in-block guard (the engine
|
|
261
|
+
# compiles it into the registry's contribution table, ADR-52 WD1).
|
|
262
|
+
# It is REQUIRED when `receivers:` is omitted: a rule gated on
|
|
263
|
+
# neither would fire on every dispatch, which is exactly the
|
|
264
|
+
# ungated cost the `flow_contribution_for` escape valve carries —
|
|
265
|
+
# `dynamic_return` declines to reintroduce it.
|
|
266
|
+
#
|
|
267
|
+
# Method-name and type-shape refinement can still be done inside
|
|
268
|
+
# the block. The block runs through `instance_exec`, so `config`
|
|
269
|
+
# / `services` are in scope.
|
|
270
|
+
# ADR-52 slice 3 — `receivers:` may also be a **callable**
|
|
271
|
+
# (a `-> { ... }` resolved once per run, lazily, the first time
|
|
272
|
+
# the rule is consulted — always after `#prepare`) for a receiver
|
|
273
|
+
# set the plugin only knows at run time:
|
|
274
|
+
#
|
|
275
|
+
# dynamic_return receivers: -> { attachment_index.model_names } do |call_node, scope|
|
|
276
|
+
# # fires when the receiver class is one a `prepare`-time scan
|
|
277
|
+
# # found; the block does the precise per-call lookup
|
|
278
|
+
# end
|
|
279
|
+
#
|
|
280
|
+
# The callable runs through `instance_exec`, so it reads the
|
|
281
|
+
# plugin's own `#prepare`-built indexes. It MUST be idempotent and
|
|
282
|
+
# post-`#prepare`-safe — reference a lazily-built / memoised index
|
|
283
|
+
# (as activestorage's `attachment_index` and activerecord's
|
|
284
|
+
# `model_index` are), never a value captured at class-definition
|
|
285
|
+
# time. The resolved set is a safe over-approximation of the
|
|
286
|
+
# block's own filter (it admits subclasses too), so the block
|
|
287
|
+
# stays the precise gate and diagnostics are unchanged.
|
|
288
|
+
#
|
|
289
|
+
# ADR-52 slice 4 — `methods:` may ALSO be a callable, for a
|
|
290
|
+
# method-name set the plugin only knows at run time (a Sorbet
|
|
291
|
+
# catalog's keys, a config-derived DSL method name):
|
|
292
|
+
#
|
|
293
|
+
# dynamic_return methods: -> { catalog.method_names } do |call_node, scope|
|
|
294
|
+
# ...
|
|
295
|
+
# end
|
|
296
|
+
#
|
|
297
|
+
# Same contract as a callable `receivers:` — `instance_exec`'d,
|
|
298
|
+
# resolved lazily after `#prepare`, memoised, idempotent. A
|
|
299
|
+
# callable method set cannot be compiled into the registry's
|
|
300
|
+
# name gate (it is unknown at registry-build time), so the
|
|
301
|
+
# plugin is consulted on every dispatch and the name filter runs
|
|
302
|
+
# in this instance path instead — the block still only fires for
|
|
303
|
+
# a listed name, so diagnostics are unchanged.
|
|
304
|
+
# ADR-52 slice 5a — `file_methods:` is the per-file
|
|
305
|
+
# specialisation of the run-time `methods:` callable, for a name
|
|
306
|
+
# set that varies per analysed file (rigor-rspec's `let` names —
|
|
307
|
+
# the names depend on each file's `describe`/`let` structure, so
|
|
308
|
+
# one run-wide set cannot exist). The callable receives the file
|
|
309
|
+
# path, runs through `instance_exec`, and is memoised per
|
|
310
|
+
# `(rule, path)`:
|
|
311
|
+
#
|
|
312
|
+
# dynamic_return file_methods: ->(path) { let_names_for(path) } do |call_node, scope|
|
|
313
|
+
# ...
|
|
314
|
+
# end
|
|
315
|
+
#
|
|
316
|
+
# Same idempotence contract as the other callables, plus: it MUST
|
|
317
|
+
# tolerate any path the engine analyses (return `[]` / nil for a
|
|
318
|
+
# file it has no names for — never raise). Like a callable
|
|
319
|
+
# `methods:`, it cannot compile into the registry name gate, so
|
|
320
|
+
# the plugin is consulted on every dispatch and filtered here.
|
|
321
|
+
# `file_methods:` replaces `methods:` (declaring both is
|
|
322
|
+
# rejected — they are the same gate at two scopes); it MAY
|
|
323
|
+
# combine with `receivers:`.
|
|
324
|
+
def dynamic_return(receivers: nil, methods: nil, file_methods: nil, &block)
|
|
211
325
|
raise ArgumentError, "Plugin::Base.dynamic_return requires a block body" if block.nil?
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
326
|
+
|
|
327
|
+
validate_dynamic_return_gate!(receivers, methods, file_methods)
|
|
328
|
+
validate_dynamic_return_receivers!(receivers) unless receivers.nil?
|
|
329
|
+
validate_dynamic_return_methods!(methods)
|
|
330
|
+
validate_dynamic_return_file_methods!(file_methods, methods)
|
|
217
331
|
|
|
218
332
|
@dynamic_returns ||= []
|
|
219
|
-
@dynamic_returns << {
|
|
333
|
+
@dynamic_returns << {
|
|
334
|
+
receivers: normalize_dynamic_return_receivers(receivers),
|
|
335
|
+
methods: normalize_dynamic_return_methods(methods),
|
|
336
|
+
file_methods: file_methods,
|
|
337
|
+
block: block
|
|
338
|
+
}.freeze
|
|
220
339
|
nil
|
|
221
340
|
end
|
|
222
341
|
|
|
342
|
+
# A class-name Array is frozen element-wise; a run-time callable
|
|
343
|
+
# (ADR-52 slice 3) is stored verbatim and resolved per instance.
|
|
344
|
+
def normalize_dynamic_return_receivers(receivers)
|
|
345
|
+
return nil if receivers.nil?
|
|
346
|
+
return receivers if receivers.respond_to?(:call)
|
|
347
|
+
|
|
348
|
+
receivers.map { |r| r.dup.freeze }.freeze
|
|
349
|
+
end
|
|
350
|
+
private :normalize_dynamic_return_receivers
|
|
351
|
+
|
|
352
|
+
# A method-name Array is symbol-normalised + frozen; a run-time
|
|
353
|
+
# callable (ADR-52 slice 4) is stored verbatim and resolved per
|
|
354
|
+
# instance.
|
|
355
|
+
def normalize_dynamic_return_methods(methods)
|
|
356
|
+
return nil if methods.nil?
|
|
357
|
+
return methods if methods.respond_to?(:call)
|
|
358
|
+
|
|
359
|
+
methods.map(&:to_sym).freeze
|
|
360
|
+
end
|
|
361
|
+
private :normalize_dynamic_return_methods
|
|
362
|
+
|
|
223
363
|
# Frozen snapshot of the declared dynamic-return rules. Memoised:
|
|
224
364
|
# `@dynamic_returns` is built once at class-definition time (via
|
|
225
365
|
# `dynamic_return`) and never mutated during analysis, and every
|
|
@@ -237,9 +377,67 @@ module Rigor
|
|
|
237
377
|
end
|
|
238
378
|
# rubocop:enable Naming/MemoizedInstanceVariableName
|
|
239
379
|
|
|
380
|
+
# ADR-52 WD2 — a rule must gate on something. `receivers:` alone,
|
|
381
|
+
# `methods:` alone, or both are valid; neither is not (it would
|
|
382
|
+
# fire on every dispatch).
|
|
383
|
+
def validate_dynamic_return_gate!(receivers, methods, file_methods)
|
|
384
|
+
return unless receivers.nil? && file_methods.nil?
|
|
385
|
+
return if (methods.is_a?(Array) && !methods.empty?) || methods.respond_to?(:call)
|
|
386
|
+
|
|
387
|
+
raise ArgumentError,
|
|
388
|
+
"Plugin::Base.dynamic_return requires receivers:, methods:, or file_methods: — a rule " \
|
|
389
|
+
"gated on none would fire on every dispatch (that is what flow_contribution_for is for)"
|
|
390
|
+
end
|
|
391
|
+
|
|
392
|
+
# ADR-52 slice 5a — `file_methods:` must be a callable, and is
|
|
393
|
+
# mutually exclusive with `methods:` (one name gate, two scopes —
|
|
394
|
+
# declaring both is a contradiction, not a composition).
|
|
395
|
+
def validate_dynamic_return_file_methods!(file_methods, methods)
|
|
396
|
+
return if file_methods.nil?
|
|
397
|
+
|
|
398
|
+
unless file_methods.respond_to?(:call)
|
|
399
|
+
raise ArgumentError,
|
|
400
|
+
"Plugin::Base.dynamic_return file_methods: must be a callable receiving the file path, " \
|
|
401
|
+
"got #{file_methods.inspect}"
|
|
402
|
+
end
|
|
403
|
+
return if methods.nil?
|
|
404
|
+
|
|
405
|
+
raise ArgumentError,
|
|
406
|
+
"Plugin::Base.dynamic_return file_methods: replaces methods: — declare one name gate, " \
|
|
407
|
+
"not both"
|
|
408
|
+
end
|
|
409
|
+
|
|
410
|
+
def validate_dynamic_return_receivers!(receivers)
|
|
411
|
+
# ADR-52 slice 3 — a run-time callable is resolved per instance
|
|
412
|
+
# after `#prepare`; its shape is checked at resolution time.
|
|
413
|
+
return if receivers.respond_to?(:call)
|
|
414
|
+
return if receivers.is_a?(Array) && !receivers.empty? && receivers.all? { |r| r.is_a?(String) && !r.empty? }
|
|
415
|
+
|
|
416
|
+
raise ArgumentError,
|
|
417
|
+
"Plugin::Base.dynamic_return receivers: must be a non-empty Array of class-name Strings " \
|
|
418
|
+
"or a callable, got #{receivers.inspect}"
|
|
419
|
+
end
|
|
420
|
+
|
|
421
|
+
def validate_dynamic_return_methods!(methods)
|
|
422
|
+
return if methods.nil?
|
|
423
|
+
# ADR-52 slice 4 — a run-time callable resolves to the name set
|
|
424
|
+
# per instance after `#prepare`; its shape is checked then.
|
|
425
|
+
return if methods.respond_to?(:call)
|
|
426
|
+
return if methods.is_a?(Array) && !methods.empty? &&
|
|
427
|
+
methods.all? { |m| m.is_a?(Symbol) || (m.is_a?(String) && !m.empty?) }
|
|
428
|
+
|
|
429
|
+
raise ArgumentError,
|
|
430
|
+
"Plugin::Base.dynamic_return methods: must be a non-empty Array of Symbol/String, a callable, " \
|
|
431
|
+
"or nil, got #{methods.inspect}"
|
|
432
|
+
end
|
|
433
|
+
|
|
434
|
+
private :validate_dynamic_return_gate!, :validate_dynamic_return_receivers!,
|
|
435
|
+
:validate_dynamic_return_methods!, :validate_dynamic_return_file_methods!
|
|
436
|
+
|
|
240
437
|
# ADR-37 slice 2 — declares a predicate/assertion narrowing
|
|
241
438
|
# contribution, method-gated. The narrow successor to the
|
|
242
|
-
# `post_return_facts` slot of `flow_contribution_for
|
|
439
|
+
# `post_return_facts` slot of the deleted `flow_contribution_for`
|
|
440
|
+
# hook (ADR-52 WD3):
|
|
243
441
|
#
|
|
244
442
|
# type_specifier methods: [:assert_kind_of] do |call_node, scope|
|
|
245
443
|
# # return an Array of post-return facts, or nil
|
|
@@ -277,6 +475,19 @@ module Rigor
|
|
|
277
475
|
def initialize(services:, config: {})
|
|
278
476
|
@services = services
|
|
279
477
|
@config = merge_config_defaults(config).freeze
|
|
478
|
+
# ADR-52 slice 3 — per-rule cache of resolved run-time
|
|
479
|
+
# `dynamic_return receivers:` callables. Created here (before any
|
|
480
|
+
# subclass `initialize` freezes the instance) so the lazy
|
|
481
|
+
# memo-on-first-dispatch is a Hash-content mutation, sound even on
|
|
482
|
+
# a self-freezing plugin.
|
|
483
|
+
@dynamic_return_runtime_cache = {}
|
|
484
|
+
# ADR-60 WD4 — nil-inclusive memo tables for the authoring
|
|
485
|
+
# helpers ({#read_fact} / {#producer_value} / {#producer_error}).
|
|
486
|
+
# Allocated here, before any subclass `initialize` self-freeze,
|
|
487
|
+
# for the same reason: a populate is a Hash-content mutation.
|
|
488
|
+
@fact_cache = {}
|
|
489
|
+
@producer_value_cache = {}
|
|
490
|
+
@producer_errors = {}
|
|
280
491
|
end
|
|
281
492
|
|
|
282
493
|
# Override in subclasses to wire any state the plugin needs
|
|
@@ -287,22 +498,13 @@ module Rigor
|
|
|
287
498
|
nil
|
|
288
499
|
end
|
|
289
500
|
|
|
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
|
|
501
|
+
# NOTE: (ADR-52 WD3): the legacy ungated per-call hook
|
|
502
|
+
# `flow_contribution_for` was DELETED here pre-1.0 after its five
|
|
503
|
+
# production users migrated. Per-call return types are declared via
|
|
504
|
+
# the gated {.dynamic_return} DSL (static / run-time / per-file
|
|
505
|
+
# name sets, static / run-time receiver sets); post-return
|
|
506
|
+
# narrowing facts via {.type_specifier}. See the CHANGELOG
|
|
507
|
+
# migration note for the idiom-by-idiom mapping.
|
|
306
508
|
|
|
307
509
|
# ADR-9 slice 3 — per-run preparation hook. The runner
|
|
308
510
|
# invokes `#prepare(services)` on every loaded plugin once
|
|
@@ -379,10 +581,14 @@ module Rigor
|
|
|
379
581
|
|
|
380
582
|
diagnostics = []
|
|
381
583
|
Source::NodeWalker.each_with_ancestors(root) do |node, ancestors|
|
|
584
|
+
# One frozen NodeContext per node, shared across the rules
|
|
585
|
+
# that match it (ADR-52 WD1) — built lazily so non-matching
|
|
586
|
+
# nodes (the vast majority) allocate nothing.
|
|
587
|
+
context = nil
|
|
382
588
|
rules.each do |rule|
|
|
383
589
|
next unless node.is_a?(rule[:node_type])
|
|
384
590
|
|
|
385
|
-
context
|
|
591
|
+
context ||= NodeContext.new(ancestors)
|
|
386
592
|
diagnostics.concat(Array(instance_exec(node, scope, path, file_context, context, &rule[:block])))
|
|
387
593
|
end
|
|
388
594
|
end
|
|
@@ -391,20 +597,22 @@ module Rigor
|
|
|
391
597
|
|
|
392
598
|
# ADR-37 slice 2 — the return type contributed by this plugin's
|
|
393
599
|
# {.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
|
|
600
|
+
# from `MethodDispatcher`; a rule fires only when `receiver_type`'s
|
|
396
601
|
# class equals or inherits from one of its declared `receivers:`.
|
|
397
602
|
# First non-nil wins (declaration order). Failures isolate to nil.
|
|
398
603
|
def dynamic_return_type(call_node:, scope:, receiver_type:)
|
|
399
604
|
rules = self.class.dynamic_returns
|
|
400
605
|
return nil if rules.empty? || receiver_type.nil?
|
|
401
606
|
|
|
607
|
+
# `class_name` is nil for a receiver carrier with no nominal
|
|
608
|
+
# class (a refinement dimension, an inferred shape) — fine for a
|
|
609
|
+
# receiver-less (methods-only) rule (ADR-52 WD2), which gates on
|
|
610
|
+
# the method name alone and reads the receiver shape inside its
|
|
611
|
+
# own block.
|
|
402
612
|
class_name = dynamic_return_receiver_class_name(receiver_type)
|
|
403
|
-
return nil if class_name.nil?
|
|
404
|
-
|
|
405
613
|
environment = scope&.environment
|
|
406
614
|
rules.each do |rule|
|
|
407
|
-
next unless rule
|
|
615
|
+
next unless dynamic_return_rule_applies?(rule, call_node, class_name, environment, scope)
|
|
408
616
|
|
|
409
617
|
result = instance_exec(call_node, scope, &rule[:block])
|
|
410
618
|
return result if result
|
|
@@ -416,8 +624,8 @@ module Rigor
|
|
|
416
624
|
|
|
417
625
|
# ADR-37 slice 2 — the post-return narrowing facts contributed by
|
|
418
626
|
# this plugin's {.type_specifier} rules for a call. The engine
|
|
419
|
-
# calls this from `StatementEvaluator
|
|
420
|
-
# `
|
|
627
|
+
# calls this from `StatementEvaluator`; a rule fires only when
|
|
628
|
+
# `call_node.name`
|
|
421
629
|
# is one of its declared `methods:`. Failures isolate to [].
|
|
422
630
|
def type_specifier_facts(call_node:, scope:)
|
|
423
631
|
rules = self.class.type_specifiers
|
|
@@ -460,6 +668,69 @@ module Rigor
|
|
|
460
668
|
)
|
|
461
669
|
end
|
|
462
670
|
|
|
671
|
+
# ADR-60 WD4 — maps a plugin's own violation objects to
|
|
672
|
+
# `Rigor::Analysis::Diagnostic`s through {#diagnostic}, absorbing
|
|
673
|
+
# the `violations.map { |v| diagnostic(node, …) }` block the
|
|
674
|
+
# node-rule plugins otherwise repeat. Each violation duck-types:
|
|
675
|
+
# `#message` (required); optional `#node` (the Prism node to
|
|
676
|
+
# position at — falls back to the `node:` argument, the common
|
|
677
|
+
# "all violations point at the same call" case), `#location` (a
|
|
678
|
+
# sub-location such as `node.message_loc`), `#severity` (defaults
|
|
679
|
+
# `:error`), and `#rule`. Returns an Array suitable for direct
|
|
680
|
+
# return from `#diagnostics_for_file` / a `node_rule` block.
|
|
681
|
+
def diagnostics_for(violations, path:, node: nil)
|
|
682
|
+
Array(violations).map do |violation|
|
|
683
|
+
target = (violation.node if violation.respond_to?(:node)) || node
|
|
684
|
+
diagnostic(
|
|
685
|
+
target,
|
|
686
|
+
path: path,
|
|
687
|
+
message: violation.message,
|
|
688
|
+
severity: (violation.respond_to?(:severity) && violation.severity) || :error,
|
|
689
|
+
rule: (violation.rule if violation.respond_to?(:rule)),
|
|
690
|
+
location: (violation.location if violation.respond_to?(:location))
|
|
691
|
+
)
|
|
692
|
+
end
|
|
693
|
+
end
|
|
694
|
+
|
|
695
|
+
# ADR-60 WD4 — reads a cross-plugin fact (ADR-9) published by
|
|
696
|
+
# another plugin's `#prepare` hook, memoised per `(plugin_id,
|
|
697
|
+
# name)` on this instance INCLUDING a nil result. The nil-inclusive
|
|
698
|
+
# memo retires the hand-rolled `@x_resolved` flag the discovery
|
|
699
|
+
# plugins carried to distinguish "fact not published" from "not yet
|
|
700
|
+
# read". `services.fact_store` is the only sanctioned cross-plugin
|
|
701
|
+
# channel; a fact no loaded producer published reads as nil.
|
|
702
|
+
def read_fact(plugin_id:, name:)
|
|
703
|
+
key = [plugin_id.to_s, name.to_sym].freeze
|
|
704
|
+
return @fact_cache[key] if @fact_cache.key?(key)
|
|
705
|
+
|
|
706
|
+
@fact_cache[key] = services.fact_store.read(plugin_id: plugin_id.to_s, name: name.to_sym)
|
|
707
|
+
end
|
|
708
|
+
|
|
709
|
+
# ADR-60 WD4 — runs a declared {.producer} through {#cache_for}
|
|
710
|
+
# and returns its value, memoised per `(id, params)` INCLUDING nil.
|
|
711
|
+
# A `StandardError` the producer raises (a malformed project file,
|
|
712
|
+
# an I/O failure) is rescued, recorded for {#producer_error}, and
|
|
713
|
+
# yields nil — so one bad project file degrades a plugin to silence
|
|
714
|
+
# rather than aborting the whole run. This is the `*_index_or_nil`
|
|
715
|
+
# shape the discovery plugins hand-rolled, named once.
|
|
716
|
+
def producer_value(id, params: {})
|
|
717
|
+
key = [id.to_sym, params].freeze
|
|
718
|
+
return @producer_value_cache[key] if @producer_value_cache.key?(key)
|
|
719
|
+
|
|
720
|
+
@producer_value_cache[key] = cache_for(id, params: params).call
|
|
721
|
+
rescue StandardError => e
|
|
722
|
+
@producer_errors[id.to_sym] = e
|
|
723
|
+
@producer_value_cache[key] = nil
|
|
724
|
+
end
|
|
725
|
+
|
|
726
|
+
# ADR-60 WD4 — the `StandardError` a prior {#producer_value} call
|
|
727
|
+
# rescued for `id`, or nil when it succeeded or was never called.
|
|
728
|
+
# Plugins surface it as a load-error diagnostic from
|
|
729
|
+
# `#diagnostics_for_file`.
|
|
730
|
+
def producer_error(id)
|
|
731
|
+
@producer_errors[id.to_sym]
|
|
732
|
+
end
|
|
733
|
+
|
|
463
734
|
# Boilerplate-reduction helper (review §1.3): the "did you mean …?"
|
|
464
735
|
# suggestion every diagnostic-emitting plugin otherwise hand-rolls.
|
|
465
736
|
# Returns the closest of `candidates` to `name` via
|
|
@@ -530,32 +801,38 @@ module Rigor
|
|
|
530
801
|
@io_boundary ||= services.io_boundary_for(manifest.id)
|
|
531
802
|
end
|
|
532
803
|
|
|
533
|
-
# ADR-7 § "Slice 6-A" — returns a callable that
|
|
534
|
-
# a `Cache::Store#
|
|
535
|
-
# named producer
|
|
536
|
-
#
|
|
537
|
-
# `PluginEntry` template (id, version, config_hash)
|
|
538
|
-
#
|
|
539
|
-
#
|
|
540
|
-
#
|
|
541
|
-
#
|
|
804
|
+
# ADR-7 § "Slice 6-A" / ADR-60 WD3 — returns a callable that
|
|
805
|
+
# performs a `Cache::Store#fetch_or_validate` round-trip for
|
|
806
|
+
# the named producer (the ADR-45 record-and-validate path).
|
|
807
|
+
# The entry is KEYED on the stable identity inputs — the
|
|
808
|
+
# plugin's `PluginEntry` template (id, version, config_hash)
|
|
809
|
+
# composed with the optional `descriptor:` extras — and
|
|
810
|
+
# stores, beside the value, a DEPENDENCY descriptor recorded
|
|
811
|
+
# AFTER the producer block ran: the {IoBoundary}'s
|
|
812
|
+
# post-compute read history plus the evaluated `watch:`
|
|
813
|
+
# {Cache::Descriptor::GlobEntry} rows. In-block reads are
|
|
814
|
+
# therefore always captured (the structural stale-cache
|
|
815
|
+
# hazard `fetch_or_compute`'s call-time snapshot carried);
|
|
816
|
+
# the next run re-validates the recorded dependencies by
|
|
817
|
+
# re-digest (`Descriptor#fresh?`) and recomputes when any
|
|
818
|
+
# changed. The producer id is auto-prefixed
|
|
819
|
+
# `plugin.<manifest.id>.` per ADR-7 § "Slice 6-C" so plugin
|
|
820
|
+
# caches stay sandboxed from built-in producers.
|
|
542
821
|
#
|
|
543
822
|
# When `services.cache_store` is `nil` (e.g. CLI
|
|
544
823
|
# `--no-cache`), the callable bypasses the cache and
|
|
545
824
|
# runs the producer block every time — same semantics
|
|
546
825
|
# as the v0.0.9 cache surface for built-in producers.
|
|
547
826
|
#
|
|
548
|
-
# `descriptor:` (optional
|
|
549
|
-
#
|
|
550
|
-
#
|
|
551
|
-
#
|
|
552
|
-
#
|
|
553
|
-
#
|
|
554
|
-
#
|
|
555
|
-
#
|
|
556
|
-
#
|
|
557
|
-
# `Cache::Descriptor::Conflict` to make divergent inputs
|
|
558
|
-
# visible rather than silently shadowing.
|
|
827
|
+
# `descriptor:` (optional) supplies extra `Cache::Descriptor`
|
|
828
|
+
# rows for IDENTITY inputs — gem-version `GemEntry` pins,
|
|
829
|
+
# `ConfigEntry` rows for external state — that compose into
|
|
830
|
+
# the cache KEY via `Cache::Descriptor.compose`; per-slot
|
|
831
|
+
# conflicts raise `Cache::Descriptor::Conflict` to make
|
|
832
|
+
# divergent inputs visible rather than silently shadowing.
|
|
833
|
+
# A key change is a miss, so the invalidation effect of the
|
|
834
|
+
# legacy `glob_descriptor`-as-`descriptor:` idiom is
|
|
835
|
+
# preserved unchanged.
|
|
559
836
|
def cache_for(producer_id, params: {}, descriptor: nil)
|
|
560
837
|
producer = self.class.producers[producer_id.to_sym]
|
|
561
838
|
unless producer
|
|
@@ -568,16 +845,18 @@ module Rigor
|
|
|
568
845
|
return compute unless store
|
|
569
846
|
|
|
570
847
|
prefixed_id = "plugin.#{manifest.id}.#{producer_id}"
|
|
571
|
-
|
|
848
|
+
key_descriptor = compose_key_descriptor(descriptor)
|
|
572
849
|
lambda do
|
|
573
|
-
store.
|
|
850
|
+
store.fetch_or_validate(
|
|
574
851
|
producer_id: prefixed_id,
|
|
852
|
+
key_descriptor: key_descriptor,
|
|
575
853
|
params: params,
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
854
|
+
serialize: pair_serializer(producer[:serialize]),
|
|
855
|
+
deserialize: pair_deserializer(producer[:deserialize])
|
|
856
|
+
) do
|
|
857
|
+
value = compute.call
|
|
858
|
+
[value, producer_dependency_descriptor(producer)]
|
|
859
|
+
end
|
|
581
860
|
end
|
|
582
861
|
end
|
|
583
862
|
|
|
@@ -589,31 +868,13 @@ module Rigor
|
|
|
589
868
|
# descriptor), or any removal (the previously-matched file
|
|
590
869
|
# drops out).
|
|
591
870
|
#
|
|
592
|
-
#
|
|
593
|
-
#
|
|
594
|
-
#
|
|
595
|
-
#
|
|
596
|
-
#
|
|
597
|
-
#
|
|
598
|
-
#
|
|
599
|
-
# warm runs return stale producer output when files have
|
|
600
|
-
# changed between sessions.
|
|
601
|
-
#
|
|
602
|
-
# Discovery-style producers (`actioncable`'s `:channel_index`,
|
|
603
|
-
# `actionmailer`'s `:mailer_index`, `rails-i18n`'s
|
|
604
|
-
# `:locale_index`) all follow the same pattern: walk a glob
|
|
605
|
-
# under one or more search roots, parse / read every match,
|
|
606
|
-
# build a typed index. They MUST call this helper at the
|
|
607
|
-
# `cache_for(descriptor: …)` site to be cache-correct under
|
|
608
|
-
# the persistent `Cache::Store` `rigor check` uses by
|
|
609
|
-
# default.
|
|
610
|
-
#
|
|
611
|
-
# The helper pays one SHA-256 read per matched file at
|
|
612
|
-
# call time; the producer block typically re-reads through
|
|
613
|
-
# `io_boundary.read_file` so the cost is doubled. For
|
|
614
|
-
# discovery globs in the 10-100 file range this is
|
|
615
|
-
# negligible (~ms) relative to the parse + walk the
|
|
616
|
-
# producer does on cache miss.
|
|
871
|
+
# ADR-60 WD3 made this **private**: the declared way for a
|
|
872
|
+
# discovery-style producer to cover its glob is `producer
|
|
873
|
+
# watch:` (one {Cache::Descriptor::GlobEntry} per glob in the
|
|
874
|
+
# record-and-validate dependency descriptor), not a hand-built
|
|
875
|
+
# descriptor composed into the cache *key*. The method survives
|
|
876
|
+
# only as the building block for the rare producer that needs
|
|
877
|
+
# `FileEntry` rows directly; plugin code calls `watch:`.
|
|
617
878
|
#
|
|
618
879
|
# @param roots [Array<String>] search roots (relative to
|
|
619
880
|
# the project root, or absolute paths)
|
|
@@ -633,6 +894,7 @@ module Rigor
|
|
|
633
894
|
end
|
|
634
895
|
Cache::Descriptor.new(files: entries)
|
|
635
896
|
end
|
|
897
|
+
private :glob_descriptor
|
|
636
898
|
|
|
637
899
|
private
|
|
638
900
|
|
|
@@ -659,6 +921,82 @@ module Rigor
|
|
|
659
921
|
end
|
|
660
922
|
end
|
|
661
923
|
|
|
924
|
+
# The gate for one `dynamic_return` rule. Method-name gate first —
|
|
925
|
+
# a Symbol-array probe vs the receiver ancestry resolution below
|
|
926
|
+
# (ADR-52 WD1); both are pure predicates, so order only affects
|
|
927
|
+
# cost. A receiver-less rule (ADR-52 WD2) skips the ancestry check
|
|
928
|
+
# entirely and fires on the method name alone.
|
|
929
|
+
def dynamic_return_rule_applies?(rule, call_node, class_name, environment, scope)
|
|
930
|
+
return false if rule[:methods] && !resolved_dynamic_return_methods(rule).include?(call_node.name)
|
|
931
|
+
|
|
932
|
+
if rule[:file_methods]
|
|
933
|
+
# The path is read here, not in `dynamic_return_type`, so a
|
|
934
|
+
# spec-double scope without `source_path` only affects
|
|
935
|
+
# `file_methods:` rules (other gate forms never touch it).
|
|
936
|
+
path = scope.respond_to?(:source_path) ? scope.source_path : nil
|
|
937
|
+
return false unless resolved_dynamic_return_file_methods(rule, path).include?(call_node.name)
|
|
938
|
+
end
|
|
939
|
+
|
|
940
|
+
receivers = resolved_dynamic_return_receivers(rule)
|
|
941
|
+
return true if receivers.nil?
|
|
942
|
+
return false if class_name.nil?
|
|
943
|
+
|
|
944
|
+
receivers.any? { |c| class_matches_receiver?(class_name, c, environment) }
|
|
945
|
+
end
|
|
946
|
+
|
|
947
|
+
# ADR-52 slice 4 — the rule's method-name set. A static Array is
|
|
948
|
+
# returned as-is (`#include?` over Symbols); a run-time callable is
|
|
949
|
+
# `instance_exec`'d against this plugin and memoised as a Symbol Set,
|
|
950
|
+
# same lazy/idempotent contract as a callable `receivers:`. The
|
|
951
|
+
# cache key is namespaced so a rule that makes both `methods:` and
|
|
952
|
+
# `receivers:` callable keeps two distinct memo slots.
|
|
953
|
+
def resolved_dynamic_return_methods(rule)
|
|
954
|
+
methods = rule[:methods]
|
|
955
|
+
return methods unless methods.respond_to?(:call)
|
|
956
|
+
|
|
957
|
+
(@dynamic_return_runtime_cache ||= {})[[:methods, rule]] ||=
|
|
958
|
+
Array(instance_exec(&methods)).to_set(&:to_sym).freeze
|
|
959
|
+
end
|
|
960
|
+
|
|
961
|
+
# ADR-52 slice 5a — the rule's per-file method-name set. The
|
|
962
|
+
# `file_methods:` callable is `instance_exec`'d with the file path
|
|
963
|
+
# and memoised per `(rule, path)` — one resolution per analysed
|
|
964
|
+
# file, the per-file analogue of the run-wide `methods:` memo. A
|
|
965
|
+
# nil path (synthetic call sites with no file context) resolves to
|
|
966
|
+
# the empty set: the gate has nothing to key on, so the rule
|
|
967
|
+
# declines — fail-closed, consistent with the gate's purpose. A
|
|
968
|
+
# raising callable degrades to "declines this dispatch" via
|
|
969
|
+
# `dynamic_return_type`'s surrounding rescue.
|
|
970
|
+
EMPTY_NAME_SET = Set.new.freeze
|
|
971
|
+
private_constant :EMPTY_NAME_SET
|
|
972
|
+
|
|
973
|
+
def resolved_dynamic_return_file_methods(rule, path)
|
|
974
|
+
return EMPTY_NAME_SET if path.nil?
|
|
975
|
+
|
|
976
|
+
(@dynamic_return_runtime_cache ||= {})[[:file_methods, rule, path]] ||=
|
|
977
|
+
Array(instance_exec(path, &rule[:file_methods])).to_set(&:to_sym).freeze
|
|
978
|
+
end
|
|
979
|
+
|
|
980
|
+
# ADR-52 slice 3 — the rule's receiver class-name Array. A static
|
|
981
|
+
# Array is returned as-is; a run-time callable is `instance_exec`'d
|
|
982
|
+
# against this plugin (so it reads the `#prepare`-built indexes) and
|
|
983
|
+
# memoised per rule for the run. Resolution is lazy — first reached
|
|
984
|
+
# during file analysis, always after `#prepare` — and the callable
|
|
985
|
+
# is required to be idempotent, so the memoised set is stable. A
|
|
986
|
+
# callable that raises degrades to "no receivers match" (the rule
|
|
987
|
+
# declines), never a crash, consistent with the surrounding rescue.
|
|
988
|
+
def resolved_dynamic_return_receivers(rule)
|
|
989
|
+
receivers = rule[:receivers]
|
|
990
|
+
return receivers unless receivers.respond_to?(:call)
|
|
991
|
+
|
|
992
|
+
# `||= {}` keeps the path correct even when a caller bypassed
|
|
993
|
+
# `initialize` (`allocate` in unit specs that inject a fake
|
|
994
|
+
# index); a self-freezing plugin already has the Hash from
|
|
995
|
+
# `initialize`, so the `||=` is a no-op there (never a FrozenError).
|
|
996
|
+
(@dynamic_return_runtime_cache ||= {})[rule] ||=
|
|
997
|
+
Array(instance_exec(&receivers)).map { |c| c.to_s.dup.freeze }.freeze
|
|
998
|
+
end
|
|
999
|
+
|
|
662
1000
|
# True when `class_name` equals or inherits from `constraint`,
|
|
663
1001
|
# matched through `Environment#class_ordering` (the mechanism
|
|
664
1002
|
# `MacroBlockSelfType` / `additional_initializers` use). Degrades to
|
|
@@ -682,17 +1020,6 @@ module Rigor
|
|
|
682
1020
|
matched.uniq.sort.select { |path| File.file?(path) }
|
|
683
1021
|
end
|
|
684
1022
|
|
|
685
|
-
# ADR-7 § "Slice 6-B" — composes the per-call cache
|
|
686
|
-
# descriptor from (1) the plugin's PluginEntry template
|
|
687
|
-
# and (2) the IoBoundary's accumulated FileEntry rows.
|
|
688
|
-
def build_plugin_cache_descriptor
|
|
689
|
-
boundary_descriptor = io_boundary.cache_descriptor
|
|
690
|
-
Cache::Descriptor.new(
|
|
691
|
-
plugins: [plugin_entry],
|
|
692
|
-
files: boundary_descriptor.files
|
|
693
|
-
)
|
|
694
|
-
end
|
|
695
|
-
|
|
696
1023
|
public
|
|
697
1024
|
|
|
698
1025
|
# ADR-32 WD5 — the `Cache::Descriptor::PluginEntry`
|
|
@@ -721,20 +1048,90 @@ module Rigor
|
|
|
721
1048
|
|
|
722
1049
|
private
|
|
723
1050
|
|
|
724
|
-
# ADR-
|
|
725
|
-
#
|
|
726
|
-
# extension
|
|
727
|
-
#
|
|
728
|
-
#
|
|
729
|
-
#
|
|
730
|
-
#
|
|
731
|
-
|
|
732
|
-
|
|
1051
|
+
# ADR-60 WD3 — the cache KEY descriptor: the plugin's
|
|
1052
|
+
# PluginEntry template composed with an optional
|
|
1053
|
+
# plugin-author-supplied extension carrying IDENTITY inputs
|
|
1054
|
+
# (gem-version pins, `ConfigEntry` rows, configuration-file
|
|
1055
|
+
# digests). The IoBoundary read history deliberately does NOT
|
|
1056
|
+
# enter the key — it is recorded post-compute into the
|
|
1057
|
+
# dependency descriptor instead (see
|
|
1058
|
+
# {#producer_dependency_descriptor}).
|
|
1059
|
+
def compose_key_descriptor(extra)
|
|
1060
|
+
auto_built = Cache::Descriptor.new(plugins: [plugin_entry])
|
|
733
1061
|
return auto_built if extra.nil?
|
|
734
1062
|
|
|
735
1063
|
Cache::Descriptor.compose(auto_built, extra)
|
|
736
1064
|
end
|
|
737
1065
|
|
|
1066
|
+
# ADR-60 WD3 — the dependency descriptor stored beside the
|
|
1067
|
+
# producer's value, built AFTER the block ran so every
|
|
1068
|
+
# in-block `io_boundary` read is captured, plus the evaluated
|
|
1069
|
+
# `watch:` glob rows.
|
|
1070
|
+
#
|
|
1071
|
+
# The boundary snapshot may carry `ConfigEntry` rows (URL
|
|
1072
|
+
# fetches, see {IoBoundary#open_url}). `Descriptor#fresh?`
|
|
1073
|
+
# refuses any non-file/glob slot, so including them makes the
|
|
1074
|
+
# entry permanently stale → the producer recomputes EVERY run.
|
|
1075
|
+
# That is deliberate: it is sound (never stale) and
|
|
1076
|
+
# URL-reading producers are rare; a remote document has no
|
|
1077
|
+
# cheap local re-validation anyway.
|
|
1078
|
+
def producer_dependency_descriptor(producer)
|
|
1079
|
+
boundary = io_boundary.cache_descriptor
|
|
1080
|
+
Cache::Descriptor.new(
|
|
1081
|
+
files: boundary.files,
|
|
1082
|
+
configs: boundary.configs,
|
|
1083
|
+
globs: watch_glob_entries(producer[:watch])
|
|
1084
|
+
)
|
|
1085
|
+
end
|
|
1086
|
+
|
|
1087
|
+
# ADR-60 WD3 — evaluates a producer's `watch:` declaration
|
|
1088
|
+
# into {Cache::Descriptor::GlobEntry} rows. A Proc is
|
|
1089
|
+
# `instance_exec`'d on this plugin instance (so `#init`-built
|
|
1090
|
+
# search roots are in scope); the result — like the static
|
|
1091
|
+
# form — is an Array of `[roots, pattern, ...]` tuples, one
|
|
1092
|
+
# GlobEntry per (root, pattern) pair. Roots are expanded to
|
|
1093
|
+
# absolute paths (matching {#glob_descriptor}) so freshness
|
|
1094
|
+
# re-validation does not depend on the validating process's
|
|
1095
|
+
# working directory.
|
|
1096
|
+
def watch_glob_entries(watch)
|
|
1097
|
+
return [] if watch.nil?
|
|
1098
|
+
|
|
1099
|
+
tuples = watch.respond_to?(:call) ? instance_exec(&watch) : watch
|
|
1100
|
+
Array(tuples).flat_map do |tuple|
|
|
1101
|
+
roots, *patterns = Array(tuple)
|
|
1102
|
+
Array(roots).flat_map do |root|
|
|
1103
|
+
absolute = File.expand_path(root.to_s)
|
|
1104
|
+
patterns.map { |pattern| Cache::Descriptor::GlobEntry.compute(root: absolute, pattern: pattern.to_s) }
|
|
1105
|
+
end
|
|
1106
|
+
end.uniq
|
|
1107
|
+
end
|
|
1108
|
+
|
|
1109
|
+
# ADR-60 WD3 — `fetch_or_validate` stores a
|
|
1110
|
+
# `[value, dependency_descriptor]` pair, but the producer's
|
|
1111
|
+
# declared `serialize:`/`deserialize:` contract covers the
|
|
1112
|
+
# VALUE alone. These wrappers apply the custom callable to the
|
|
1113
|
+
# value half and Marshal the descriptor half, so a producer
|
|
1114
|
+
# with a non-Marshal-clean value keeps working unchanged. A
|
|
1115
|
+
# nil callable returns nil — the store's default whole-pair
|
|
1116
|
+
# Marshal round-trip applies.
|
|
1117
|
+
def pair_serializer(serialize)
|
|
1118
|
+
return nil if serialize.nil?
|
|
1119
|
+
|
|
1120
|
+
lambda do |pair|
|
|
1121
|
+
value, dependency_descriptor = pair
|
|
1122
|
+
Marshal.dump([serialize.call(value).b, Marshal.dump(dependency_descriptor)]).b
|
|
1123
|
+
end
|
|
1124
|
+
end
|
|
1125
|
+
|
|
1126
|
+
def pair_deserializer(deserialize)
|
|
1127
|
+
return nil if deserialize.nil?
|
|
1128
|
+
|
|
1129
|
+
lambda do |bytes|
|
|
1130
|
+
value_bytes, descriptor_bytes = Marshal.load(bytes) # rubocop:disable Security/MarshalLoad
|
|
1131
|
+
[deserialize.call(value_bytes), Marshal.load(descriptor_bytes)] # rubocop:disable Security/MarshalLoad
|
|
1132
|
+
end
|
|
1133
|
+
end
|
|
1134
|
+
|
|
738
1135
|
def digest_config(config)
|
|
739
1136
|
canonical = Cache::Descriptor.canonicalize_value(config || {})
|
|
740
1137
|
Digest::SHA256.hexdigest(JSON.generate(canonical))
|