rigortype 0.0.6 → 0.0.8

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 0af88abcc3dd912fd5c9d22440dc456b0418d6bd0de2cae58167f8538880c1a0
4
- data.tar.gz: 2ef580ca7c24007313fd4b30e4e2f0dc1cd59e68ccdc4113f3fe060694880782
3
+ metadata.gz: 38aa66f97f5bed742a36c156ceee7f13d3181597b74c32329b693b3c219852b3
4
+ data.tar.gz: 69af0ccbf42c2b890b78a558d574b49293ff6b4880875568137098624e66d3f8
5
5
  SHA512:
6
- metadata.gz: 46bd06614cdbf530ada69f28fd5be8620b5e54d834c8af69663fdf0949f0592a7123e0f564d924046dbc9b3d9d04ffec96af593825c6b84cafebf1a6b1a60431
7
- data.tar.gz: df1f86d712a8a081356917989a4c98c775180eb805d54300b671d652a2d87eee86a7624d9b8812f2e7a4b3b26364a668948aa8aa06481c53295908428ab0d4ae
6
+ metadata.gz: 8a9e0ee0461e2f0b2779981fa95758e0b7b5bd02668254dc1cb2d82002565b383d866534edff6e75c53aa0a9ae2fdb59832a35788ccfda3cb4558982bed61b15
7
+ data.tar.gz: 9699b63098d16e1959177684a849923384196f0dac55a532a3a0ecdff77d45c76230c3b72a273f59ec237059127007d7bb1ae01be9c9cf8b9613a02033b89bb5
@@ -2,6 +2,7 @@
2
2
 
3
3
  require "prism"
4
4
 
5
+ require_relative "../reflection"
5
6
  require_relative "../source/node_walker"
6
7
  require_relative "../type"
7
8
  require_relative "diagnostic"
@@ -172,9 +173,7 @@ module Rigor
172
173
  kind = receiver_type.is_a?(Type::Singleton) ? :singleton : :instance
173
174
  return nil if scope.discovered_method?(class_name, call_node.name, kind)
174
175
 
175
- loader = scope.environment.rbs_loader
176
- return nil if loader.nil?
177
- return nil unless loader.class_known?(class_name)
176
+ return nil unless Rigor::Reflection.rbs_class_known?(class_name, scope: scope)
178
177
 
179
178
  # When the loader cannot build a class definition for a
180
179
  # name it nominally knows (constant-decl aliases such
@@ -182,9 +181,9 @@ module Rigor
182
181
  # malformed signatures), we cannot enumerate methods
183
182
  # so we MUST NOT emit a false positive. Skip the rule
184
183
  # in that case.
185
- return nil unless definition_available?(loader, receiver_type, class_name)
184
+ return nil unless definition_available?(receiver_type, class_name, scope)
186
185
 
187
- method_def = lookup_method(loader, receiver_type, class_name, call_node.name)
186
+ method_def = lookup_method(receiver_type, class_name, call_node.name, scope)
188
187
  return nil if method_def
189
188
 
190
189
  build_undefined_method_diagnostic(path, call_node, receiver_type)
@@ -219,27 +218,29 @@ module Rigor
219
218
  nil
220
219
  end
221
220
 
222
- def definition_available?(loader, receiver_type, class_name)
221
+ def definition_available?(receiver_type, class_name, scope)
223
222
  if receiver_type.is_a?(Type::Singleton)
224
- !loader.singleton_definition(class_name).nil?
223
+ !Rigor::Reflection.singleton_definition(class_name, scope: scope).nil?
225
224
  else
226
- !loader.instance_definition(class_name).nil?
225
+ !Rigor::Reflection.instance_definition(class_name, scope: scope).nil?
227
226
  end
228
- rescue StandardError
229
- false
230
227
  end
231
228
 
232
- def lookup_method(loader, receiver_type, class_name, method_name)
229
+ def lookup_method(receiver_type, class_name, method_name, scope)
233
230
  if receiver_type.is_a?(Type::Singleton)
234
- loader.singleton_method(class_name: class_name, method_name: method_name)
231
+ Rigor::Reflection.singleton_method_definition(class_name, method_name, scope: scope)
235
232
  else
236
- loader.instance_method(class_name: class_name, method_name: method_name)
233
+ Rigor::Reflection.instance_method_definition(class_name, method_name, scope: scope)
237
234
  end
