rigortype 0.1.18 → 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 (89) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +159 -224
  3. data/lib/rigor/analysis/check_rules/always_truthy_condition_collector.rb +9 -3
  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 +169 -23
  8. data/lib/rigor/analysis/check_rules/unreachable_clause_collector.rb +9 -3
  9. data/lib/rigor/analysis/check_rules.rb +266 -63
  10. data/lib/rigor/analysis/diagnostic.rb +8 -0
  11. data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +2 -1
  12. data/lib/rigor/analysis/runner/project_pre_passes.rb +4 -1
  13. data/lib/rigor/analysis/runner.rb +58 -21
  14. data/lib/rigor/analysis/worker_session.rb +21 -11
  15. data/lib/rigor/bleeding_edge.rb +123 -0
  16. data/lib/rigor/cache/descriptor.rb +86 -8
  17. data/lib/rigor/cache/rbs_descriptor.rb +2 -1
  18. data/lib/rigor/cli/annotate_command.rb +100 -15
  19. data/lib/rigor/cli/check_command.rb +3 -0
  20. data/lib/rigor/cli/plugins_command.rb +2 -4
  21. data/lib/rigor/cli/plugins_renderer.rb +0 -2
  22. data/lib/rigor/cli/show_bleedingedge_command.rb +114 -0
  23. data/lib/rigor/cli/triage_command.rb +6 -3
  24. data/lib/rigor/cli/triage_renderer.rb +15 -1
  25. data/lib/rigor/cli.rb +9 -1
  26. data/lib/rigor/configuration/severity_profile.rb +13 -1
  27. data/lib/rigor/configuration.rb +57 -1
  28. data/lib/rigor/environment/rbs_loader.rb +25 -0
  29. data/lib/rigor/inference/body_fixpoint.rb +89 -0
  30. data/lib/rigor/inference/budget_trace.rb +29 -2
  31. data/lib/rigor/inference/expression_typer.rb +1052 -43
  32. data/lib/rigor/inference/macro_block_self_type.rb +2 -2
  33. data/lib/rigor/inference/method_dispatcher/array_to_h_folding.rb +60 -0
  34. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +54 -14
  35. data/lib/rigor/inference/method_dispatcher/reduce_folding.rb +281 -0
  36. data/lib/rigor/inference/method_dispatcher/regexp_folding.rb +71 -0
  37. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +148 -10
  38. data/lib/rigor/inference/method_dispatcher.rb +72 -1
  39. data/lib/rigor/inference/method_parameter_binder.rb +56 -2
  40. data/lib/rigor/inference/multi_target_binder.rb +46 -3
  41. data/lib/rigor/inference/mutation_widening.rb +142 -0
  42. data/lib/rigor/inference/narrowing.rb +270 -37
  43. data/lib/rigor/inference/scope_indexer.rb +696 -25
  44. data/lib/rigor/inference/statement_evaluator.rb +963 -16
  45. data/lib/rigor/inference/synthetic_method_scanner.rb +1 -1
  46. data/lib/rigor/plugin/base.rb +235 -79
  47. data/lib/rigor/plugin/macro/block_as_method.rb +22 -21
  48. data/lib/rigor/plugin/macro/nested_class_template.rb +9 -7
  49. data/lib/rigor/plugin/macro.rb +2 -3
  50. data/lib/rigor/plugin/manifest.rb +4 -24
  51. data/lib/rigor/plugin/node_rule_walk.rb +59 -14
  52. data/lib/rigor/plugin/registry.rb +12 -11
  53. data/lib/rigor/scope/discovery_index.rb +2 -0
  54. data/lib/rigor/scope.rb +132 -6
  55. data/lib/rigor/sig_gen/generator.rb +8 -0
  56. data/lib/rigor/triage/catalogue.rb +4 -19
  57. data/lib/rigor/triage.rb +69 -1
  58. data/lib/rigor/type/combinator.rb +29 -0
  59. data/lib/rigor/version.rb +1 -1
  60. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable.rb +13 -29
  61. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer.rb +13 -32
  62. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +27 -90
  63. data/plugins/rigor-activejob/lib/rigor/plugin/activejob.rb +13 -30
  64. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +20 -19
  65. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage.rb +10 -8
  66. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot.rb +11 -40
  67. data/plugins/rigor-mangrove/lib/rigor/plugin/mangrove.rb +1 -1
  68. data/plugins/rigor-pundit/lib/rigor/plugin/pundit.rb +10 -21
  69. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +21 -34
  70. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +11 -18
  71. data/plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline.rb +0 -1
  72. data/plugins/rigor-rspec/lib/rigor/plugin/rspec.rb +2 -13
  73. data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers.rb +3 -23
  74. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq.rb +8 -21
  75. data/plugins/rigor-sinatra/lib/rigor/plugin/sinatra.rb +1 -1
  76. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +25 -0
  77. data/sig/rigor/analysis/fact_store.rbs +3 -0
  78. data/sig/rigor/inference/builtins/method_catalog.rbs +1 -1
  79. data/sig/rigor/plugin/base.rbs +5 -2
  80. data/sig/rigor/plugin/manifest.rbs +1 -2
  81. data/sig/rigor/scope.rbs +10 -1
  82. data/sig/rigor/type.rbs +1 -0
  83. data/sig/rigor.rbs +1 -1
  84. data/skills/rigor-baseline-reduce/references/01-classify.md +27 -0
  85. data/skills/rigor-plugin-author/SKILL.md +6 -4
  86. data/skills/rigor-plugin-author/references/02-walker-and-types.md +22 -17
  87. data/skills/rigor-project-init/references/03-baseline-and-bugs.md +18 -1
  88. metadata +7 -2
  89. data/lib/rigor/plugin/macro/external_file.rb +0 -143
