schemurai 2.3.0 → 3.0.0

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 (35) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +67 -4
  3. data/ext/schemurai_native/Cargo.lock +1 -1
  4. data/ext/schemurai_native/Cargo.toml +1 -1
  5. data/ext/schemurai_native/glue.c +155 -14
  6. data/ext/schemurai_native/src/budget.rs +101 -0
  7. data/ext/schemurai_native/src/diagnostics.rs +395 -225
  8. data/ext/schemurai_native/src/enumeration.rs +154 -0
  9. data/ext/schemurai_native/src/evaluation.rs +219 -0
  10. data/ext/schemurai_native/src/graph.rs +50 -0
  11. data/ext/schemurai_native/src/instance_graph.rs +84 -0
  12. data/ext/schemurai_native/src/lib.rs +947 -184
  13. data/ext/schemurai_native/src/memo.rs +1149 -0
  14. data/ext/schemurai_native/src/memo_scope.rs +240 -0
  15. data/ext/schemurai_native/src/number.rs +7 -8
  16. data/ext/schemurai_native/src/pattern.rs +157 -14
  17. data/ext/schemurai_native/src/scope.rs +243 -33
  18. data/ext/schemurai_native/src/structural.rs +86 -0
  19. data/ext/schemurai_native/src/tracking.rs +245 -90
  20. data/lib/schemurai/backend.rb +1 -0
  21. data/lib/schemurai/evaluator.rb +45 -3
  22. data/lib/schemurai/limit_budget.rb +78 -0
  23. data/lib/schemurai/limit_exceeded.rb +18 -0
  24. data/lib/schemurai/limited_evaluator.rb +100 -0
  25. data/lib/schemurai/native/evaluator.rb +32 -5
  26. data/lib/schemurai/schema_domain.rb +12 -7
  27. data/lib/schemurai/schema_graph.rb +10 -1
  28. data/lib/schemurai/unique_items.rb +2 -0
  29. data/lib/schemurai/version.rb +1 -1
  30. data/lib/schemurai/vm/compiler.rb +7 -3
  31. data/lib/schemurai/vm/evaluator.rb +50 -3
  32. data/lib/schemurai/vm/limited_evaluator.rb +97 -0
  33. data/lib/schemurai.rb +74 -27
  34. data/sig/schemurai.rbs +20 -12
  35. metadata +16 -4
@@ -148,11 +148,13 @@ module Schemurai
148
148
  schema = node.schema
149
149
  if schema.key?("allOf")
150
150
  schema["allOf"].each_index do |index|
151
+ @budget&.checkpoint
151
152
  return false unless evaluate_valid(node.child("allOf", index), value)
152
153
  end
153
154
  end
154
155
  if schema.key?("anyOf")
155
156
  matched = schema["anyOf"].each_index.any? do |index|
157
+ @budget&.checkpoint
156
158
  evaluate_valid(node.child("anyOf", index), value)
157
159
  end
158
160
  return false unless matched
@@ -160,6 +162,7 @@ module Schemurai
160
162
  if schema.key?("oneOf")
161
163
  matches = 0
162
164
  schema["oneOf"].each_index do |index|
165
+ @budget&.checkpoint
163
166
  matches += 1 if evaluate_valid(node.child("oneOf", index), value)
164
167
  return false if matches > 1
165
168
  end
@@ -234,6 +237,7 @@ module Schemurai
234
237
  prefix_items = schema["prefixItems"]
235
238
  if prefix_items.is_a?(Array)
236
239
  prefix_items.each_index do |index|
240
+ @budget&.checkpoint
237
241
  break if index >= length
238
242
  return false unless evaluate_valid(node.child("prefixItems", index), value[index])
239
243
  end
@@ -242,12 +246,14 @@ module Schemurai
242
246
  items = schema["items"]
243
247
  if items.is_a?(Array)
244
248
  items.each_index do |index|
249
+ @budget&.checkpoint
245
250
  break if index >= length
246
251
  return false unless evaluate_valid(node.child("items", index), value[index])
247
252
  end
248
253
  if length > items.length && schema.key?("additionalItems")
249
254
  additional = node.child("additionalItems")
250
255
  (items.length...length).each do |index|
256
+ @budget&.checkpoint
251
257
  return false unless evaluate_valid(additional, value[index])