238
235
  rescue StandardError
239
- # The loader is best-effort and may raise on malformed
240
- # RBS. Treat any failure as "method exists" so we do
241
- # NOT emit a false positive when our knowledge of the
242
- # receiver class is structurally incomplete.
236
+ # The Reflection facade catches loader exceptions and
237
+ # returns nil. The wrapper here treats failures as
238
+ # "method exists" so we do NOT emit a false positive
239
+ # when our knowledge of the receiver class is
240
+ # structurally incomplete (Reflection's own rescue
241
+ # already returns nil; this catch is a defensive
242
+ # double-net for any future call shape that might
243
+ # raise).
243
244
  true
244
245
  end
245
246
 
@@ -271,12 +272,10 @@ module Rigor
271
272
  kind = receiver_type.is_a?(Type::Singleton) ? :singleton : :instance
272
273
  return nil if scope.discovered_method?(class_name, call_node.name, kind)
273
274
 
274
- loader = scope.environment.rbs_loader
275
- return nil if loader.nil?
276
- return nil unless loader.class_known?(class_name)
277
- return nil unless definition_available?(loader, receiver_type, class_name)
275
+ return nil unless Rigor::Reflection.rbs_class_known?(class_name, scope: scope)
276
+ return nil unless definition_available?(receiver_type, class_name, scope)
278
277
 
279
- method_def = lookup_method(loader, receiver_type, class_name, call_node.name)
278
+ method_def = lookup_method(receiver_type, class_name, call_node.name, scope)
280
279
  return nil if method_def.nil? || method_def == true
281
280
 
282
281
  arity_envelope = compute_arity_envelope(method_def)
@@ -383,12 +382,14 @@ module Rigor
383
382
  receiver_type = scope.type_of(call_node.receiver)
384
383
  return nil unless receiver_type.is_a?(Type::Union)
385
384
 
386
- loader = scope.environment.rbs_loader
387
- return nil if loader.nil?
385
+ # The rule only fires when the analyzer has access to
386
+ # an RBS loader; without it, the per-member method-
387
+ # presence checks below cannot rule out a sound call.
388
+ return nil unless Rigor::Reflection.rbs_class_known?("NilClass", scope: scope)
388
389
 
389
390
  return nil unless union_contains_nil?(receiver_type)
390
- return nil unless union_method_present_on_non_nil?(receiver_type, call_node.name, loader, scope)
391
- return nil if nil_class_has_method?(call_node.name, loader)
391
+ return nil unless union_method_present_on_non_nil?(receiver_type, call_node.name, scope)
392
+ return nil if nil_class_has_method?(call_node.name, scope)
392
393
 
393
394
  build_nil_receiver_diagnostic(path, call_node)
394
395
  end
@@ -409,27 +410,25 @@ module Rigor
409
410
  # that are unsound on the non-nil branch — that is the
410
411
  # `undefined_method_diagnostic` rule's job, and we want
411
412
  # exactly one diagnostic per offending call site.
412
- def union_method_present_on_non_nil?(union, method_name, loader, scope)
413
+ def union_method_present_on_non_nil?(union, method_name, scope)
413
414
  non_nil_members = union.members.reject { |m| nil_member?(m) }
414
415
  return false if non_nil_members.empty?
415
416
 
416
- non_nil_members.all? { |m| method_present_anywhere?(m, method_name, loader, scope) }
417
+ non_nil_members.all? { |m| method_present_anywhere?(m, method_name, scope) }
417
418
  end
418
419
 
419
- def method_present_anywhere?(member, method_name, loader, scope)
420
+ def method_present_anywhere?(member, method_name, scope)
420
421
  class_name = concrete_class_name(member)
421
422
  return true if class_name.nil? # Dynamic / Top / Bot — be permissive.
422
423
  return true if scope.discovered_method?(class_name, method_name, :instance)
423
- return true unless loader.class_known?(class_name)
424
- return true unless definition_available?(loader, member, class_name)
424
+ return true unless Rigor::Reflection.rbs_class_known?(class_name, scope: scope)
425
+ return true unless definition_available?(member, class_name, scope)
425
426
 
426
- !lookup_method(loader, member, class_name, method_name).nil?
427
+ !lookup_method(member, class_name, method_name, scope).nil?
427
428
  end
428
429
 