@@ -228,7 +228,7 @@ module Rigor
228
228
  return unless call.is_a?(Prism::CallNode) && call.receiver.nil? && call.name == template.variant_method
229
229
 
230
230
  args = call.arguments&.arguments || []
231
- variant_const = const_name_string(args[template.name_arg_position])
231
+ variant_const = const_name_string(args[template.symbol_arg_position])
232
232
  return if variant_const.nil?
233
233
 
234
234
  yield variant_const, args[template.inner_arg_position]
@@ -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
@@ -445,6 +481,13 @@ module Rigor
445
481
  # memo-on-first-dispatch is a Hash-content mutation, sound even on
446
482
  # a self-freezing plugin.
447
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 = {}
448
491
  end
449
492
 
450
493
  # Override in subclasses to wire any state the plugin needs
@@ -625,6 +668,69 @@ module Rigor
625
668
  )
626
669
  end
627
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
+
628
734
  # Boilerplate-reduction helper (review §1.3): the "did you mean …?"
629
735
  # suggestion every diagnostic-emitting plugin otherwise hand-rolls.
630
736
  # Returns the closest of `candidates` to `name` via
@@ -695,32 +801,38 @@ module Rigor
695
801
  @io_boundary ||= services.io_boundary_for(manifest.id)
696
802
  end
697
803
 
698
- # ADR-7 § "Slice 6-A" — returns a callable that performs
699
- # a `Cache::Store#fetch_or_compute` round-trip for the
700
- # named producer. The descriptor (per ADR-7 § "Slice
701
- # 6-B") is auto-assembled from the plugin's
702
- # `PluginEntry` template (id, version, config_hash) and
703
- # the {IoBoundary} read history. The producer id is
704
- # auto-prefixed `plugin.<manifest.id>.` per ADR-7 §
705
- # "Slice 6-C" so plugin caches stay sandboxed from
706
- # 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.
707
821
  #
708
822
  # When `services.cache_store` is `nil` (e.g. CLI
709
823
  # `--no-cache`), the callable bypasses the cache and
710
824
  # runs the producer block every time — same semantics
711
825
  # as the v0.0.9 cache surface for built-in producers.
712
826
  #
713
- # `descriptor:` (optional, ADR-7 § "Slice 6" follow-up)
714
- # supplies extra `Cache::Descriptor` rows the plugin
715
- # author wants to compose into the auto-built descriptor
716
- # typically gem-version `GemEntry`, configuration-file
717
- # `FileEntry` digests, or `ConfigEntry` rows for external
718
- # state the {IoBoundary} cannot capture itself. The
719
- # passed descriptor composes via `Cache::Descriptor.compose`
720
- # with the auto-built one (PluginEntry template + boundary
721
- # reads); per-slot conflicts raise
722
- # `Cache::Descriptor::Conflict` to make divergent inputs
723
- # 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.
724
836
  def cache_for(producer_id, params: {}, descriptor: nil)
725
837
  producer = self.class.producers[producer_id.to_sym]
726
838
  unless producer
@@ -733,16 +845,18 @@ module Rigor
733
845
  return compute unless store
734
846
 
735
847
  prefixed_id = "plugin.#{manifest.id}.#{producer_id}"
736
- composed_descriptor = compose_cache_descriptor(descriptor)
848
+ key_descriptor = compose_key_descriptor(descriptor)
737
849
  lambda do