252
258
  end
253
259
  end
@@ -255,6 +261,7 @@ module Schemurai
255
261
  child = node.child("items")
256
262
  start = prefix_items.is_a?(Array) ? prefix_items.length : 0
257
263
  (start...length).each do |index|
264
+ @budget&.checkpoint
258
265
  return false unless evaluate_valid(child, value[index])
259
266
  end
260
267
  end
@@ -277,12 +284,16 @@ module Schemurai
277
284
  length = value.length
278
285
  return false if schema.key?("maxProperties") && length > schema["maxProperties"]
279
286
  return false if schema.key?("minProperties") && length < schema["minProperties"]
280
- return false if schema.key?("required") && !schema["required"].all? { |name| value.key?(name) }
287
+ return false if schema.key?("required") && !schema["required"].all? do |name|
288
+ @budget&.checkpoint
289
+ value.key?(name)
290
+ end
281
291
 
282
292
  properties = schema["properties"]
283
293
  patterns = schema["patternProperties"]
284
294
  additional = node.child("additionalProperties") if schema.key?("additionalProperties")
285
295
  value.each do |name, property_value|
296
+ @budget&.checkpoint
286
297
  matched = false
287
298
  if properties&.key?(name)
288
299
  matched = true
@@ -290,6 +301,7 @@ module Schemurai
290
301
  end
291
302
  if patterns
292
303
  patterns.each_key do |pattern|
304
+ @budget&.checkpoint
293
305
  next unless ecma_regexp(pattern).match?(name)
294
306
  matched = true
295
307
  return false unless evaluate_valid(node.child("patternProperties", pattern), property_value)
@@ -305,9 +317,13 @@ module Schemurai
305
317
 
306
318
  if schema.key?("dependencies")
307
319
  schema["dependencies"].each do |name, dependency|
320
+ @budget&.checkpoint
308
321
  next unless value.key?(name)
309
322
  if dependency.is_a?(Array)
310
- return false unless dependency.all? { |required_name| value.key?(required_name) }
323
+ return false unless dependency.all? do |required_name|
324
+ @budget&.checkpoint
325
+ value.key?(required_name)
326
+ end
311
327
  else
312
328
  return false unless evaluate_valid(node.child("dependencies", name), value)
313
329
  end
@@ -315,13 +331,18 @@ module Schemurai
315
331
  end
316
332
  if schema.key?("dependentRequired")
317
333
  schema["dependentRequired"].each do |name, required_names|
334
+ @budget&.checkpoint
318
335
  next unless value.key?(name)
319
- return false unless required_names.all? { |required_name| value.key?(required_name) }
336
+ return false unless required_names.all? do |required_name|
337
+ @budget&.checkpoint
338
+ value.key?(required_name)
339
+ end
320
340
  end
321
341
  end
322
342
 
323
343
  if schema.key?("dependentSchemas")
324
344
  schema["dependentSchemas"].each_key do |name|
345
+ @budget&.checkpoint
325
346
  next unless value.key?(name)
326
347
  return false unless evaluate_valid(node.child("dependentSchemas", name), value)
327
348
  end
@@ -425,6 +446,7 @@ module Schemurai
425
446
  return target unless target.schema.is_a?(Hash) && target.schema["$dynamicAnchor"] == raw_fragment
426
447
 
427
448
  Array(@dynamic_scope).each do |resource|
449
+ @budget&.checkpoint
428
450
  dynamic = @graph.dynamic_anchor(resource, raw_fragment)
429
451
  return dynamic if dynamic
430
452
  end
@@ -459,6 +481,7 @@ module Schemurai
459
481
  evaluation = Evaluation.valid
460
482
  if schema.key?("allOf")
461
483
  schema["allOf"].each_index do |index|
484
+ @budget&.checkpoint
462
485
  evaluation = evaluation.merge(
463
486
  evaluate_at(node.child("allOf", index), value, MISSING_SEGMENT, "allOf", index)
464
487
  )
@@ -468,6 +491,7 @@ module Schemurai
468
491
  if schema.key?("anyOf")
469
492
  matches = []
470
493
  schema["anyOf"].each_index do |index|