429
- def nil_class_has_method?(method_name, loader)
430
- return false unless loader.class_known?("NilClass")
431
-
432
- definition = loader.instance_definition("NilClass")
430
+ def nil_class_has_method?(method_name, scope)
431
+ definition = Rigor::Reflection.instance_definition("NilClass", scope: scope)
433
432
  return false if definition.nil?
434
433
 
435
434
  !definition.methods[method_name.to_sym].nil?
@@ -684,12 +683,10 @@ module Rigor
684
683
  # supplies BOTH a `def` and an RBS sig, the sig is
685
684
  # the authoritative parameter contract and we
686
685
  # should validate calls against it.
687
- loader = scope.environment.rbs_loader
688
- return nil if loader.nil?
689
- return nil unless loader.class_known?(class_name)
690
- return nil unless definition_available?(loader, receiver_type, class_name)
686
+ return nil unless Rigor::Reflection.rbs_class_known?(class_name, scope: scope)
687
+ return nil unless definition_available?(receiver_type, class_name, scope)
691
688
 
692
- method_def = lookup_method(loader, receiver_type, class_name, call_node.name)
689
+ method_def = lookup_method(receiver_type, class_name, call_node.name, scope)
693
690
  return nil if method_def.nil? || method_def == true
694
691
  return nil unless method_def.method_types.size == 1
695
692
 
@@ -3,7 +3,12 @@
3
3
  module Rigor
4
4
  module Analysis
5
5
  class Diagnostic
6
- attr_reader :path, :line, :column, :message, :severity, :rule
6
+ # The default source family. Matches the existing analyzer-
7
+ # internal rule families; serialised as `"builtin"` and is the
8
+ # baseline against which non-default families are recognised.
9
+ DEFAULT_SOURCE_FAMILY = :builtin
10
+
11
+ attr_reader :path, :line, :column, :message, :severity, :rule, :source_family
7
12
 
8
13
  # `rule:` is the stable identifier (a kebab-case string)
9
14
  # of the diagnostic's source rule. It is used by the
@@ -12,8 +17,16 @@ module Rigor
12
17
  # category. Diagnostics not produced by `CheckRules`
13
18
  # (parse errors, path errors, internal analyzer errors)
14
19
  # may leave `rule` as nil and stay unsuppressible.
20
+ #
21
+ # `source_family:` names the producer of the rule. The default
22
+ # `:builtin` covers analyzer-internal rules; future families
23
+ # like `:rbs_extended`, `:generated`, or `"plugin.<id>"` (per
24
+ # ADR-2 § "Plugin Diagnostic Provenance") let consumers
25
+ # distinguish where a diagnostic originated without committing
26
+ # to the plugin API itself.
15
27
  # rubocop:disable Metrics/ParameterLists
16
- def initialize(path:, line:, column:, message:, severity: :error, rule: nil)
28
+ def initialize(path:, line:, column:, message:, severity: :error, rule: nil,
29
+ source_family: DEFAULT_SOURCE_FAMILY)
17
30
  # rubocop:enable Metrics/ParameterLists
18
31
  @path = path
19
32
  @line = line
@@ -21,12 +34,24 @@ module Rigor
21
34
  @message = message
22
35
  @severity = severity
23
36
  @rule = rule
37
+ @source_family = source_family
24
38
  end
25
39
 
26
40
  def error?
27
41
  severity == :error
28
42
  end
29
43
 
44
+ # The fully-qualified rule identifier — `<source_family>.<rule>`
45
+ # when the source is non-default, or just `<rule>` for the
46
+ # `:builtin` family. Returns nil when `rule` itself is nil
47
+ # (e.g. parse errors and internal-analyzer errors).
48
+ def qualified_rule
49
+ return nil if rule.nil?
50
+ return rule if source_family == DEFAULT_SOURCE_FAMILY
51
+
52
+ "#{source_family}.#{rule}"
53
+ end
54
+
30
55
  def to_h
31
56
  {
32
57
  "path" => path,
@@ -34,6 +59,7 @@ module Rigor
34
59
  "column" => column,
35
60
  "severity" => severity.to_s,
36
61
  "rule" => rule,
62
+ "source_family" => source_family.to_s,
37
63
  "message" => message
38
64
  }
39
65
  end
@@ -78,6 +78,41 @@ module Rigor
78
78
  return nil unless args.size == 2
79
79
 