738
- store.fetch_or_compute(
850
+ store.fetch_or_validate(
739
851
  producer_id: prefixed_id,
852
+ key_descriptor: key_descriptor,
740
853
  params: params,
741
- descriptor: composed_descriptor,
742
- serialize: producer[:serialize],
743
- deserialize: producer[:deserialize],
744
- &compute
745
- )
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
746
860
  end
747
861
  end
748
862
 
@@ -754,31 +868,13 @@ module Rigor
754
868
  # descriptor), or any removal (the previously-matched file
755
869
  # drops out).
756
870
  #
757
- # Pass the returned descriptor as `cache_for(..., descriptor: …)`
758
- # so the cache key reflects the project files the producer
759
- # reads from. Without it, `Plugin::Base#cache_for`'s
760
- # auto-built descriptor only includes files the
761
- # {Plugin::IoBoundary} has already read in the current
762
- # process empty on the first call of a fresh process so
763
- # the cache key is identical regardless of project state and
764
- # warm runs return stale producer output when files have
765
- # changed between sessions.
766
- #
767
- # Discovery-style producers (`actioncable`'s `:channel_index`,
768
- # `actionmailer`'s `:mailer_index`, `rails-i18n`'s
769
- # `:locale_index`) all follow the same pattern: walk a glob
770
- # under one or more search roots, parse / read every match,
771
- # build a typed index. They MUST call this helper at the
772
- # `cache_for(descriptor: …)` site to be cache-correct under
773
- # the persistent `Cache::Store` `rigor check` uses by
774
- # default.
775
- #
776
- # The helper pays one SHA-256 read per matched file at
777
- # call time; the producer block typically re-reads through
778
- # `io_boundary.read_file` so the cost is doubled. For
779
- # discovery globs in the 10-100 file range this is
780
- # negligible (~ms) relative to the parse + walk the
781
- # 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:`.
782
878
  #
783
879
  # @param roots [Array<String>] search roots (relative to
784
880
  # the project root, or absolute paths)
@@ -798,6 +894,7 @@ module Rigor
798
894
  end
799
895
  Cache::Descriptor.new(files: entries)
800
896
  end
897
+ private :glob_descriptor
801
898
 
802
899
  private
803
900
 
@@ -923,17 +1020,6 @@ module Rigor
923
1020
  matched.uniq.sort.select { |path| File.file?(path) }
924
1021
  end
925
1022
 
926
- # ADR-7 § "Slice 6-B" — composes the per-call cache
927
- # descriptor from (1) the plugin's PluginEntry template
928
- # and (2) the IoBoundary's accumulated FileEntry rows.
929
- def build_plugin_cache_descriptor
930
- boundary_descriptor = io_boundary.cache_descriptor
931
- Cache::Descriptor.new(
932
- plugins: [plugin_entry],
933
- files: boundary_descriptor.files
934
- )
935
- end
936
-
937
1023
  public
938
1024
 
939
1025
  # ADR-32 WD5 — the `Cache::Descriptor::PluginEntry`
@@ -962,20 +1048,90 @@ module Rigor
962
1048
 
963
1049
  private
964
1050
 
965
- # ADR-7 § "Slice 6" follow-up composes the auto-built
966
- # cache descriptor with an optional plugin-author-supplied
967
- # extension. Extra `GemEntry` / `FileEntry` / `ConfigEntry`
968
- # rows the plugin needs (gem-version pins, external
969
- # configuration files, sibling-plugin state) flow through
970
- # `Cache::Descriptor.compose`; the union behaviour matches
971
- # built-in producers (`RbsConstantTable`, `RbsEnvironment`).
972
- def compose_cache_descriptor(extra)
973
- 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])
974
1061
  return auto_built if extra.nil?
975
1062
 
976
1063
  Cache::Descriptor.compose(auto_built, extra)
977
1064
  end
978
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
+
979
1135
  def digest_config(config)
980
1136
  canonical = Cache::Descriptor.canonicalize_value(config || {})
981
1137
  Digest::SHA256.hexdigest(JSON.generate(canonical))
@@ -4,9 +4,9 @@ module Rigor
4
4
  module Plugin
5
5
  module Macro
6
6
  # ADR-16 Tier A declaration: "the block passed to a
7
- # class-level DSL call of one of `verbs` runs as an instance
8
- # method on `receiver_constraint`'s subclass tree, with
9
- # `self` typed accordingly."
7
+ # class-level DSL call of one of `method_names` runs as an
8
+ # instance method on `receiver_constraint`'s subclass tree,
9
+ # with `self` typed accordingly."
10
10
  #
11
11
  # Authored on a plugin manifest:
12
12
  #
