odin-foundation 1.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 (49) hide show
  1. checksums.yaml +7 -0
  2. data/lib/odin/diff/differ.rb +115 -0
  3. data/lib/odin/diff/patcher.rb +64 -0
  4. data/lib/odin/export.rb +330 -0
  5. data/lib/odin/parsing/parser.rb +1193 -0
  6. data/lib/odin/parsing/token.rb +26 -0
  7. data/lib/odin/parsing/token_type.rb +40 -0
  8. data/lib/odin/parsing/tokenizer.rb +825 -0
  9. data/lib/odin/parsing/value_parser.rb +322 -0
  10. data/lib/odin/resolver/import_resolver.rb +137 -0
  11. data/lib/odin/serialization/canonicalize.rb +112 -0
  12. data/lib/odin/serialization/stringify.rb +582 -0
  13. data/lib/odin/transform/format_exporters.rb +819 -0
  14. data/lib/odin/transform/source_parsers.rb +385 -0
  15. data/lib/odin/transform/transform_engine.rb +2837 -0
  16. data/lib/odin/transform/transform_parser.rb +979 -0
  17. data/lib/odin/transform/transform_types.rb +278 -0
  18. data/lib/odin/transform/verb_context.rb +87 -0
  19. data/lib/odin/transform/verbs/aggregation_verbs.rb +106 -0
  20. data/lib/odin/transform/verbs/collection_verbs.rb +640 -0
  21. data/lib/odin/transform/verbs/datetime_verbs.rb +602 -0
  22. data/lib/odin/transform/verbs/financial_verbs.rb +356 -0
  23. data/lib/odin/transform/verbs/geo_verbs.rb +125 -0
  24. data/lib/odin/transform/verbs/numeric_verbs.rb +434 -0
  25. data/lib/odin/transform/verbs/object_verbs.rb +123 -0
  26. data/lib/odin/types/array_item.rb +42 -0
  27. data/lib/odin/types/diff.rb +89 -0
  28. data/lib/odin/types/directive.rb +28 -0
  29. data/lib/odin/types/document.rb +92 -0
  30. data/lib/odin/types/document_builder.rb +67 -0
  31. data/lib/odin/types/dyn_value.rb +270 -0
  32. data/lib/odin/types/errors.rb +149 -0
  33. data/lib/odin/types/modifiers.rb +45 -0
  34. data/lib/odin/types/ordered_map.rb +79 -0
  35. data/lib/odin/types/schema.rb +262 -0
  36. data/lib/odin/types/value_type.rb +28 -0
  37. data/lib/odin/types/values.rb +618 -0
  38. data/lib/odin/types.rb +12 -0
  39. data/lib/odin/utils/format_utils.rb +186 -0
  40. data/lib/odin/utils/path_utils.rb +25 -0
  41. data/lib/odin/utils/security_limits.rb +17 -0
  42. data/lib/odin/validation/format_validators.rb +238 -0
  43. data/lib/odin/validation/redos_protection.rb +102 -0
  44. data/lib/odin/validation/schema_parser.rb +813 -0
  45. data/lib/odin/validation/schema_serializer.rb +262 -0
  46. data/lib/odin/validation/validator.rb +1061 -0
  47. data/lib/odin/version.rb +5 -0
  48. data/lib/odin.rb +90 -0
  49. metadata +160 -0
