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.
Files changed (125) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +159 -222
  3. data/lib/rigor/analysis/check_rules/always_truthy_condition_collector.rb +24 -1
  4. data/lib/rigor/analysis/check_rules/dead_assignment_collector.rb +25 -0
  5. data/lib/rigor/analysis/check_rules/ivar_write_collector.rb +29 -0
  6. data/lib/rigor/analysis/check_rules/main_pass_collector.rb +54 -0
  7. data/lib/rigor/analysis/check_rules/rule_walk.rb +213 -0
  8. data/lib/rigor/analysis/check_rules/unreachable_clause_collector.rb +24 -1
  9. data/lib/rigor/analysis/check_rules.rb +275 -44
  10. data/lib/rigor/analysis/diagnostic.rb +8 -0
  11. data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +581 -0
  12. data/lib/rigor/analysis/runner/pool_coordinator.rb +569 -0
  13. data/lib/rigor/analysis/runner/project_pre_passes.rb +321 -0
  14. data/lib/rigor/analysis/runner/run_snapshots.rb +46 -0
  15. data/lib/rigor/analysis/runner.rb +207 -1200
  16. data/lib/rigor/analysis/worker_session.rb +60 -11
  17. data/lib/rigor/bleeding_edge.rb +123 -0
  18. data/lib/rigor/cache/descriptor.rb +86 -8
  19. data/lib/rigor/cache/incremental_snapshot.rb +10 -4
  20. data/lib/rigor/cache/rbs_cache_producer.rb +5 -1
  21. data/lib/rigor/cache/rbs_descriptor.rb +2 -1
  22. data/lib/rigor/cache/store.rb +46 -13
  23. data/lib/rigor/cli/annotate_command.rb +100 -15
  24. data/lib/rigor/cli/check_command.rb +708 -0
  25. data/lib/rigor/cli/ci_detector.rb +94 -0
  26. data/lib/rigor/cli/diagnostic_formats.rb +345 -0
  27. data/lib/rigor/cli/plugins_command.rb +2 -4
  28. data/lib/rigor/cli/plugins_renderer.rb +0 -2
  29. data/lib/rigor/cli/prism_colorizer.rb +10 -3
  30. data/lib/rigor/cli/show_bleedingedge_command.rb +114 -0
  31. data/lib/rigor/cli/trace_command.rb +143 -0
  32. data/lib/rigor/cli/trace_renderer.rb +310 -0
  33. data/lib/rigor/cli/triage_command.rb +6 -3
  34. data/lib/rigor/cli/triage_renderer.rb +15 -1
  35. data/lib/rigor/cli.rb +21 -612
  36. data/lib/rigor/configuration/severity_profile.rb +13 -1
  37. data/lib/rigor/configuration.rb +66 -7
  38. data/lib/rigor/environment/rbs_loader.rb +78 -68
  39. data/lib/rigor/environment.rb +1 -1
  40. data/lib/rigor/inference/acceptance.rb +10 -0
  41. data/lib/rigor/inference/body_fixpoint.rb +89 -0
  42. data/lib/rigor/inference/budget_trace.rb +29 -2
  43. data/lib/rigor/inference/expression_typer.rb +1080 -105
  44. data/lib/rigor/inference/flow_tracer.rb +180 -0
  45. data/lib/rigor/inference/macro_block_self_type.rb +11 -12
  46. data/lib/rigor/inference/method_dispatcher/array_to_h_folding.rb +60 -0
  47. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +54 -14
  48. data/lib/rigor/inference/method_dispatcher/overload_selector.rb +33 -1
  49. data/lib/rigor/inference/method_dispatcher/reduce_folding.rb +281 -0
  50. data/lib/rigor/inference/method_dispatcher/regexp_folding.rb +71 -0
  51. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +148 -10
  52. data/lib/rigor/inference/method_dispatcher.rb +187 -55
  53. data/lib/rigor/inference/method_parameter_binder.rb +56 -2
  54. data/lib/rigor/inference/multi_target_binder.rb +46 -3
  55. data/lib/rigor/inference/mutation_widening.rb +142 -0
  56. data/lib/rigor/inference/narrowing.rb +330 -37
  57. data/lib/rigor/inference/scope_indexer.rb +770 -39
  58. data/lib/rigor/inference/statement_evaluator.rb +998 -68
  59. data/lib/rigor/inference/synthetic_method_scanner.rb +1 -1
  60. data/lib/rigor/plugin/additional_initializer.rb +61 -38
  61. data/lib/rigor/plugin/base.rb +517 -120
  62. data/lib/rigor/plugin/macro/block_as_method.rb +22 -21
  63. data/lib/rigor/plugin/macro/nested_class_template.rb +9 -7
  64. data/lib/rigor/plugin/macro.rb +2 -3
  65. data/lib/rigor/plugin/manifest.rb +4 -24
  66. data/lib/rigor/plugin/node_rule_walk.rb +192 -0
  67. data/lib/rigor/plugin/registry.rb +264 -35
  68. data/lib/rigor/plugin.rb +1 -0
  69. data/lib/rigor/rbs_extended/conformance_checker.rb +86 -1
  70. data/lib/rigor/scope/discovery_index.rb +60 -0
  71. data/lib/rigor/scope.rb +199 -204
  72. data/lib/rigor/sig_gen/generator.rb +8 -0
  73. data/lib/rigor/sig_gen/observation_collector.rb +6 -6
  74. data/lib/rigor/source/literals.rb +14 -0
  75. data/lib/rigor/triage/catalogue.rb +4 -19
  76. data/lib/rigor/triage.rb +69 -1
  77. data/lib/rigor/type/combinator.rb +34 -0
  78. data/lib/rigor/version.rb +1 -1
  79. data/lib/rigor.rb +0 -1
  80. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable.rb +13 -29
  81. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer.rb +13 -32
  82. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/analyzer.rb +1 -2
  83. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +27 -90
  84. data/plugins/rigor-activejob/lib/rigor/plugin/activejob.rb +13 -30
  85. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +2 -4
  86. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +90 -51
  87. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/analyzer.rb +3 -3
  88. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage.rb +25 -29
  89. data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext.rb +1 -1
  90. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_discoverer.rb +1 -2
  91. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot.rb +11 -40
  92. data/plugins/rigor-graphql/lib/rigor/plugin/graphql/type_scanner.rb +2 -2
  93. data/plugins/rigor-mangrove/lib/rigor/plugin/mangrove.rb +1 -1
  94. data/plugins/rigor-pundit/lib/rigor/plugin/pundit.rb +10 -21
  95. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +21 -34
  96. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +11 -18
  97. data/plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline.rb +0 -1
  98. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/let_scope_index.rb +12 -2
  99. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/matcher_analyzer.rb +1 -1
  100. data/plugins/rigor-rspec/lib/rigor/plugin/rspec.rb +37 -31
  101. data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers.rb +3 -23
  102. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq.rb +8 -21
  103. data/plugins/rigor-sinatra/lib/rigor/plugin/sinatra.rb +1 -1
  104. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/absurd_recognizer.rb +8 -29
  105. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/catalog.rb +17 -1
  106. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/sigil_detector.rb +2 -2
  107. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +108 -36
  108. data/sig/rigor/analysis/fact_store.rbs +3 -0
  109. data/sig/rigor/environment.rbs +0 -2
  110. data/sig/rigor/inference/builtins/method_catalog.rbs +1 -1
  111. data/sig/rigor/inference.rbs +5 -0
  112. data/sig/rigor/plugin/base.rbs +6 -4
  113. data/sig/rigor/plugin/manifest.rbs +1 -2
  114. data/sig/rigor/scope.rbs +50 -29
  115. data/sig/rigor/source.rbs +1 -0
  116. data/sig/rigor/type.rbs +1 -0
  117. data/sig/rigor.rbs +1 -1
  118. data/skills/rigor-baseline-reduce/references/01-classify.md +27 -0
  119. data/skills/rigor-ci-setup/SKILL.md +319 -0
  120. data/skills/rigor-plugin-author/SKILL.md +6 -4
  121. data/skills/rigor-plugin-author/references/02-walker-and-types.md +22 -17
  122. data/skills/rigor-project-init/references/03-baseline-and-bugs.md +18 -1
  123. metadata +21 -3
  124. data/lib/rigor/cache/rbs_instance_definitions.rb +0 -66
  125. data/lib/rigor/plugin/macro/external_file.rb +0 -143