494
+ @budget&.checkpoint
471
495
  result = trial_at(node.child("anyOf", index), value, MISSING_SEGMENT, "anyOf", index)
472
496
  matches << result if result.valid?
473
497
  end
@@ -481,6 +505,7 @@ module Schemurai
481
505
  if schema.key?("oneOf")
482
506
  matches = []
483
507
  schema["oneOf"].each_index do |index|
508
+ @budget&.checkpoint
484
509
  result = trial_at(node.child("oneOf", index), value, MISSING_SEGMENT, "oneOf", index)
485
510
  matches << result if result.valid?
486
511
  end
@@ -559,6 +584,7 @@ module Schemurai
559
584
  prefix_items = schema["prefixItems"]
560
585
  if prefix_items.is_a?(Array)
561
586
  prefix_items.each_index do |index|
587
+ @budget&.checkpoint
562
588
  break if index >= value.length
563
589
  evaluate_at(node.child("prefixItems", index), value[index], index, "prefixItems", index)
564
590
  evaluated << index
@@ -568,6 +594,7 @@ module Schemurai
568
594
  items = schema["items"]
569
595
  if items.is_a?(Array)
570
596
  items.each_index do |index|
597
+ @budget&.checkpoint
571
598
  break if index >= value.length
572
599
  evaluate_at(node.child("items", index), value[index], index, "items", index)
573
600
  evaluated << index
@@ -575,6 +602,7 @@ module Schemurai
575
602
  if value.length > items.length && schema.key?("additionalItems")
576
603
  additional = node.child("additionalItems")
577
604
  (items.length...value.length).each do |index|
605
+ @budget&.checkpoint
578
606
  evaluate_at(additional, value[index], index, "additionalItems")
579
607
  evaluated << index
580
608
  end
@@ -582,6 +610,7 @@ module Schemurai
582
610
  elsif !items.nil?
583
611
  start = prefix_items.is_a?(Array) ? prefix_items.length : 0
584
612
  value.each_with_index do |item, index|
613
+ @budget&.checkpoint
585
614
  next if index < start
586
615
  evaluate_at(node.child("items"), item, index, "items")
587
616
  evaluated << index
@@ -590,6 +619,7 @@ module Schemurai
590
619
 
591
620
  if schema.key?("contains")
592
621
  matched = value.each_index.select do |index|
622
+ @budget&.checkpoint
593
623
  trial_at(node.child("contains"), value[index], index, "contains").valid?
594
624
  end
595
625
  minimum = schema.fetch("minContains", 1)
@@ -604,6 +634,7 @@ module Schemurai
604
634
  if schema.key?("unevaluatedItems")
605
635
  unevaluated = (0...value.length).to_a - combined
606
636
  unevaluated.each do |index|
637
+ @budget&.checkpoint
607
638
  evaluate_at(node.child("unevaluatedItems"), value[index], index, "unevaluatedItems")
608
639
  end
609
640
  evaluated.concat(unevaluated)
@@ -618,12 +649,14 @@ module Schemurai
618
649
  limit(schema, "minProperties", value.length) { |actual, expected| actual >= expected }
619
650
 
620
651
  Array(schema["required"]).each do |name|
652
+ @budget&.checkpoint
621
653
  add_error("required") { ErrorMessage.required(name) } unless value.key?(name)
622
654
  end
623
655
 
624
656
  properties = schema.fetch("properties", {})
625
657
  patterns = schema.fetch("patternProperties", {})
626
658
  value.each do |name, property_value|
659
+ @budget&.checkpoint
627
660
  matched = false
628
661
  if properties.key?(name)
629
662
  matched = true
@@ -631,6 +664,7 @@ module Schemurai
631
664
  evaluated << name
632
665
  end
633
666
  patterns.each do |pattern, subschema|
667
+ @budget&.checkpoint
634
668
  next unless ecma_regexp(pattern).match?(name)
635
669
  matched = true
636
670
  evaluate_at(node.child("patternProperties", pattern), property_value, name, "patternProperties", pattern)
@@ -644,14 +678,17 @@ module Schemurai
644
678
 
645
679
  if schema.key?("propertyNames")
646
680
  value.each_key do |name|
681
+ @budget&.checkpoint
647
682
  evaluate_at(node.child("propertyNames"), name, name, "propertyNames")
648
683
  end