@@ -0,0 +1,1061 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Odin
4
+ module Validation
5
+ class Validator
6
+ # Validate an OdinDocument against an OdinSchema
7
+ # Returns ValidationResult
8
+ def validate(doc, schema, options = {})
9
+ @errors = []
10
+ @doc = doc
11
+ @schema = schema
12
+ @strict = options.fetch(:strict, false)
13
+
14
+ # V001: Required fields
15
+ check_required_fields
16
+
17
+ # V002: Type matches
18
+ check_type_matches
19
+
20
+ # V003: Bounds constraints
21
+ check_bounds_constraints
22
+
23
+ # V004: Pattern constraints
24
+ check_pattern_constraints
25
+
26
+ # V004 (format): Format constraints
27
+ check_format_constraints
28
+
29
+ # V005: Enum constraints
30
+ check_enum_constraints
31
+
32
+ # V006: Array length constraints
33
+ check_array_lengths
34
+
35
+ # V007: Uniqueness constraints
36
+ check_uniqueness
37
+
38
+ # V008: Invariant validation
39
+ check_invariants
40
+
41
+ # V009: Cardinality constraints
42
+ check_cardinality
43
+
44
+ # V010: Conditional requirements
45
+ check_conditionals
46
+
47
+ # V011: Unknown fields (strict mode)
48
+ check_unknown_fields if @strict
49
+
50
+ # V012: Circular references
51
+ check_circular_references
52
+
53
+ # V013: Unresolved references
54
+ check_unresolved_references
55
+
56
+ Errors::ValidationResult.new(@errors)
57
+ end
58
+
59
+ private
60
+
61
+ def add_error(code:, path:, message:, expected: nil, actual: nil)
62
+ @errors << Errors::ValidationError.new(
63
+ code: code,
64
+ path: path,
65
+ message: message,
66
+ expected: expected,
67
+ actual: actual
68
+ )
69
+ end
70
+
71
+ # ── V001: Required field missing ──
72
+
73
+ def check_required_fields
74
+ # Check root-level fields
75
+ @schema.fields.each do |path, field|
76
+ next unless field.required
77
+ next if field.computed
78
+ next if has_active_conditional?(field) # handled by V010
79
+
80
+ unless doc_has_value?(path)
81
+ add_error(
82
+ code: Errors::ValidationErrorCode::REQUIRED_FIELD_MISSING,
83
+ path: path,
84
+ message: "Required field '#{path}' is missing",
85
+ expected: "present"
86
+ )
87
+ end
88
+ end
89
+
90
+ # Check type-level fields — only when the type is used as an inline object
91
+ # in the document (not when it's just a type definition via {@ ...}).
92
+ # Type definitions like {@address} define structure but don't require
93
+ # the fields to exist at the type-name path. They are checked when
94
+ # a field references the type (e.g., home = @address means check
95
+ # home.street, home.city).
96
+ @schema.types.each do |type_name, schema_type|
97
+ # Find all fields that reference this type
98
+ type_usage_paths = find_type_usage_paths(type_name)
99
+
100
+ if type_usage_paths.empty?
101
+ # Check if the type is used directly in the document at its own path
102
+ schema_type.fields.each do |field_name, field|
103
+ next unless field.required
104
+ next if field.computed
105
+ next if has_active_conditional?(field)
106
+
107
+ full_path = "#{type_name}.#{field_name}"
108
+ # Only check if the type section actually exists in the document
109
+ next unless doc_section_exists?(type_name)
110
+ unless doc_has_value?(full_path)
111
+ add_error(
112
+ code: Errors::ValidationErrorCode::REQUIRED_FIELD_MISSING,
113
+ path: full_path,
114
+ message: "Required field '#{full_path}' is missing",
115
+ expected: "present"
116
+ )
117
+ end
118
+ end
119
+ else
120
+ # Check required fields at each usage path
121
+ type_usage_paths.each do |usage_path|
122
+ schema_type.fields.each do |field_name, field|
123
+ next unless field.required
124
+ next if field.computed
125
+ next if has_active_conditional?(field)
126
+
127
+ full_path = "#{usage_path}.#{field_name}"
128
+ unless doc_has_value?(full_path)
129
+ add_error(
130
+ code: Errors::ValidationErrorCode::REQUIRED_FIELD_MISSING,
131
+ path: full_path,
132
+ message: "Required field '#{full_path}' is missing",
133
+ expected: "present"
134
+ )
135
+ end
136
+ end
137
+ end
138
+ end
139
+ end
140
+
141
+ # Check array item fields
142
+ @schema.arrays.each do |array_path, schema_array|
143
+ check_array_item_required_fields(array_path, schema_array)
144
+ end
145
+ end
146
+
147
+ def check_array_item_required_fields(array_path, schema_array)
148
+ # Find all array items in the document
149
+ item_count = count_array_items(array_path)
150
+ return if item_count == 0
151
+
152
+ schema_array.item_fields.each do |field_name, field|
153
+ next unless field.required
154
+ next if field.computed
155
+
156
+ item_count.times do |i|
157
+ # Try both path formats
158
+ full_path = "#{array_path}[#{i}].#{field_name}"
159
+ alt_path = "#{array_path}[].[#{i}].#{field_name}"
160
+ unless doc_has_value?(full_path) || doc_has_value?(alt_path)
161
+ add_error(
162
+ code: Errors::ValidationErrorCode::REQUIRED_FIELD_MISSING,
163
+ path: full_path,
164
+ message: "Required field '#{field_name}' is missing in #{array_path}[#{i}]",
165
+ expected: "present"
166
+ )
167
+ end
168
+ end
169
+ end
170
+ end
171
+
172
+ # ── V002: Type mismatch ──
173
+
174
+ def check_type_matches
175
+ each_schema_field do |path, field, value|
176
+ next if value.nil? || value.null?
177
+ expected_type = field.field_type
178
+ next if expected_type == Types::SchemaFieldType::ANY
179
+
180
+ actual_type = value_to_schema_type(value)
181
+ next if types_compatible?(expected_type, actual_type, value)
182
+
183
+ add_error(
184
+ code: Errors::ValidationErrorCode::TYPE_MISMATCH,
185
+ path: path,
186
+ message: "Expected type '#{expected_type}' but got '#{actual_type}' at '#{path}'",
187
+ expected: expected_type.to_s,
188
+ actual: actual_type.to_s
189
+ )
190
+ end
191
+ end
192
+
193
+ def types_compatible?(expected, actual, value)
194
+ return true if expected == actual
195
+ return true if expected == Types::SchemaFieldType::ANY
196
+
197
+ # Number accepts integer
198
+ return true if expected == Types::SchemaFieldType::NUMBER &&
199
+ actual == Types::SchemaFieldType::INTEGER
200
+
201
+ # Currency is a numeric type
202
+ return true if expected == Types::SchemaFieldType::NUMBER &&
203
+ actual == Types::SchemaFieldType::CURRENCY
204
+
205
+ # String accepts date, timestamp, time, duration (they are string subtypes)
206
+ return true if expected == Types::SchemaFieldType::STRING &&
207
+ [Types::SchemaFieldType::DATE, Types::SchemaFieldType::TIMESTAMP,
208
+ Types::SchemaFieldType::TIME, Types::SchemaFieldType::DURATION].include?(actual)
209
+
210
+ # Nullable fields accept null
211
+ return true if actual == Types::SchemaFieldType::NULL
212
+
213
+ false
214
+ end
215
+
216
+ def value_to_schema_type(value)
217
+ case value
218
+ when Types::OdinString then Types::SchemaFieldType::STRING
219
+ when Types::OdinInteger then Types::SchemaFieldType::INTEGER
220
+ when Types::OdinNumber then Types::SchemaFieldType::NUMBER
221
+ when Types::OdinBoolean then Types::SchemaFieldType::BOOLEAN
222
+ when Types::OdinCurrency then Types::SchemaFieldType::CURRENCY
223
+ when Types::OdinPercent then Types::SchemaFieldType::PERCENT
224
+ when Types::OdinDate then Types::SchemaFieldType::DATE
225
+ when Types::OdinTimestamp then Types::SchemaFieldType::TIMESTAMP
226
+ when Types::OdinTime then Types::SchemaFieldType::TIME
227
+ when Types::OdinDuration then Types::SchemaFieldType::DURATION
228
+ when Types::OdinReference then Types::SchemaFieldType::REFERENCE
229
+ when Types::OdinBinary then Types::SchemaFieldType::BINARY
230
+ when Types::OdinNull then Types::SchemaFieldType::NULL
231
+ else Types::SchemaFieldType::STRING
232
+ end
233
+ end
234
+
235
+ # ── V003: Value out of bounds ──
236
+
237
+ def check_bounds_constraints
238
+ each_schema_field_with_constraints(:bounds) do |path, field, value, constraint|
239
+ next if value.nil? || value.null?
240
+ check_single_bounds(path, field, value, constraint)
241
+ end
242
+ end
243
+
244
+ def check_single_bounds(path, field, value, constraint)
245
+ if value.numeric?
246
+ num = value.value.to_f
247
+ check_numeric_bounds(path, num, constraint)
248
+ elsif value.string?
249
+ len = value.value.length
250
+ check_numeric_bounds(path, len, constraint, label: "length")
251
+ elsif value.date? || value.timestamp?
252
+ check_date_bounds(path, value, constraint)
253
+ end
254
+ end
255
+
256
+ def check_numeric_bounds(path, num, constraint, label: "value")
257
+ if constraint.min
258
+ min_val = constraint.min.to_f
259
+ if constraint.exclusive_min
260
+ unless num > min_val
261
+ add_error(
262
+ code: Errors::ValidationErrorCode::VALUE_OUT_OF_BOUNDS,
263
+ path: path,
264
+ message: "#{label.capitalize} #{num} must be greater than #{constraint.min} at '#{path}'",
265
+ expected: "> #{constraint.min}",
266
+ actual: num.to_s
267
+ )
268
+ end
269
+ else
270
+ unless num >= min_val
271
+ add_error(
272
+ code: Errors::ValidationErrorCode::VALUE_OUT_OF_BOUNDS,
273
+ path: path,
274
+ message: "#{label.capitalize} #{num} is below minimum #{constraint.min} at '#{path}'",
275
+ expected: ">= #{constraint.min}",
276
+ actual: num.to_s
277
+ )
278
+ end
279
+ end
280
+ end
281
+
282
+ if constraint.max
283
+ max_val = constraint.max.to_f
284
+ if constraint.exclusive_max
285
+ unless num < max_val
286
+ add_error(
287
+ code: Errors::ValidationErrorCode::VALUE_OUT_OF_BOUNDS,
288
+ path: path,
289
+ message: "#{label.capitalize} #{num} must be less than #{constraint.max} at '#{path}'",
290
+ expected: "< #{constraint.max}",
291
+ actual: num.to_s
292
+ )
293
+ end
294
+ else
295
+ unless num <= max_val
296
+ add_error(
297
+ code: Errors::ValidationErrorCode::VALUE_OUT_OF_BOUNDS,
298
+ path: path,
299
+ message: "#{label.capitalize} #{num} exceeds maximum #{constraint.max} at '#{path}'",
300
+ expected: "<= #{constraint.max}",
301
+ actual: num.to_s
302
+ )
303
+ end
304
+ end
305
+ end
306
+ end
307
+
308
+ def check_date_bounds(path, value, constraint)
309
+ val_str = value.to_s
310
+ if constraint.min && val_str < constraint.min.to_s
311
+ add_error(
312
+ code: Errors::ValidationErrorCode::VALUE_OUT_OF_BOUNDS,
313
+ path: path,
314
+ message: "Date #{val_str} is before minimum #{constraint.min} at '#{path}'",
315
+ expected: ">= #{constraint.min}",
316
+ actual: val_str
317
+ )
318
+ end
319
+ if constraint.max && val_str > constraint.max.to_s
320
+ add_error(
321
+ code: Errors::ValidationErrorCode::VALUE_OUT_OF_BOUNDS,
322
+ path: path,
323
+ message: "Date #{val_str} is after maximum #{constraint.max} at '#{path}'",
324
+ expected: "<= #{constraint.max}",
325
+ actual: val_str
326
+ )
327
+ end
328
+ end
329
+
330
+ # ── V004: Pattern mismatch ──
331
+
332
+ def check_pattern_constraints
333
+ each_schema_field_with_constraints(:pattern) do |path, field, value, constraint|
334
+ next if value.nil? || value.null?
335
+ next unless value.string?
336
+
337
+ # ReDoS check
338
+ unless ReDoSProtection.safe?(constraint.pattern)
339
+ add_error(
340
+ code: Errors::ValidationErrorCode::PATTERN_MISMATCH,
341
+ path: path,
342
+ message: "Unsafe regex pattern rejected at '#{path}'"
343
+ )
344
+ next
345
+ end
346
+
347
+ begin
348
+ regex = Regexp.new(constraint.pattern)
349
+ result = ReDoSProtection.safe_test(regex, value.value)
350
+ if result[:reason] == :value_too_long
351
+ add_error(
352
+ code: Errors::ValidationErrorCode::PATTERN_MISMATCH,
353
+ path: path,
354
+ message: "Value too long for pattern validation at '#{path}'"
355
+ )
356
+ elsif result[:timed_out]
357
+ add_error(
358
+ code: Errors::ValidationErrorCode::PATTERN_MISMATCH,
359
+ path: path,
360
+ message: "Pattern validation timed out at '#{path}'"
361
+ )
362
+ elsif !result[:matched]
363
+ add_error(
364
+ code: Errors::ValidationErrorCode::PATTERN_MISMATCH,
365
+ path: path,
366
+ message: "Value '#{value.value}' does not match pattern /#{constraint.pattern}/ at '#{path}'",
367
+ expected: constraint.pattern,
368
+ actual: value.value
369
+ )
370
+ end
371
+ rescue RegexpError => e
372
+ add_error(
373
+ code: Errors::ValidationErrorCode::PATTERN_MISMATCH,
374
+ path: path,
375
+ message: "Invalid regex pattern: #{e.message} at '#{path}'"
376
+ )
377
+ end
378
+ end
379
+ end
380
+
381
+ # ── V005: Invalid enum value ──
382
+
383
+ def check_enum_constraints
384
+ each_schema_field_with_constraints(:enum) do |path, field, value, constraint|
385
+ next if value.nil? || value.null?
386
+
387
+ val_str = extract_value_for_comparison(value)
388
+ unless constraint.values.include?(val_str)
389
+ add_error(
390
+ code: Errors::ValidationErrorCode::INVALID_ENUM_VALUE,
391
+ path: path,
392
+ message: "Value '#{val_str}' is not one of allowed values [#{constraint.values.join(', ')}] at '#{path}'",
393
+ expected: constraint.values.join(", "),
394
+ actual: val_str
395
+ )
396
+ end
397
+ end
398
+ end
399
+
400
+ # ── V006: Array length violation ──
401
+
402
+ def check_array_lengths
403
+ @schema.arrays.each do |array_path, schema_array|
404
+ count = count_array_items(array_path)
405
+ # For max_items, only validate if array exists
406
+ # For min_items, always validate (0 items < min is a violation)
407
+
408
+ if schema_array.min_items && count < schema_array.min_items
409
+ add_error(
410
+ code: Errors::ValidationErrorCode::ARRAY_LENGTH_VIOLATION,
411
+ path: array_path,
412
+ message: "Array '#{array_path}' has #{count} items, minimum is #{schema_array.min_items}",
413
+ expected: ">= #{schema_array.min_items}",
414
+ actual: count.to_s
415
+ )
416
+ end
417
+
418
+ if schema_array.max_items && count > schema_array.max_items
419
+ add_error(
420
+ code: Errors::ValidationErrorCode::ARRAY_LENGTH_VIOLATION,
421
+ path: array_path,
422
+ message: "Array '#{array_path}' has #{count} items, maximum is #{schema_array.max_items}",
423
+ expected: "<= #{schema_array.max_items}",
424
+ actual: count.to_s
425
+ )
426
+ end
427
+ end
428
+ end
429
+
430
+ # ── V007: Uniqueness constraint violation ──
431
+
432
+ def check_uniqueness
433
+ @schema.arrays.each do |array_path, schema_array|
434
+ next unless schema_array.unique
435
+
436
+ count = count_array_items(array_path)
437
+ next if count <= 1
438
+
439
+ # Collect values for uniqueness check
440
+ seen = {}
441
+ count.times do |i|
442
+ # Get all fields for this item
443
+ item_key = collect_item_values(array_path, i)
444
+ if seen.key?(item_key)
445
+ add_error(
446
+ code: Errors::ValidationErrorCode::UNIQUE_CONSTRAINT_VIOLATION,
447
+ path: array_path,
448
+ message: "Duplicate item at index #{i} in array '#{array_path}'",
449
+ expected: "unique items",
450
+ actual: "duplicate of index #{seen[item_key]}"
451
+ )
452
+ else
453
+ seen[item_key] = i
454
+ end
455
+ end
456
+ end
457
+
458
+ # Check unique constraints on individual fields
459
+ each_schema_field_with_constraints(:unique) do |path, field, value, constraint|
460
+ # Unique constraint on a field within an array — check uniqueness across items
461
+ check_field_uniqueness_in_array(path, field, constraint)
462
+ end
463
+ end
464
+
465
+ def check_field_uniqueness_in_array(path, field, constraint)
466
+ # Determine if this field is inside an array
467
+ parts = path.split(".")
468
+ return unless parts.length >= 2
469
+
470
+ array_path = parts[0...-1].join(".")
471
+ field_name = parts.last
472
+ count = count_array_items(array_path)
473
+ return if count <= 1
474
+
475
+ seen = {}
476
+ count.times do |i|
477
+ item_path = "#{array_path}[#{i}].#{field_name}"
478
+ value = @doc.get(item_path)
479
+ next unless value
480
+
481
+ val_str = extract_value_for_comparison(value)
482
+ if seen.key?(val_str)
483
+ add_error(
484
+ code: Errors::ValidationErrorCode::UNIQUE_CONSTRAINT_VIOLATION,
485
+ path: item_path,
486
+ message: "Duplicate value '#{val_str}' for unique field '#{field_name}' at index #{i}",
487
+ expected: "unique",
488
+ actual: val_str
489
+ )
490
+ else
491
+ seen[val_str] = i
492
+ end
493
+ end
494
+ end
495
+
496
+ # ── V008: Invariant violation ──
497
+
498
+ def check_invariants
499
+ @schema.object_constraints.each do |scope, constraints|
500
+ constraints.each do |constraint|
501
+ next unless constraint.is_a?(Types::SchemaInvariant)
502
+ evaluate_invariant(scope, constraint)
503
+ end
504
+ end
505
+ end
506
+
507
+ def evaluate_invariant(scope, invariant)
508
+ expr = invariant.expression
509
+ # Parse simple binary expressions: field OPERATOR value_or_field
510
+ match = expr.match(/\A(\S+)\s*(>=|<=|!=|==|>|<|=)\s*(.+)\z/)
511
+ return unless match
512
+
513
+ left_field = match[1]
514
+ operator = match[2]
515
+ right_expr = match[3].strip
516
+
517
+ left_path = scope.empty? ? left_field : "#{scope}.#{left_field}"
518
+ left_value = @doc.get(left_path)
519
+ return unless left_value # Can't evaluate if field missing
520
+
521
+ # Right side might be a field reference or a literal
522
+ right_path = scope.empty? ? right_expr : "#{scope}.#{right_expr}"
523
+ right_value = @doc.get(right_path)
524
+
525
+ if right_value
526
+ # Compare two field values
527
+ result = compare_values(left_value, operator, right_value)
528
+ else
529
+ # Compare field to literal
530
+ result = compare_value_to_literal(left_value, operator, right_expr)
531
+ end
532
+
533
+ unless result
534
+ add_error(
535
+ code: Errors::ValidationErrorCode::INVARIANT_VIOLATION,
536
+ path: scope,
537
+ message: "Invariant '#{expr}' violated at '#{scope}'",
538
+ expected: expr
539
+ )
540
+ end
541
+ end
542
+
543
+ def compare_values(left, operator, right)
544
+ lv = extract_numeric_value(left)
545
+ rv = extract_numeric_value(right)
546
+
547
+ if lv && rv
548
+ case operator
549
+ when ">", ">" then lv > rv
550
+ when "<" then lv < rv
551
+ when ">=", ">=" then lv >= rv
552
+ when "<=", "<=" then lv <= rv
553
+ when "=", "==" then lv == rv
554
+ when "!=" then lv != rv
555
+ else false
556
+ end
557
+ else
558
+ ls = extract_value_for_comparison(left)
559
+ rs = extract_value_for_comparison(right)
560
+ case operator
561
+ when "=", "==" then ls == rs
562
+ when "!=" then ls != rs
563
+ else false
564
+ end
565
+ end
566
+ end
567
+
568
+ def compare_value_to_literal(value, operator, literal)
569
+ nv = extract_numeric_value(value)
570
+ nl = Float(literal) rescue nil
571
+
572
+ if nv && nl
573
+ case operator
574
+ when ">" then nv > nl
575
+ when "<" then nv < nl
576
+ when ">=", ">=" then nv >= nl
577
+ when "<=", "<=" then nv <= nl
578
+ when "=", "==" then nv == nl
579
+ when "!=" then nv != nl
580
+ else false
581
+ end
582
+ else
583
+ vs = extract_value_for_comparison(value)
584
+ case operator
585
+ when "=", "==" then vs == literal
586
+ when "!=" then vs != literal
587
+ else false
588
+ end
589
+ end
590
+ end
591
+
592
+ # ── V009: Cardinality constraint violation ──
593
+
594
+ def check_cardinality
595
+ @schema.object_constraints.each do |scope, constraints|
596
+ constraints.each do |constraint|
597
+ next unless constraint.is_a?(Types::SchemaCardinality)
598
+ evaluate_cardinality(scope, constraint)
599
+ end
600
+ end
601
+ end
602
+
603
+ def evaluate_cardinality(scope, constraint)
604
+ # Count how many of the listed fields are present and non-null
605
+ count = 0
606
+ constraint.fields.each do |field_name|
607
+ path = scope.empty? ? field_name : "#{scope}.#{field_name}"
608
+ value = @doc.get(path)
609
+ count += 1 if value && !value.null?
610
+ end
611
+
612
+ case constraint.cardinality_type
613
+ when "of"
614
+ if constraint.min && count < constraint.min
615
+ add_error(
616
+ code: Errors::ValidationErrorCode::CARDINALITY_CONSTRAINT_VIOLATION,
617
+ path: scope,
618
+ message: "At least #{constraint.min} of [#{constraint.fields.join(', ')}] required at '#{scope}', found #{count}",
619
+ expected: ">= #{constraint.min}",
620
+ actual: count.to_s
621
+ )
622
+ end
623
+ if constraint.max && count > constraint.max
624
+ add_error(
625
+ code: Errors::ValidationErrorCode::CARDINALITY_CONSTRAINT_VIOLATION,
626
+ path: scope,
627
+ message: "At most #{constraint.max} of [#{constraint.fields.join(', ')}] allowed at '#{scope}', found #{count}",
628
+ expected: "<= #{constraint.max}",
629
+ actual: count.to_s
630
+ )
631
+ end
632
+ when "one_of"
633
+ unless count >= 1
634
+ add_error(
635
+ code: Errors::ValidationErrorCode::CARDINALITY_CONSTRAINT_VIOLATION,
636
+ path: scope,
637
+ message: "At least one of [#{constraint.fields.join(', ')}] required at '#{scope}'",
638
+ expected: ">= 1",
639
+ actual: count.to_s
640
+ )
641
+ end
642
+ when "exactly_one"
643
+ unless count == 1
644
+ add_error(
645
+ code: Errors::ValidationErrorCode::CARDINALITY_CONSTRAINT_VIOLATION,
646
+ path: scope,
647
+ message: "Exactly one of [#{constraint.fields.join(', ')}] required at '#{scope}', found #{count}",
648
+ expected: "1",
649
+ actual: count.to_s
650
+ )
651
+ end
652
+ when "at_most_one"
653
+ unless count <= 1
654
+ add_error(
655
+ code: Errors::ValidationErrorCode::CARDINALITY_CONSTRAINT_VIOLATION,
656
+ path: scope,
657
+ message: "At most one of [#{constraint.fields.join(', ')}] allowed at '#{scope}', found #{count}",
658
+ expected: "<= 1",
659
+ actual: count.to_s
660
+ )
661
+ end
662
+ end
663
+ end
664
+
665
+ # ── V010: Conditional requirement not met ──
666
+
667
+ def check_conditionals
668
+ each_schema_field_with_conditionals do |path, field, conditionals|
669
+ conditionals.each do |cond|
670
+ # Resolve the condition field value from the document
671
+ cond_field_path = resolve_conditional_field(path, cond.field)
672
+ cond_value = @doc.get(cond_field_path)
673
+
674
+ # If condition field doesn't exist, skip
675
+ next unless cond_value
676
+
677
+ # Evaluate the condition
678
+ is_met = cond.evaluate(extract_value_for_comparison(cond_value))
679
+
680
+ if is_met && field.required && !doc_has_value?(path)
681
+ add_error(
682
+ code: Errors::ValidationErrorCode::CONDITIONAL_REQUIREMENT_NOT_MET,
683
+ path: path,
684
+ message: "Field '#{path}' is required when #{cond.field} #{cond.operator} #{cond.value}",
685
+ expected: "present",
686
+ actual: "missing"
687
+ )
688
+ end
689
+ end
690
+ end
691
+ end
692
+
693
+ def resolve_conditional_field(field_path, cond_field)
694
+ # If the field is in a section, resolve relative to same section
695
+ parts = field_path.split(".")
696
+ if parts.length > 1
697
+ section = parts[0...-1].join(".")
698
+ "#{section}.#{cond_field}"
699
+ else
700
+ cond_field
701
+ end
702
+ end
703
+
704
+ # ── V011: Unknown field (strict mode) ──
705
+
706
+ def check_unknown_fields
707
+ known_paths = collect_known_paths
708
+ @doc.each_assignment do |path, _value|
709
+ unless known_paths.include?(path) || path_in_known_array?(path, known_paths)
710
+ add_error(
711
+ code: Errors::ValidationErrorCode::UNKNOWN_FIELD,
712
+ path: path,
713
+ message: "Unknown field '#{path}' (strict mode)",
714
+ expected: "known field"
715
+ )
716
+ end
717
+ end
718
+ end
719
+
720
+ def collect_known_paths
721
+ paths = Set.new
722
+ @schema.fields.each_key { |p| paths.add(p) }
723
+ @schema.types.each do |type_name, schema_type|
724
+ schema_type.fields.each_key { |f| paths.add("#{type_name}.#{f}") }
725
+ end
726
+ @schema.arrays.each do |array_path, schema_array|
727
+ schema_array.item_fields.each_key do |f|
728
+ # Array items match pattern: path[N].field
729
+ paths.add("#{array_path}[].#{f}")
730
+ end
731
+ end
732
+ paths
733
+ end
734
+
735
+ def path_in_known_array?(path, known_paths)
736
+ # Check if path matches an array item pattern
737
+ @schema.arrays.each do |array_path, schema_array|
738
+ escaped = Regexp.escape(array_path)
739
+ # Support both formats: items[0].field and items[].[0].field
740
+ if path.start_with?("#{array_path}[")
741
+ # Format: items[0].field
742
+ match = path.match(/\A#{escaped}\[\d+\]\.(.+)\z/)
743
+ if match
744
+ field_name = match[1]
745
+ return true if schema_array.item_fields.key?(field_name)
746
+ end
747
+ return true if path.match?(/\A#{escaped}\[\d+\]\z/)
748
+
749
+ # Format: items[].[0].field
750
+ match = path.match(/\A#{escaped}\[\]\.\[(\d+)\]\.(.+)\z/)
751
+ if match
752
+ field_name = match[2]
753
+ return true if schema_array.item_fields.key?(field_name)
754
+ end
755
+ return true if path.match?(/\A#{escaped}\[\]\.\[\d+\]\z/)
756
+ end
757
+ end
758
+ false
759
+ end
760
+
761
+ # ── V012: Circular reference ──
762
+
763
+ def check_circular_references
764
+ # Check if any reference values in the document create cycles
765
+ @doc.each_assignment do |path, value|
766
+ next unless value.is_a?(Types::OdinReference)
767
+ visited = Set.new([path])
768
+ check_ref_cycle(value.path, visited, path)
769
+ end
770
+
771
+ # Check schema-level type reference cycles
772
+ check_schema_type_cycles
773
+ end
774
+
775
+ def check_ref_cycle(ref_path, visited, origin_path)
776
+ return if visited.size > 100 # safety limit
777
+
778
+ if visited.include?(ref_path)
779
+ add_error(
780
+ code: Errors::ValidationErrorCode::CIRCULAR_REFERENCE,
781
+ path: origin_path,
782
+ message: "Circular reference detected: #{origin_path} -> #{ref_path}"
783
+ )
784
+ return
785
+ end
786
+
787
+ target = @doc.get(ref_path)
788
+ return unless target.is_a?(Types::OdinReference)
789
+
790
+ visited.add(ref_path)
791
+ check_ref_cycle(target.path, visited, origin_path)
792
+ end
793
+
794
+ def check_schema_type_cycles
795
+ # Build a graph of type references from schema types
796
+ type_refs = {}
797
+ @schema.types.each do |type_name, schema_type|
798
+ refs = []
799
+ schema_type.fields.each do |_field_name, field|
800
+ if field.type_ref
801
+ clean_ref = field.type_ref.sub(/\A@+/, "")
802
+ refs << clean_ref if @schema.types.key?(clean_ref)
803
+ end
804
+ end
805
+ type_refs[type_name] = refs unless refs.empty?
806
+ end
807
+
808
+ # Detect cycles using DFS
809
+ type_refs.each_key do |start_type|
810
+ visited = Set.new
811
+ check_type_cycle(start_type, start_type, visited, type_refs)
812
+ end
813
+ end
814
+
815
+ def check_type_cycle(current, start_type, visited, type_refs)
816
+ return if visited.include?(current)
817
+ visited.add(current)
818
+
819
+ (type_refs[current] || []).each do |ref|
820
+ if ref == start_type
821
+ add_error(
822
+ code: Errors::ValidationErrorCode::CIRCULAR_REFERENCE,
823
+ path: "@#{start_type}",
824
+ message: "Circular reference detected in schema types: @#{start_type} -> @#{current} -> @#{ref}"
825
+ )
826
+ return
827
+ end
828
+ check_type_cycle(ref, start_type, visited, type_refs)
829
+ end
830
+ end
831
+
832
+ # ── V013: Unresolved reference ──
833
+
834
+ def check_unresolved_references
835
+ @doc.each_assignment do |path, value|
836
+ next unless value.is_a?(Types::OdinReference)
837
+
838
+ ref_path = value.path
839
+ unless @doc.include?(ref_path) || ref_path_matches_any?(ref_path)
840
+ add_error(
841
+ code: Errors::ValidationErrorCode::UNRESOLVED_REFERENCE,
842
+ path: path,
843
+ message: "Reference '@#{ref_path}' at '#{path}' does not resolve to any path",
844
+ expected: "valid path",
845
+ actual: ref_path
846
+ )
847
+ end
848
+ end
849
+
850
+ # Also check type references in schema
851
+ @schema.fields.each do |path, field|
852
+ next unless field.type_ref
853
+ check_type_reference(path, field.type_ref)
854
+ end
855
+ end
856
+
857
+ def check_type_reference(path, type_ref)
858
+ # Strip leading @ or @@ from type reference for lookup
859
+ clean_ref = type_ref.sub(/\A@+/, "")
860
+ return if @schema.types.key?(type_ref)
861
+ return if @schema.types.key?(clean_ref)
862
+ # Check with namespace prefixes
863
+ return if @schema.types.any? { |name, _| name.end_with?(clean_ref) }
864
+
865
+ add_error(
866
+ code: Errors::ValidationErrorCode::UNRESOLVED_REFERENCE,
867
+ path: path,
868
+ message: "Type reference '@@#{clean_ref}' at '#{path}' does not resolve to any type",
869
+ expected: "valid type name",
870
+ actual: type_ref
871
+ )
872
+ end
873
+
874
+ def ref_path_matches_any?(ref_path)
875
+ # Check if ref_path with wildcard matches any document path
876
+ return false unless ref_path.include?("*")
877
+ pattern = Regexp.new("\\A#{ref_path.gsub('*', '.*')}\\z")
878
+ @doc.paths.any? { |p| pattern.match?(p) }
879
+ end
880
+
881
+ # ── Helpers ──
882
+
883
+ def doc_has_value?(path)
884
+ value = @doc.get(path)
885
+ !value.nil? && !value.null?
886
+ end
887
+
888
+ def doc_section_exists?(section)
889
+ @doc.paths.any? { |p| p == section || p.start_with?("#{section}.") || p.start_with?("#{section}[") }
890
+ end
891
+
892
+ # Find document paths where a type is used via type references
893
+ def find_type_usage_paths(type_name)
894
+ paths = []
895
+ @schema.fields.each do |field_path, field|
896
+ next unless field.type_ref
897
+ clean_ref = field.type_ref.sub(/\A@+/, "")
898
+ paths << field_path if clean_ref == type_name
899
+ end
900
+ paths
901
+ end
902
+
903
+ def array_exists?(array_path)
904
+ @doc.paths.any? { |p| p.start_with?("#{array_path}[") || p.start_with?("#{array_path}[].") }
905
+ end
906
+
907
+ def count_array_items(array_path)
908
+ max_index = -1
909
+ # Support both path formats: "items[0].field" and "items[].[0].field"
910
+ prefixes = ["#{array_path}[", "#{array_path}[].["]
911
+ @doc.paths.each do |p|
912
+ prefixes.each do |prefix|
913
+ next unless p.start_with?(prefix)
914
+ match = p[prefix.length..].match(/\A(\d+)/)
915
+ if match
916
+ idx = match[1].to_i
917
+ max_index = idx if idx > max_index
918
+ end
919
+ end
920
+ end
921
+ max_index + 1
922
+ end
923
+
924
+ def collect_item_values(array_path, index)
925
+ # Support both path formats
926
+ prefixes = ["#{array_path}[#{index}]", "#{array_path}[].[#{index}]"]
927
+ values = []
928
+ @doc.each_assignment do |path, value|
929
+ if prefixes.any? { |pfx| path.start_with?(pfx) }
930
+ values << [path, extract_value_for_comparison(value)]
931
+ end
932
+ end
933
+ values.sort_by(&:first).map { |_, v| v }.join("|")
934
+ end
935
+
936
+ def extract_value_for_comparison(value)
937
+ case value
938
+ when Types::OdinString then value.value
939
+ when Types::OdinInteger, Types::OdinNumber, Types::OdinCurrency,
940
+ Types::OdinPercent then value.value.to_s
941
+ when Types::OdinBoolean then value.value.to_s
942
+ when Types::OdinNull then ""
943
+ when Types::OdinDate, Types::OdinTimestamp, Types::OdinTime,
944
+ Types::OdinDuration then value.to_s
945
+ when Types::OdinReference then value.path
946
+ else value.to_s
947
+ end
948
+ end
949
+
950
+ def extract_numeric_value(value)
951
+ case value
952
+ when Types::OdinInteger, Types::OdinNumber, Types::OdinCurrency, Types::OdinPercent
953
+ value.value.to_f
954
+ else
955
+ nil
956
+ end
957
+ end
958
+
959
+ def has_active_conditional?(field)
960
+ !field.conditionals.empty?
961
+ end
962
+
963
+ # Iterate over all schema fields paired with their document values
964
+ def each_schema_field
965
+ @schema.fields.each do |path, field|
966
+ value = @doc.get(path)
967
+ yield path, field, value if value
968
+ end
969
+
970
+ @schema.types.each do |type_name, schema_type|
971
+ schema_type.fields.each do |field_name, field|
972
+ full_path = "#{type_name}.#{field_name}"
973
+ value = @doc.get(full_path)
974
+ yield full_path, field, value if value
975
+ end
976
+ end
977
+
978
+ @schema.arrays.each do |array_path, schema_array|
979
+ count = count_array_items(array_path)
980
+ count.times do |i|
981
+ schema_array.item_fields.each do |field_name, field|
982
+ # Try both path formats
983
+ full_path = "#{array_path}[#{i}].#{field_name}"
984
+ value = @doc.get(full_path)
985
+ unless value
986
+ alt_path = "#{array_path}[].[#{i}].#{field_name}"
987
+ value = @doc.get(alt_path)
988
+ full_path = alt_path if value
989
+ end
990
+ yield full_path, field, value if value
991
+ end
992
+ end
993
+ end
994
+ end
995
+
996
+ # Iterate over fields with a specific constraint kind
997
+ def each_schema_field_with_constraints(kind)
998
+ each_schema_field do |path, field, value|
999
+ field.constraints.each do |constraint|
1000
+ yield path, field, value, constraint if constraint.kind == kind
1001
+ end
1002
+ end
1003
+ end
1004
+
1005
+ # Iterate over fields with conditionals
1006
+ def each_schema_field_with_conditionals
1007
+ @schema.fields.each do |path, field|
1008
+ yield path, field, field.conditionals unless field.conditionals.empty?
1009
+ end
1010
+
1011
+ @schema.types.each do |type_name, schema_type|
1012
+ schema_type.fields.each do |field_name, field|
1013
+ unless field.conditionals.empty?
1014
+ yield "#{type_name}.#{field_name}", field, field.conditionals
1015
+ end
1016
+ end
1017
+ end
1018
+ end
1019
+
1020
+ # Format validation (part of V004)
1021
+ def check_format_constraints
1022
+ each_schema_field_with_constraints(:format) do |path, field, value, constraint|
1023
+ next if value.nil? || value.null?
1024
+
1025
+ # For non-string values, extract string representation for format check
1026
+ if value.string?
1027
+ val_str = value.value
1028
+ elsif value.date? || value.timestamp? || value.time?
1029
+ val_str = value.to_s
1030
+ else
1031
+ next # Non-string, non-temporal values skip format checks
1032
+ end
1033
+
1034
+ # date-iso: validate against YYYY-MM-DD pattern (matches TypeScript)
1035
+ if constraint.format_name == "date-iso"
1036
+ unless val_str.match?(/\A\d{4}-\d{2}-\d{2}\z/)
1037
+ add_error(
1038
+ code: Errors::ValidationErrorCode::PATTERN_MISMATCH,
1039
+ path: path,
1040
+ message: "Value does not match format 'date-iso' at '#{path}'",
1041
+ expected: "date-iso",
1042
+ actual: val_str
1043
+ )
1044
+ end
1045
+ next
1046
+ end
1047
+
1048
+ unless FormatValidators.validate(constraint.format_name, val_str)
1049
+ add_error(
1050
+ code: Errors::ValidationErrorCode::PATTERN_MISMATCH,
1051
+ path: path,
1052
+ message: "Value does not match format '#{constraint.format_name}' at '#{path}'",
1053
+ expected: constraint.format_name,
1054
+ actual: val_str
1055
+ )
1056
+ end
1057
+ end
1058
+ end
1059
+ end
1060
+ end
1061
+ end