@@ -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:` are forwarded verbatim to
78
- # `Cache::Store#fetch_or_compute`. Default round-trip is
79
- # `Marshal.dump` / `Marshal.load` per the v0.0.9 callable
80
- # surface; producers whose return values are not Marshal-
81
- # clean must supply their own pair.
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] = { block: block, serialize: serialize, deserialize: deserialize }.freeze
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 return-type
194
- # contribution, receiver-gated. The narrow successor to the
195
- # `return_type` slot of `flow_contribution_for`:
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`). Method-name and type-shape
205
- # refinement stays in the block, which returns a `Rigor::Type`
206
- # (or `nil` to decline). The block runs through `instance_exec`,
207
- # so `config` / `services` are in scope. This is the
208
- # {.producer}-style class DSL (it carries logic needing the
209
- # instance, not pure data).
210
- def dynamic_return(receivers:, &block)
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
- unless receivers.is_a?(Array) && !receivers.empty? && receivers.all? { |r| r.is_a?(String) && !r.empty? }
213
- raise ArgumentError,
214
- "Plugin::Base.dynamic_return receivers: must be a non-empty Array of class-name Strings, " \
215
- "got #{receivers.inspect}"
216
- end
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 << { receivers: receivers.map { |r| r.dup.freeze }.freeze, block: block }.freeze
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-2 § "Flow Contribution Bundle" / v0.1.1 Track 2
291
- # slice 7 per-call return-type contribution hook. When
292
- # the inference engine dispatches a `Prism::CallNode` and
293
- # neither the precision tiers nor RBS resolve a result,
294
- # `MethodDispatcher` consults each loaded plugin via this
295
- # hook ahead of `RbsDispatch`. Plugins that override the
296
- # default return a {Rigor::FlowContribution} bundle whose
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 = NodeContext.new(ancestors)
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` alongside (and ahead of) the legacy
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[:receivers].any? { |c| class_matches_receiver?(class_name, c, environment) }
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` alongside the legacy
420
- # `flow_contribution_for`; a rule fires only when `call_node.name`
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 performs
534
- # a `Cache::Store#fetch_or_compute` round-trip for the
535
- # named producer. The descriptor (per ADR-7 § "Slice
536
- # 6-B") is auto-assembled from the plugin's
537
- # `PluginEntry` template (id, version, config_hash) and
538
- # the {IoBoundary} read history. The producer id is
539
- # auto-prefixed `plugin.<manifest.id>.` per ADR-7 §
540
- # "Slice 6-C" so plugin caches stay sandboxed from
541
- # built-in producers.
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, ADR-7 § "Slice 6" follow-up)
549
- # supplies extra `Cache::Descriptor` rows the plugin
550
- # author wants to compose into the auto-built descriptor
551
- # typically gem-version `GemEntry`, configuration-file
552
- # `FileEntry` digests, or `ConfigEntry` rows for external
553
- # state the {IoBoundary} cannot capture itself. The
554
- # passed descriptor composes via `Cache::Descriptor.compose`
555
- # with the auto-built one (PluginEntry template + boundary
556
- # reads); per-slot conflicts raise
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
- composed_descriptor = compose_cache_descriptor(descriptor)
848
+ key_descriptor = compose_key_descriptor(descriptor)
572
849
  lambda do