@@ -16,7 +16,7 @@ module Rigor
16
16
  # block_as_methods: [
17
17
  # Rigor::Plugin::Macro::BlockAsMethod.new(
18
18
  # receiver_constraint: "Sinatra::Base",
19
- # verbs: %i[get post put delete head options patch link unlink]
19
+ # method_names: %i[get post put delete head options patch link unlink]
20
20
  # )
21
21
  # ]
22
22
  # )
@@ -41,9 +41,10 @@ module Rigor
41
41
  # for the entry to fire. For Sinatra modular-style this is
42
42
  # `"Sinatra::Base"`; the substrate's class-context match
43
43
  # accepts every subclass.
44
- # - `verbs` — Array of Symbol method names. A call shape
44
+ # - `method_names` — Array of Symbol method names. A call shape
45
45
  # `<receiver_subclass>.get('/path') { ... }` matches when
46
- # `:get` is in this list.
46
+ # `:get` is in this list. (Named `verbs:` before ADR-60 WD2
47
+ # normalised the macro value-object vocabulary.)
47
48
  # - `self_type` — Symbol selecting the kind of `self`-binding
48
49
  # the substrate applies inside the block. Slice 1a accepts
49
50
  # only `:receiver_instance` (the block runs as an instance
@@ -53,22 +54,22 @@ module Rigor
53
54
  # ## Ractor-shareability
54
55
  #
55
56
  # All fields are frozen at construction (ADR-15 Phase 1).
56
- # `verbs` is dup-frozen so the caller's mutable array does
57
- # not leak into the value. `Ractor.shareable?` returns true
58
- # after `#initialize`.
57
+ # `method_names` is dup-frozen so the caller's mutable array
58
+ # does not leak into the value. `Ractor.shareable?` returns
59
+ # true after `#initialize`.
59
60
  class BlockAsMethod
60
61
  SELF_TYPE_RECEIVER_INSTANCE = :receiver_instance
61
62
  VALID_SELF_TYPES = [SELF_TYPE_RECEIVER_INSTANCE].freeze
62
63
 
63
- attr_reader :receiver_constraint, :verbs, :self_type
64
+ attr_reader :receiver_constraint, :method_names, :self_type
64
65
 
65
- def initialize(receiver_constraint:, verbs:, self_type: SELF_TYPE_RECEIVER_INSTANCE)
66
+ def initialize(receiver_constraint:, method_names:, self_type: SELF_TYPE_RECEIVER_INSTANCE)
66
67
  validate_receiver_constraint!(receiver_constraint)
67
- validate_verbs!(verbs)
68
+ validate_method_names!(method_names)
68
69
  validate_self_type!(self_type)
69
70
 
70
71
  @receiver_constraint = receiver_constraint.dup.freeze
71
- @verbs = verbs.map(&:to_sym).freeze
72
+ @method_names = method_names.map(&:to_sym).freeze
72
73
  @self_type = self_type
73
74
  freeze
74
75
  end
@@ -76,7 +77,7 @@ module Rigor
76
77
  def to_h
77
78
  {
78
79
  "receiver_constraint" => receiver_constraint,
79
- "verbs" => verbs.map(&:to_s),
80
+ "method_names" => method_names.map(&:to_s),
80
81
  "self_type" => self_type.to_s
81
82
  }
82
83
  end
@@ -84,13 +85,13 @@ module Rigor
84
85
  def ==(other)
85
86
  other.is_a?(BlockAsMethod) &&
86
87
  receiver_constraint == other.receiver_constraint &&
87
- verbs == other.verbs &&
88
+ method_names == other.method_names &&
88
89
  self_type == other.self_type
89
90
  end
90
91
  alias eql? ==
91
92
 
92
93
  def hash
93
- [receiver_constraint, verbs, self_type].hash
94
+ [receiver_constraint, method_names, self_type].hash
94
95
  end
95
96
 
96
97
  private
@@ -103,17 +104,17 @@ module Rigor
103
104
  "got #{value.inspect}"
104
105
  end
105
106
 
106
- def validate_verbs!(verbs)
107
- unless verbs.is_a?(Array) && !verbs.empty?
107
+ def validate_method_names!(method_names)
108
+ unless method_names.is_a?(Array) && !method_names.empty?
108
109
  raise ArgumentError,
109
- "Plugin::Macro::BlockAsMethod#verbs must be a non-empty Array, got #{verbs.inspect}"
110
+ "Plugin::Macro::BlockAsMethod#method_names must be a non-empty Array, got #{method_names.inspect}"
110
111
  end
111
112
 
112
- verbs.each do |v|
113
+ method_names.each do |v|
113
114
  next if v.is_a?(Symbol) || (v.is_a?(String) && !v.empty?)