649
684
  end
650
685
 
651
686
  schema.fetch("dependencies", {}).each do |name, dependency|
687
+ @budget&.checkpoint
652
688
  next unless value.key?(name)
653
689
  if dependency.is_a?(Array)
654
690
  dependency.each do |required_name|
691
+ @budget&.checkpoint
655
692
  add_error("dependencies") { ErrorMessage.dependent_required(name, required_name) } unless value.key?(required_name)
656
693
  end
657
694
  else
@@ -661,8 +698,10 @@ module Schemurai
661
698
  end
662
699
 
663
700
  schema.fetch("dependentRequired", {}).each do |name, required_names|
701
+ @budget&.checkpoint
664
702
  next unless value.key?(name)
665
703
  required_names.each do |required_name|
704
+ @budget&.checkpoint
666
705
  unless value.key?(required_name)
667
706
  add_error("dependentRequired") { ErrorMessage.dependent_required(name, required_name) }
668
707
  end
@@ -670,6 +709,7 @@ module Schemurai
670
709
  end
671
710
 
672
711
  schema.fetch("dependentSchemas", {}).each_key do |name|
712
+ @budget&.checkpoint
673
713
  next unless value.key?(name)
674
714
  result = evaluate_at(node.child("dependentSchemas", name), value, MISSING_SEGMENT, "dependentSchemas", name)
675
715
  evaluated.concat(result.evaluated_properties) if result.valid?
@@ -679,6 +719,7 @@ module Schemurai
679
719
  if schema.key?("unevaluatedProperties")
680
720
  unevaluated = value.keys - combined
681
721
  unevaluated.each do |name|
722
+ @budget&.checkpoint
682
723
  evaluate_at(node.child("unevaluatedProperties"), value[name], name, "unevaluatedProperties")
683
724
  end
684
725
  evaluated.concat(unevaluated)
@@ -757,6 +798,7 @@ module Schemurai
757
798
  escaped = false
758
799
  in_class = false
759
800
  pattern.each_char do |character|
801
+ @budget&.checkpoint
760
802
  if escaped
761
803
  translated << case character