573
- store.fetch_or_compute(
850
+ store.fetch_or_validate(
574
851
  producer_id: prefixed_id,
852
+ key_descriptor: key_descriptor,
575
853
  params: params,
576
- descriptor: composed_descriptor,
577
- serialize: producer[:serialize],
578
- deserialize: producer[:deserialize],
579
- &compute
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
- # Pass the returned descriptor as `cache_for(..., descriptor: …)`
593
- # so the cache key reflects the project files the producer
594
- # reads from. Without it, `Plugin::Base#cache_for`'s
595
- # auto-built descriptor only includes files the
596
- # {Plugin::IoBoundary} has already read in the current
597
- # process empty on the first call of a fresh process so
598
- # the cache key is identical regardless of project state and
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-7 § "Slice 6" follow-up composes the auto-built
725
- # cache descriptor with an optional plugin-author-supplied
726
- # extension. Extra `GemEntry` / `FileEntry` / `ConfigEntry`
727
- # rows the plugin needs (gem-version pins, external
728
- # configuration files, sibling-plugin state) flow through
729
- # `Cache::Descriptor.compose`; the union behaviour matches
730
- # built-in producers (`RbsConstantTable`, `RbsEnvironment`).
731
- def compose_cache_descriptor(extra)
732
- auto_built = build_plugin_cache_descriptor
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))