80
80
  Type::Combinator.non_empty_hash(args[0], args[1])
81
+ },
82
+ # v0.0.7 — `key_of[T]` and `value_of[T]` type functions.
83
+ # Each takes a single type argument and projects the
84
+ # known-keys (resp. known-values) union out of `T`. See
85
+ # `Type::Combinator.key_of` for the per-shape projection
86
+ # rules. Use `lower_snake` per the
87
+ # imported-built-in-types.md type-function naming rule.
88
+ "key_of" => lambda { |args|
89
+ return nil unless args.size == 1
90
+
91
+ Type::Combinator.key_of(args.first)
92
+ },
93
+ "value_of" => lambda { |args|
94
+ return nil unless args.size == 1
95
+
96
+ Type::Combinator.value_of(args.first)
97
+ },
98
+ # `int_mask[1, 2, 4]` — every integer representable by
99
+ # a bitwise OR over the listed flags. Each arg must be a
100
+ # `Constant<Integer>`; the parser wraps integer literals
101
+ # for this purpose. Builder declines on any non-integer
102
+ # arg.
103
+ "int_mask" => lambda { |args|
104
+ flags = args.map { |arg| arg.is_a?(Type::Constant) && arg.value.is_a?(Integer) ? arg.value : nil }
105
+ return nil if flags.any?(&:nil?)
106
+
107
+ Type::Combinator.int_mask(flags)
108
+ },
109
+ # `int_mask_of[T]` — derives the closure from a finite
110
+ # integer literal type (single Constant<Integer> or a
111
+ # Union of them).
112
+ "int_mask_of" => lambda { |args|
113
+ return nil unless args.size == 1
114
+
115
+ Type::Combinator.int_mask_of(args.first)
81
116
  }
82
117
  }.freeze
83
118
  private_constant :PARAMETERISED_TYPE_BUILDERS
@@ -145,7 +180,7 @@ module Rigor
145
180
  # soft (returns `nil` from `parse`) on any deviation so the
146
181
  # `RBS::Extended` directive site can fall back to the
147
182
  # RBS-declared type rather than crash on a typo.
148
- class Parser
183
+ class Parser # rubocop:disable Metrics/ClassLength
149
184
  def initialize(input)
150
185
  @scanner = StringScanner.new(input.strip)
151
186
  end
@@ -153,6 +188,12 @@ module Rigor
153
188
  def parse
154
189
  type = parse_type
155
190
  return nil if type.nil?
191
+
192
+ # v0.0.7 — trailing `[K]` indexed-access projects
193
+ # into the parsed type. Multiple `[K]` segments
194
+ # chain (`Tuple[A, B, C][1][0]`).
195
+ type = parse_indexed_access_chain(type)
196
+ return nil if type.nil?
156
197
  return nil unless @scanner.eos?
157
198
 
158
199
  type
@@ -160,12 +201,21 @@ module Rigor
160
201
 
161
202
  private
162
203
 
163
- SIMPLE_NAME = /[a-z][a-z0-9-]*/
204
+ # Refinement names use kebab-case (`non-empty-string`),
205
+ # type-function names use lower_snake (`key_of`,
206
+ # `value_of`, `int_mask`). The regex accepts both shapes;
207
+ # the registry lookup decides which family the name
208
+ # belongs to.
209
+ SIMPLE_NAME = /[a-z][a-z0-9_-]*/
164
210
  CLASS_NAME = /[A-Z][A-Za-z0-9_]*(?:::[A-Z][A-Za-z0-9_]*)*/
165
211
  SIGNED_INT = /-?\d+/
166
212
  private_constant :SIMPLE_NAME, :CLASS_NAME, :SIGNED_INT
167
213
 
168
214
  def parse_type
215
+ if (class_name = @scanner.scan(CLASS_NAME))
216
+ return parse_class_arg_tail(class_name)
217
+ end
218
+
169
219
  name = @scanner.scan(SIMPLE_NAME)
170
220
  return nil if name.nil?
171
221
 
@@ -176,6 +226,25 @@ module Rigor
176
226
  end
177
227
  end
178
228
 