762
804
  when "d" then in_class ? "0-9" : "[0-9]"
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Schemurai
4
+ module Internal
5
+ # Mutable state owned by one validation call, including native/VM fallback.
6
+ class LimitBudget
7
+ CHECK_INTERVAL = 256
8
+
9
+ def self.validate_options!(max_errors, timeout)
10
+ valid_errors = max_errors.nil? || (max_errors.is_a?(Integer) && max_errors >= 0)
11
+ unless valid_errors
12
+ raise ArgumentError, "max_errors must be a nonnegative Integer or nil"
13
+ end
14
+ valid_timeout = timeout.nil? || ((timeout.is_a?(Integer) || timeout.is_a?(Float)) && timeout >= 0 && timeout.to_f.finite?)
15
+ unless valid_timeout
16
+ raise ArgumentError, "timeout must be finite nonnegative seconds or nil"
17
+ end
18
+ end
19
+
20
+ def initialize(max_errors: nil, timeout: nil)
21
+ @max_errors = max_errors
22
+ @timeout = timeout
23
+ @errors = 0
24
+ @checks = 0
25
+ if timeout
26
+ @started = monotonic_time
27
+ @deadline = @started + timeout
28
+ check_deadline!
29
+ end
30
+ end
31
+
32
+ # Synchronous polling only; never inject exceptions into another thread.
33
+ def checkpoint
34
+ return unless @deadline
35
+
36
+ @checks += 1
37
+ check_deadline! if (@checks % CHECK_INTERVAL).zero?
38
+ end
39
+
40
+ def check_deadline!
41
+ return unless @deadline
42
+
43
+ now = monotonic_time
44
+ raise LimitExceeded.new(:timeout, @timeout, now - @started) if now >= @deadline
45
+ end
46
+
47
+ # Ruby retains the original deadline through native execution and fallback.
48
+ def native_duration
49
+ check_deadline!
50
+ @deadline && [@deadline - monotonic_time, 0.0].max
51
+ end
52
+
53
+ def native_max_errors
54
+ @max_errors && [@max_errors, (1 << (0.size * 8)) - 1].min
55
+ end
56
+
57
+ def raise_native_limit(kind, attempted)
58
+ if kind == 2
59
+ raise LimitExceeded.new(:timeout, @timeout, monotonic_time - @started)
60
+ end
61
+ raise LimitExceeded.new(:max_errors, @max_errors, attempted)
62
+ end
63
+
64
+ # Check before constructing the next message and JSON Pointers.
65
+ def reserve_error
66
+ checkpoint
67
+ attempted = @errors + 1
68
+ raise LimitExceeded.new(:max_errors, @max_errors, attempted) if @max_errors && attempted > @max_errors
69
+
70
+ @errors = attempted
71
+ end
72
+
73
+ private def monotonic_time
74
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
75
+ end
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Schemurai
4
+ # Raised when validation cannot finish within an application-supplied limit.
5
+ # This is an incomplete evaluation, not a JSON Schema validation failure.
6
+ class LimitExceeded < Error
7
+ # Limit name, configured maximum and attempted count (or elapsed seconds).
8
+ attr_reader :limit, :maximum, :attempted
9
+
10
+ # Creates an interruption error without embedding the input or partial errors.
11
+ def initialize(limit, maximum, attempted)
12
+ @limit = limit
13
+ @maximum = maximum
14
+ @attempted = attempted
15
+ super("validation exceeded #{limit} (maximum #{maximum}, attempted #{attempted})")
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,100 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "limit_exceeded"
4
+ require_relative "limit_budget"
5
+ require_relative "evaluator"
6
+
7
+ module Schemurai
8
+ module Internal
9
+ # Ruby evaluation with invocation-local limits.
10
+ class LimitedEvaluator < Evaluator
11
+ def validate(instance, max_errors: nil, timeout: nil, budget: nil)
12
+ @budget = budget || LimitBudget.new(max_errors: max_errors, timeout: timeout)
13
+ errors = []
14
+ each_error(instance) do |error|
15
+ errors << error
16
+ end
17
+ @budget.check_deadline!
18
+ Result.new(errors)
19
+ ensure
20
+ @budget = nil
21
+ end
22
+
23
+ def valid?(instance, max_errors: nil, timeout: nil, budget: nil)
24
+ @budget = budget || LimitBudget.new(max_errors: max_errors, timeout: timeout)
25
+ result = super(instance)
26
+ @budget.check_deadline!
27
+ result
28
+ ensure
29
+ @budget = nil
30
+ end
31
+
32
+ private def evaluate(node, instance)
33
+ @budget.checkpoint
34
+ super
35
+ end
36
+
37
+ private def evaluate_valid(node, instance)
38
+ @budget.checkpoint
39
+ super
40
+ end
41
+
42
+ private def add_error(keyword, message = nil, append_keyword: true, &block)
43
+ @budget.reserve_error if @error_callback
44
+ super
45
+ end
46
+
47
+ private def valid_string?(node, value)
48
+ @budget.check_deadline!
49
+ result = super
50
+ @budget.check_deadline!
51
+ result
52
+ end
53
+
54
+ private def check_string(node, value)
55
+ @budget.check_deadline!
56
+ result = super
57
+ @budget.check_deadline!
58
+ result
59
+ end
60
+
61
+ private def decimal(value)
62
+ @budget.check_deadline!
63
+ result = super
64
+ @budget.check_deadline!
65
+ result
66
+ end
67
+
68
+ private def ecma_regexp(pattern)
69
+ @budget.check_deadline!
70
+ result = super
71
+ @budget.check_deadline!
72
+ result
73
+ end
74
+
75
+ private def valid_content?(schema, value)
76
+ @budget.check_deadline!
77
+ result = super
78
+ @budget.check_deadline!
79
+ result
80
+ end
81
+
82
+ private def check_content(schema, value)
83
+ @budget.check_deadline!
84
+ result = super
85
+ @budget.check_deadline!
86
+ result
87
+ end
88
+
89
+ private def json_equal?(left, right)
90
+ @budget.checkpoint
91
+ super
92
+ end
93
+
94
+ private def json_fingerprint(value)
95
+ @budget.checkpoint
96
+ super
97
+ end
98
+ end
99
+ end
100
+ end
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "../vm/evaluator"
3
+ require_relative "../vm/limited_evaluator"
4
4
  require "schemurai_native"