114
115
 
115
116
  raise ArgumentError,
116
- "Plugin::Macro::BlockAsMethod#verbs entries must be Symbol/non-empty String, " \
117
+ "Plugin::Macro::BlockAsMethod#method_names entries must be Symbol/non-empty String, " \
117
118
  "got #{v.inspect}"
118
119
  end
119
120
  end
@@ -33,7 +33,7 @@ module Rigor
33
33
  # receiver_constraint: "Mangrove::Enum", # `extend`-ed marker module
34
34
  # block_method: :variants, # the enclosing DSL block
35
35
  # variant_method: :variant, # each declaration call
36
- # name_arg_position: 0, # constant arg → nested class
36
+ # symbol_arg_position: 0, # constant arg → nested class
37
37
  # inner_arg_position: 1, # type arg → `#inner` return
38
38
  # inner_reader: :inner # the payload reader name
39
39
  # )
@@ -48,8 +48,10 @@ module Rigor
48
48
  # (`:variants`).
49
49
  # - `variant_method` — Symbol naming each declaration call
50
50
  # inside the block (`:variant`).
51
- # - `name_arg_position` — Integer (default 0): the argument
51
+ # - `symbol_arg_position` — Integer (default 0): the argument
52
52
  # index whose literal **constant** names the nested subclass.
53
+ # (Named `name_arg_position:` before ADR-60 WD2 normalised
54
+ # the macro value-object vocabulary.)
53
55
  # - `inner_arg_position` — Integer (default 1): the argument
54
56
  # index whose type expression becomes the `#inner` reader's
55
57
  # return type. Slice A resolves a constant type argument
@@ -69,21 +71,21 @@ module Rigor
69
71
  # `Environment#class_ordering`.
70
72
  class NestedClassTemplate
71
73
  attr_reader :receiver_constraint, :block_method, :variant_method,
72
- :name_arg_position, :inner_arg_position, :inner_reader
74
+ :symbol_arg_position, :inner_arg_position, :inner_reader
73
75
 
74
76
  def initialize(receiver_constraint:, block_method: :variants, variant_method: :variant,
75
- name_arg_position: 0, inner_arg_position: 1, inner_reader: :inner)
77
+ symbol_arg_position: 0, inner_arg_position: 1, inner_reader: :inner)
76
78
  validate_constraint!(receiver_constraint)
77
79
  validate_method!(block_method, "block_method")
78
80
  validate_method!(variant_method, "variant_method")
79
- validate_position!(name_arg_position, "name_arg_position")
81
+ validate_position!(symbol_arg_position, "symbol_arg_position")
80
82
  validate_position!(inner_arg_position, "inner_arg_position")
81
83
  validate_method!(inner_reader, "inner_reader")
82
84
 
83
85
  @receiver_constraint = receiver_constraint.dup.freeze
84
86
  @block_method = block_method.to_sym
85
87
  @variant_method = variant_method.to_sym
86
- @name_arg_position = name_arg_position
88
+ @symbol_arg_position = symbol_arg_position
87
89
  @inner_arg_position = inner_arg_position
88
90
  @inner_reader = inner_reader.to_sym
89
91
  freeze
@@ -94,7 +96,7 @@ module Rigor
94
96
  "receiver_constraint" => receiver_constraint,
95
97
  "block_method" => block_method.to_s,
96
98
  "variant_method" => variant_method.to_s,
97
- "name_arg_position" => name_arg_position,
99
+ "symbol_arg_position" => symbol_arg_position,
98
100
  "inner_arg_position" => inner_arg_position,
99
101
  "inner_reader" => inner_reader.to_s
100
102
  }
@@ -1,7 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "macro/block_as_method"
4
- require_relative "macro/external_file"
5
4
  require_relative "macro/heredoc_template"
6
5
  require_relative "macro/nested_class_template"
7
6
  require_relative "macro/trait_registry"
@@ -11,8 +10,8 @@ module Rigor
11
10
  # Substrate declarations for the macro / DSL expansion tiers
12
11
  # introduced by ADR-16. Plugin authors declare entries under
13
12
  # `Plugin::Manifest` slots (`block_as_methods:`,
14
- # `trait_registries:`, `heredoc_macros:`,
15
- # `external_file_inclusions:`) and the substrate consumes them
13
+ # `trait_registries:`, `heredoc_templates:`,
14
+ # `nested_class_templates:`) and the substrate consumes them
16
15
  # to recognise the call shapes a library exposes to its users.
17
16
  #
18
17
  # Slice 1a (this file's first delivery) ships the Tier A value