229
+ # `T[K]` — keep applying `[K]` indexes until no more
230
+ # opening brackets are present. Each index consumes one
231
+ # type argument; multi-arg `[K1, K2]` fails (the spec
232
+ # specifies a single key).
233
+ def parse_indexed_access_chain(type)
234
+ loop do
235
+ skip_ws
236
+ break unless @scanner.peek(1) == "["
237
+
238
+ @scanner.getch
239
+ args = parse_type_arg_list
240
+ return nil if args.nil? || args.size != 1
241
+ return nil unless @scanner.getch == "]"
242
+
243
+ type = Type::Combinator.indexed_access(type, args.first)
244
+ end
245
+ type
246
+ end
247
+
179
248
  def parse_parametric_type_args(name)
180
249
  builder = PARAMETERISED_TYPE_BUILDERS[name]
181
250
  return nil if builder.nil?
@@ -227,12 +296,33 @@ module Rigor
227
296
  def parse_type_arg
228
297
  skip_ws
229
298
  if (class_name = @scanner.scan(CLASS_NAME))
230
- Type::Combinator.nominal_of(class_name)
299
+ parse_class_arg_tail(class_name)
300
+ elsif (literal = @scanner.scan(SIGNED_INT))
301
+ # Integer-literal arg, used by `int_mask[1, 2, 4]`.
302
+ # Wrapped as `Constant<Integer>` so type-arg builders
303
+ # see a uniform `Array<Type::t>`.
304
+ Type::Combinator.constant_of(Integer(literal))
231
305
  else
232
306
  parse_type
233
307
  end
234
308
  end
235
309
 
310
+ # Class-name-headed type argument with optional `[T_1,
311
+ # …]` type-args tail. Used so `key_of[Hash[Symbol,
312
+ # Integer]]` parses as the projection of a parameterised
313
+ # nominal carrier rather than rejecting the inner
314
+ # brackets.
315
+ def parse_class_arg_tail(class_name)
316
+ return Type::Combinator.nominal_of(class_name) unless @scanner.peek(1) == "["
317
+
318
+ @scanner.getch # consume '['
319
+ args = parse_type_arg_list
320
+ return nil if args.nil?
321
+ return nil unless @scanner.getch == "]"
322
+
323
+ Type::Combinator.nominal_of(class_name, type_args: args)
324
+ end
325
+
236
326
  def parse_int_bound
237
327
  skip_ws
238
328
  literal = @scanner.scan(SIGNED_INT)