5
5
  require_relative "diagnostics"
6
6
 
@@ -9,22 +9,49 @@ module Schemurai
9
9
  class Evaluator
10
10
  def backend = :native
11
11
 
12
- def initialize(graph, compiler, root, content: false, format: false)
13
- @program = Program.new(root, compiler, format, content)
12
+ def initialize(graph, compiler, root, content: false, format: false, compiled: nil)
13
+ @graph = graph
14
+ @compiler = compiler
15
+ @root = root
16
+ @content = content
17
+ @format = format
18
+ @compiled = compiled || Program.new(root, compiler, format, content)
19
+ @program = @compiled.runner(root)
14
20
  @vm = VM::Evaluator.new(graph, compiler, root, content: content, format: format)
15
21
  end
16
22
 
17
- def valid?(instance)
23
+ def valid?(instance, max_errors: nil, timeout: nil)
24
+ if !max_errors.nil? || !timeout.nil?
25
+ budget = Internal::LimitBudget.new(max_errors: max_errors, timeout: timeout)
26
+ result = @program.valid_with_budget(instance, budget)
27
+ budget.check_deadline!
28
+ return result.nil? ? limited_vm.valid?(instance, budget: budget) : result
29
+ end
18
30
  result = @program.valid?(instance)
19
31
  result.nil? ? @vm.valid?(instance) : result
20
32
  end
21
33
 
22
- def validate(instance)
34
+ def validate(instance, max_errors: nil, timeout: nil)
35
+ if !max_errors.nil? || !timeout.nil?
36
+ budget = Internal::LimitBudget.new(max_errors: max_errors, timeout: timeout)
37
+ rows = @program.diagnostics_with_budget(instance, budget)
38
+ return limited_vm.validate(instance, budget: budget) if rows.nil?
39
+
40
+ errors = rows.map do |row|
41
+ budget.reserve_error
42
+ Diagnostics.error(row)
43
+ end
44
+ budget.check_deadline!
45
+ return Result.new(errors)
46
+ end
23
47
  rows = @program.diagnostics(instance)
24
48
  return @vm.validate(instance) if rows.nil?
25
49
 
26
50
  Result.new(rows.empty? ? rows : rows.map { |row| Diagnostics.error(row) })
27
51
  end
52
+ private def limited_vm
53
+ VM::LimitedEvaluator.new(@graph, @compiler, @root, content: @content, format: @format)
54
+ end
28
55
  end
29
56
  end
30
57
 
@@ -8,7 +8,7 @@ module Schemurai
8
8
  invalid!(label, "must be a boolean or an object")
9
9
  end
10
10
 
11
- validate_value!(schema, label, [], {})
11
+ validate_value!(schema, label, [], {}, {})
12
12
  schema
13
13
  end
14
14
 
@@ -23,7 +23,7 @@ module Schemurai
23
23
  schemas
24
24
  end
25
25
 
26
- module_function def validate_value!(value, label, path, ancestors)
26
+ module_function def validate_value!(value, label, path, ancestors, completed)
27
27
  case value
28
28
  when nil, true, false
29
29
  nil
@@ -34,21 +34,21 @@ module Schemurai
34
34
  invalid_at!(label, path, "uses a Float subclass") unless value.instance_of?(Float)
35
35
  when Array
36
36
  invalid_at!(label, path, "uses an Array subclass") unless value.instance_of?(Array)
37
- descend!(value, label, path, ancestors) do
37
+ descend!(value, label, path, ancestors, completed) do
38
38
  value.each_with_index do |item, index|
39
39
  path << index
40
- validate_value!(item, label, path, ancestors)
40
+ validate_value!(item, label, path, ancestors, completed)
41
41
  path.pop
42
42
  end
43
43
  end
44
44
  when Hash
45
45
  invalid_at!(label, path, "uses a Hash subclass") unless value.instance_of?(Hash)
46
- descend!(value, label, path, ancestors) do
46
+ descend!(value, label, path, ancestors, completed) do
47
47
  value.each do |key, item|
48
48
  invalid_at!(label, path, "contains a non-string object key") unless key.instance_of?(String)
49
49
 
50
50
  path << key
51
- validate_value!(item, label, path, ancestors)
51
+ validate_value!(item, label, path, ancestors, completed)
52
52
  path.pop
53
53
  end
54
54
  end
@@ -63,11 +63,16 @@ module Schemurai
63
63
  end
64
64
  private_class_method :supported_exact_scalar?
65
65
 
66
- module_function def descend!(value, label, path, ancestors)
66
+ module_function def descend!(value, label, path, ancestors, completed)
67
+ # A completed subtree cannot contain a cycle back to this path. Only
68
+ # publish after the entire subtree passes, preserving first-error paths.
69
+ return if completed.key?(value.object_id)
70
+
67
71
  invalid_at!(label, path, "contains a recursive container") if ancestors.key?(value.object_id)
68
72
 
69
73
  ancestors[value.object_id] = true
70
74
  yield
75
+ completed[value.object_id] = true
71
76
  ensure
72
77
  ancestors.delete(value.object_id)
73
78
  end
@@ -53,8 +53,10 @@ module Schemurai
53
53
 
54
54
  class Resource
55
55
  attr_reader :uri, :root
56
+ attr_accessor :dynamic_anchor_count
56
57
 
57
58
  def initialize(uri, root)
59
+ @dynamic_anchor_count = 0
58
60
  @uri = uri
59
61
  @root = root
60
62
  @nodes = nil
@@ -144,6 +146,12 @@ module Schemurai
144
146
  @dynamic_anchors.dig(resource, name)
145
147
  end
146
148
 
149
+ # Anchor tables grow in insertion order, including late external indexing.
150
+ # Native compilation uses this snapshot to avoid absent pair lookups.
151
+ def dynamic_anchor_names(resource)
152
+ @dynamic_anchors[resource]&.keys || []
153
+ end
154
+
147
155
  def dynamic_scope?
148
156
  @dynamic_scope
149
157
  end
@@ -195,7 +203,8 @@ module Schemurai
195
203
  end
196
204
  end
197
205
  changes.dynamic_anchors.each do |resource, anchors|
198
- (@dynamic_anchors[resource] ||= {}).update(anchors)
206
+ merged = (@dynamic_anchors[resource] ||= {}).update(anchors)
207
+ resource.dynamic_anchor_count = merged.size
199
208
  end
200
209
  (@custom_dialects ||= {}).update(changes.custom_dialects) unless changes.custom_dialects.empty?
201
210
  @dynamic_scope = true if changes.requires_dynamic_scope
@@ -7,6 +7,7 @@ module Schemurai
7
7
  # Avoid building an index for small arrays and early duplicates.
8
8
  if values.length <= 16
9
9
  values.each_with_index do |item, index|
10
+ @budget&.checkpoint
10
11
  index.times { |previous| return false if json_equal?(values[previous], item) }
11
12
  end
12
13
  return true
@@ -16,6 +17,7 @@ module Schemurai
16
17
  # Store a single index until a collision needs a bucket array.
17
18
  index = 0
18
19
  while index < values.length
20
+ @budget&.checkpoint
19
21
  item = values[index]
20
22
  fingerprint = json_fingerprint(item)
21
23
  if (bucket = buckets[fingerprint])
@@ -2,5 +2,5 @@
2
2
 
3
3
  module Schemurai
4
4
  # The installed Schemurai version.
5
- VERSION = "2.3.0"
5
+ VERSION = "3.0.0"
6
6
  end
@@ -391,12 +391,16 @@ module Schemurai
391
391
  compiled.freeze unless compiled.empty?
392
392
  end
393
393
 
394
- private def snapshot(value)
394
+ # Keep aliases inside one operand while copying away from caller-owned
395
+ # containers. A fresh identity table also rechecks later compilations.
396
+ private def snapshot(value, copies = {}.compare_by_identity)
397
+ return copies[value] if copies.key?(value)
398
+
395
399
  case value
396
400
  when Hash
397
- value.to_h { |key, item| [snapshot(key), snapshot(item)] }.freeze
401
+ copies[value] = value.to_h { |key, item| [snapshot(key, copies), snapshot(item, copies)] }.freeze
398
402
  when Array
399
- value.map { |item| snapshot(item) }.freeze
403
+ copies[value] = value.map { |item| snapshot(item, copies) }.freeze
400
404
  when String
401
405
  -value
402
406
  else