@@ -0,0 +1,278 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "json"
5
+
6
+ module Rigor
7
+ module Cache
8
+ # Cache invalidation descriptor — the typed-slot schema fixed by
9
+ # [`docs/design/20260505-cache-slice-taxonomy.md`](../../../docs/design/20260505-cache-slice-taxonomy.md).
10
+ # Pure value object: no I/O, no global state, fully immutable
11
+ # after construction. The storage layer
12
+ # ([`Rigor::Cache::Store`](store.rb), v0.0.8 slice 2) consumes
13
+ # descriptors but does not extend them.
14
+ #
15
+ # The descriptor has four slots (`files`, `gems`, `plugins`,
16
+ # `configs`); every slot is an array of typed entries; an empty
17
+ # array means "no dependency in this slot". Composition unions
18
+ # by key per slot; conflicts on the comparison fields raise
19
+ # {Conflict}.
20
+ #
21
+ # See ADR-2 § "Registration, Configuration, and Caching" for
22
+ # the design rationale and ADR-6 for the storage backend
23
+ # decisions that consume this schema.
24
+ class Descriptor # rubocop:disable Metrics/ClassLength
25
+ # Bumped on incompatible schema changes. The storage layer
26
+ # mixes this into the cache key, so a bump implicitly
27
+ # invalidates every cached value.
28
+ SCHEMA_VERSION = 1
29
+
30
+ # Per-slot entry value objects. Constructors validate enums /
31
+ # required fields and freeze the resulting struct so no caller
32
+ # can mutate after the entry is in a Descriptor.
33
+
34
+ class FileEntry
35
+ VALID_COMPARATORS = %i[digest mtime exists].freeze
36
+
37
+ attr_reader :path, :comparator, :value
38
+
39
+ def initialize(path:, comparator:, value:)
40
+ unless VALID_COMPARATORS.include?(comparator)
41
+ raise ArgumentError,
42
+ "FileEntry comparator must be one of #{VALID_COMPARATORS.inspect}, got #{comparator.inspect}"
43
+ end
44
+
45
+ @path = path.to_s.dup.freeze
46
+ @comparator = comparator
47
+ @value = value.to_s.dup.freeze
48
+ freeze
49
+ end
50
+
51
+ def to_h
52
+ { "path" => path, "comparator" => comparator.to_s, "value" => value }
53
+ end
54
+
55
+ def ==(other)
56
+ other.is_a?(FileEntry) && other.path == path && other.comparator == comparator && other.value == value
57
+ end
58
+ alias eql? ==
59
+
60
+ def hash
61
+ [self.class, path, comparator, value].hash
62
+ end
63
+ end
64
+
65
+ class GemEntry
66
+ attr_reader :name, :requirement, :locked
67
+
68
+ def initialize(name:, requirement:, locked: nil)
69
+ @name = name.to_s.dup.freeze
70
+ @requirement = requirement.to_s.dup.freeze
71
+ @locked = locked.nil? ? nil : locked.to_s.dup.freeze
72
+ freeze
73
+ end
74
+
75
+ def to_h
76
+ { "name" => name, "requirement" => requirement, "locked" => locked }
77
+ end
78
+
79
+ def ==(other)
80
+ other.is_a?(GemEntry) && other.name == name && other.requirement == requirement && other.locked == locked
81
+ end
82
+ alias eql? ==
83
+
84
+ def hash
85
+ [self.class, name, requirement, locked].hash
86
+ end
87
+ end
88
+
89
+ class PluginEntry
90
+ attr_reader :id, :version, :config_hash
91
+
92
+ def initialize(id:, version:, config_hash: nil)
93
+ @id = id.to_s.dup.freeze
94
+ @version = version.to_s.dup.freeze
95
+ @config_hash = config_hash.nil? ? nil : config_hash.to_s.dup.freeze
96
+ freeze
97
+ end
98
+
99
+ def to_h
100
+ { "id" => id, "version" => version, "config_hash" => config_hash }
101
+ end
102
+
103
+ def ==(other)
104
+ other.is_a?(PluginEntry) &&
105
+ other.id == id && other.version == version && other.config_hash == config_hash
106
+ end
107
+ alias eql? ==
108
+
109
+ def hash
110
+ [self.class, id, version, config_hash].hash
111
+ end
112
+ end
113
+
114
+ class ConfigEntry
115
+ attr_reader :key, :value_hash
116
+
117
+ def initialize(key:, value_hash:)
118
+ @key = key.to_s.dup.freeze
119
+ @value_hash = value_hash.to_s.dup.freeze
120
+ freeze
121
+ end
122
+
123
+ def to_h
124
+ { "key" => key, "value_hash" => value_hash }
125
+ end
126
+
127
+ def ==(other)
128
+ other.is_a?(ConfigEntry) && other.key == key && other.value_hash == value_hash
129
+ end
130
+ alias eql? ==
131
+
132
+ def hash
133
+ [self.class, key, value_hash].hash
134
+ end
135
+ end
136
+
137
+ # Raised when {.compose} encounters incompatible entries
138
+ # under the same key (file digest mismatch, gem-locked
139
+ # disagreement, …). Callers handle the exception by
140
+ # invalidating the cache slice rather than choosing one
141
+ # contribution silently.
142
+ class Conflict < StandardError; end
143
+
144
+ attr_reader :files, :gems, :plugins, :configs
145
+
146
+ def initialize(files: [], gems: [], plugins: [], configs: [])
147
+ @files = files.dup.freeze
148
+ @gems = gems.dup.freeze
149
+ @plugins = plugins.dup.freeze
150
+ @configs = configs.dup.freeze
151
+ freeze
152
+ end
153
+
154
+ # File-comparator strictness ordering. `:digest` is strictest
155
+ # (deterministic across machines); `:mtime` is cheaper but
156
+ # local; `:exists` is the weakest signal. When two
157
+ # contributors disagree on the comparator for the same
158
+ # `path`, the stricter one wins.
159
+ COMPARATOR_STRICTNESS = { digest: 2, mtime: 1, exists: 0 }.freeze
160
+ private_constant :COMPARATOR_STRICTNESS
161
+
162
+ # Composes any number of descriptors into a single descriptor
163
+ # whose slots are the union of the inputs' slots. Conflicts
164
+ # raise {Conflict}; idempotent contributions (same key, same
165
+ # value) collapse to a single entry.
166
+ def self.compose(*descriptors)
167
+ return new if descriptors.empty?
168
+
169
+ files = compose_files(descriptors.flat_map(&:files))
170
+ gems = compose_by_key(descriptors.flat_map(&:gems), :name)
171
+ plugins = compose_by_key(descriptors.flat_map(&:plugins), :id)
172
+ configs = compose_by_key(descriptors.flat_map(&:configs), :key)
173
+ new(files: files, gems: gems, plugins: plugins, configs: configs)
174
+ end
175
+
176
+ # @param producer_id [String]
177
+ # @param params [Hash] inputs the producer was called with
178
+ # @return [String] hex SHA-256 cache key for the value
179
+ def cache_key_for(producer_id:, params: {})
180
+ payload = {
181
+ "schema_version" => SCHEMA_VERSION,
182
+ "producer_id" => producer_id.to_s,
183
+ "params" => self.class.canonicalize_value(params),
184
+ "descriptor" => to_canonical_hash
185
+ }
186
+ Digest::SHA256.hexdigest(JSON.generate(payload))
187
+ end
188
+
189
+ # Canonical UTF-8 JSON serialisation. Slots appear in
190
+ # lexicographic order; entries are sorted by their key field
191
+ # so two equivalent descriptors produce identical bytes.
192
+ def to_canonical_bytes
193
+ JSON.generate(to_canonical_hash).b
194
+ end
195
+
196
+ def to_canonical_hash
197
+ {
198
+ "configs" => sort_entries(configs, "key").map(&:to_h),
199
+ "files" => sort_entries(files, "path").map(&:to_h),
200
+ "gems" => sort_entries(gems, "name").map(&:to_h),
201
+ "plugins" => sort_entries(plugins, "id").map(&:to_h)
202
+ }
203
+ end
204
+
205
+ def ==(other)
206
+ other.is_a?(Descriptor) &&
207
+ to_canonical_bytes == other.to_canonical_bytes
208
+ end
209
+ alias eql? ==
210
+
211
+ def hash
212
+ to_canonical_bytes.hash
213
+ end
214
+
215
+ class << self
216
+ # Recursively coerces a Ruby value into a JSON-canonical
217
+ # structure: hash keys are stringified and sorted; arrays
218
+ # preserve order; symbols stringify; everything else is
219
+ # JSON-renderable.
220
+ def canonicalize_value(value)
221
+ case value
222
+ when Hash
223
+ value.to_a.map { |k, v| [k.to_s, canonicalize_value(v)] }.sort_by(&:first).to_h
224
+ when Array
225
+ value.map { |v| canonicalize_value(v) }
226
+ when Symbol
227
+ value.to_s
228
+ else
229
+ value
230
+ end
231
+ end
232
+ end
233
+
234
+ private
235
+
236
+ def sort_entries(entries, key)
237
+ entries.sort_by { |e| e.to_h.fetch(key).to_s }
238
+ end
239
+
240
+ def self.compose_by_key(entries, key)
241
+ grouped = entries.group_by { |e| e.public_send(key) }
242
+ grouped.map do |_k, group|
243
+ unique = group.uniq
244
+ if unique.size == 1
245
+ unique.first
246
+ else
247
+ raise Conflict,
248
+ "cache descriptor conflict on #{key}=#{group.first.public_send(key).inspect}: " \
249
+ "got #{unique.size} incompatible entries"
250
+ end
251
+ end
252
+ end
253
+ private_class_method :compose_by_key
254
+
255
+ def self.compose_files(entries)
256
+ grouped = entries.group_by(&:path)
257
+ grouped.map do |path, group|
258
+ merge_file_group(path, group)
259
+ end
260
+ end
261
+ private_class_method :compose_files
262
+
263
+ def self.merge_file_group(path, group)
264
+ strictest_rank = group.map { |e| COMPARATOR_STRICTNESS.fetch(e.comparator) }.max
265
+ strictest = group.select { |e| COMPARATOR_STRICTNESS.fetch(e.comparator) == strictest_rank }
266
+ values = strictest.map(&:value).uniq
267
+ unless values.size == 1
268
+ raise Conflict,
269
+ "cache descriptor conflict on file=#{path.inspect}: " \
270
+ "got #{values.size} disagreeing values under the stricter comparator"
271
+ end
272
+
273
+ strictest.first
274
+ end
275
+ private_class_method :merge_file_group
276
+ end
277
+ end
278
+ end