schemurai 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.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 6c7e69c0dbcafac0bb00bc78aa315932cfd59a141d0727e1a6e4366f80dc416b
4
+ data.tar.gz: 1ddb9269a448dc6c088a7bf22f341e064c2bb3cd0712da6750fc29545379b268
5
+ SHA512:
6
+ metadata.gz: 5f201d9a44db39000dc36081d9212e221e3e1ba69d4650dcdd929f9709a401cbb6de1412587344246ed736bec0da0e576b19daadc9d91cda5eb4a4d9924d00a8
7
+ data.tar.gz: 604dd071010fffc73639338b7f84eefa9328f7c574156cd52077f1d6f6ae9c6ebe0ff1360c2c80c29328659242547b3a3652f0165d926ac944ab1b1fcb025c41
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Kuya Kohara
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,206 @@
1
+ # Schemurai
2
+
3
+ A small, light-weight-dependency JSON Schema validator for Ruby supporting Draft 7,
4
+ Draft 2019-09, and Draft 2020-12. It covers the required cases in the official
5
+ JSON-Schema-Test-Suite, as well as the applicable optional tests for numeric
6
+ precision, ECMA-262 regular expressions, content validation, anchors, and dynamic
7
+ references. The dialect is selected from the schema's `$schema` URI; schemas that
8
+ omit `$schema` use Draft 7 for compatibility.
9
+
10
+ ```ruby
11
+ require "schemurai"
12
+
13
+ schema = {
14
+ "$schema" => "https://json-schema.org/draft/2020-12/schema",
15
+ "type" => "object",
16
+ "properties" => {
17
+ "order_id" => { "type" => "string", "pattern" => "^ord_[0-9]{6}$" },
18
+ "customer" => { "$ref" => "#/$defs/customer" },
19
+ "items" => {
20
+ "type" => "array",
21
+ "minItems" => 1,
22
+ "items" => { "$ref" => "#/$defs/line_item" }
23
+ },
24
+ "created_at" => { "type" => "string", "format" => "date-time" }
25
+ },
26
+ "required" => %w[order_id customer items created_at],
27
+ "additionalProperties" => false,
28
+ "$defs" => {
29
+ "customer" => {
30
+ "type" => "object",
31
+ "properties" => {
32
+ "id" => { "type" => "integer", "minimum" => 1 },
33
+ "name" => { "type" => "string", "minLength" => 1 },
34
+ "tier" => { "enum" => %w[standard premium] }
35
+ },
36
+ "required" => %w[id name],
37
+ "additionalProperties" => false
38
+ },
39
+ "line_item" => {
40
+ "type" => "object",
41
+ "properties" => {
42
+ "sku" => { "type" => "string", "pattern" => "^SKU-[A-Z0-9]{8}$" },
43
+ "quantity" => { "type" => "integer", "minimum" => 1 },
44
+ "unit_price" => { "type" => "number", "minimum" => 0 }
45
+ },
46
+ "required" => %w[sku quantity unit_price],
47
+ "additionalProperties" => false
48
+ }
49
+ }
50
+ }
51
+
52
+ order = {
53
+ "order_id" => "ord_123456",
54
+ "customer" => { "id" => 42, "name" => "Ada Lovelace", "tier" => "premium" },
55
+ "items" => [
56
+ { "sku" => "SKU-ABC12345", "quantity" => 2, "unit_price" => 19.95 }
57
+ ],
58
+ "created_at" => "2026-08-31T10:15:00+09:00"
59
+ }
60
+
61
+ validator = Schemurai.compile(schema, format: true)
62
+ validator.valid?(order) # => true
63
+
64
+ invalid_order = order.merge(
65
+ "items" => [order.fetch("items").first.merge("quantity" => 0)]
66
+ )
67
+ result = validator.validate(invalid_order)
68
+ result.valid? # => false
69
+ result.errors.each do |error|
70
+ puts "#{error.instance_path}: #{error.message}"
71
+ end
72
+ ```
73
+
74
+ For repeated validation, compile the schema once and reuse the validator. A
75
+ schema registry owns the compiled resource graph, so schemas compiled by the
76
+ same registry also share their compiled external references.
77
+
78
+ ```ruby
79
+ registry = Schemurai::SchemaRegistry.new(
80
+ schemas: {
81
+ "https://example.test/positive" => { "type" => "integer", "minimum" => 1 }
82
+ }
83
+ )
84
+ validator = registry.compile({"$ref" => "https://example.test/positive"})
85
+
86
+ validator.valid?(1) # => true
87
+ validator.valid?(0) # => false
88
+ validator.validate(0).errors # detailed errors, without recompiling the schema
89
+ ```
90
+
91
+ `Schemurai.compile` is a convenience for compiling a standalone
92
+ validator. Repeatedly compiling the same schema object with one registry reuses
93
+ its compiled schema graph. `Schemurai.validate` and `.valid?` continue
94
+ to accept raw JSON-like schemas and perform compilation internally.
95
+
96
+ To resolve external references, pass a mapping of URIs to schemas using
97
+ `schemas:`.
98
+
99
+ ```ruby
100
+ Schemurai.valid?(
101
+ { "$ref" => "https://example.test/positive" },
102
+ 3,
103
+ schemas: { "https://example.test/positive" => { "type" => "integer", "minimum" => 1 } }
104
+ )
105
+ ```
106
+
107
+ Draft 2019-09 and Draft 2020-12 support their dialect-specific keywords, including
108
+ `$recursiveRef` / `$dynamicRef`, `$defs`, `dependentSchemas`, `dependentRequired`,
109
+ `minContains`, `maxContains`, and the `unevaluated*` applicators. Enable optional
110
+ validation for `contentEncoding` and `contentMediaType` with `content: true`.
111
+ Enable optional format assertions with `format: true`; support for each format is
112
+ listed separately below.
113
+
114
+ ### Thread / Ractor native feature
115
+
116
+ To share a registry between threads or Ractors, finish registering schemas and
117
+ make the registry shareable first. This eagerly compiles every registered
118
+ schema, resolves all references, and makes the registry deeply immutable.
119
+
120
+ ```ruby
121
+ registry = Schemurai::SchemaRegistry.new(
122
+ schemas: {
123
+ "https://example.test/positive" => { "type" => "integer", "minimum" => 1 },
124
+ "https://example.test/value" => { "$ref" => "https://example.test/positive" }
125
+ }
126
+ )
127
+ registry.make_shareable
128
+
129
+ # Each thread or Ractor creates and owns its validator.
130
+ validator = registry.validator_for("https://example.test/value")
131
+ ```
132
+
133
+ `make_shareable` calls `Ractor.make_shareable` internally. It raises a
134
+ `ResolutionError` if a reference cannot be resolved. After it returns,
135
+ `validator_for` is read-only and may be called concurrently, while `compile` is
136
+ no longer available. A `Validator` contains per-validation mutable state and
137
+ must not be shared between threads or Ractors.
138
+
139
+ ## JSON Schema conformance
140
+
141
+ | Capability | Draft 7 | Draft 2019-09 | Draft 2020-12 |
142
+ | --- | --- | --- | --- |
143
+ | Required JSON-Schema-Test-Suite cases | Supported | Supported | Supported |
144
+ | Dialect-specific references | `$ref` | `$ref`, `$recursiveRef` | `$ref`, `$dynamicRef` |
145
+ | `unevaluatedItems` / `unevaluatedProperties` | Not applicable | Supported | Supported |
146
+ | `contentEncoding` / `contentMediaType` assertions | Opt-in[^content] | Opt-in[^content] | Opt-in[^content] |
147
+
148
+ The required-suite row covers every required case for the listed dialect in the
149
+ [official JSON Schema Test Suite](https://github.com/json-schema-org/JSON-Schema-Test-Suite).
150
+ The applicable top-level optional cases are also tested, including arbitrary
151
+ precision numbers, ECMA-262 regular expressions, anchors, cross-draft references,
152
+ and dynamic references.
153
+
154
+ ### Format assertion support
155
+
156
+ Formats are annotations by default in each standard dialect. Pass `format: true`
157
+ to enable the supported assertions below.[^format]
158
+
159
+ | Format | Assertion support |
160
+ | --- | --- |
161
+ | `date` | Supported |
162
+ | `time` | Supported |
163
+ | `date-time` | Supported |
164
+ | `duration` | Supported |
165
+ | `email` | Not supported |
166
+ | `idn-email` | Not supported |
167
+ | `hostname` | Not supported |
168
+ | `idn-hostname` | Not supported |
169
+ | `ipv4` | Supported |
170
+ | `ipv6` | Supported |
171
+ | `uri` | Not supported |
172
+ | `uri-reference` | Not supported |
173
+ | `iri` | Not supported |
174
+ | `iri-reference` | Not supported |
175
+ | `uuid` | Supported |
176
+ | `uri-template` | Not supported |
177
+ | `json-pointer` | Supported |
178
+ | `relative-json-pointer` | Supported |
179
+ | `regex` | Not supported |
180
+
181
+ Unsupported and unknown formats remain annotations when assertion is enabled by
182
+ the caller.
183
+
184
+ [^content]: Pass `content: true` to assert Base64 `contentEncoding` and JSON
185
+ `contentMediaType`. Other encodings and media types remain annotations.
186
+ [^format]: A custom Draft 2020-12 meta-schema that declares the Format-Assertion
187
+ vocabulary can assert supported formats without the option and rejects
188
+ unsupported formats during schema compilation.
189
+
190
+ ## Development
191
+
192
+ Run the test suite with:
193
+
194
+ ```sh
195
+ bundle exec rspec
196
+ ```
197
+
198
+ Run the linter with:
199
+
200
+ ```sh
201
+ bundle exec rubocop
202
+ ```
203
+
204
+ ## AI Disclosure
205
+
206
+ Large part of this work is generated by OpenAI Codex.
@@ -0,0 +1,118 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Schemurai
4
+ module Internal
5
+ class Dialect
6
+ TYPE = 1
7
+ ENUM = 2
8
+ COMBINER = 4
9
+ NUMBER = 8
10
+ STRING = 16
11
+ ARRAY = 32
12
+ OBJECT = 64
13
+
14
+ Keyword = Data.define(:mask, :subschema_shape) do
15
+ def initialize(mask:, subschema_shape: nil)
16
+ super
17
+ end
18
+ end
19
+
20
+ class << self
21
+ def register(dialect, default: false)
22
+ registry[normalize_uri(dialect.uri)] = dialect
23
+ @default = dialect if default
24
+ dialect
25
+ end
26
+
27
+ def resolve(uri = nil)
28
+ return @default if uri.nil?
29
+
30
+ registry[normalize_uri(uri)]
31
+ end
32
+
33
+ private def registry
34
+ @registry ||= {}
35
+ end
36
+
37
+ private def normalize_uri(uri)
38
+ uri.to_s.delete_suffix("#")
39
+ end
40
+ end
41
+
42
+ attr_reader :name, :uri, :keywords
43
+
44
+ def initialize(name:, uri:, keywords:, ref_siblings:, format_assertion: false)
45
+ @name = name
46
+ @uri = uri
47
+ @keywords = keywords.freeze
48
+ @ref_siblings = ref_siblings
49
+ @format_assertion = format_assertion
50
+ freeze
51
+ end
52
+
53
+ def ref_siblings?
54
+ @ref_siblings
55
+ end
56
+
57
+ def format_assertion?
58
+ @format_assertion
59
+ end
60
+
61
+ def draft7?
62
+ name == :draft7
63
+ end
64
+
65
+ def draft2019?
66
+ name == :draft2019_09
67
+ end
68
+
69
+ def draft2020?
70
+ name == :draft2020_12
71
+ end
72
+
73
+ def keyword_mask(schema)
74
+ schema.each_key.reduce(0) do |mask, keyword|
75
+ mask | (keywords[keyword]&.mask || 0)
76
+ end
77
+ end
78
+
79
+ def each_subschema(schema)
80
+ return enum_for(__method__, schema) unless block_given?
81
+ return unless schema.is_a?(Hash)
82
+ return if schema.key?("$ref") && !ref_siblings?
83
+
84
+ schema.each do |keyword, value|
85
+ specification = keywords[keyword]
86
+ next unless specification&.subschema_shape
87
+
88
+ case specification.subschema_shape
89
+ when :single
90
+ yield value, [keyword] if schema?(value)
91
+ when :single_or_list
92
+ if value.is_a?(Array)
93
+ value.each_with_index { |child, index| yield child, [keyword, index] if schema?(child) }
94
+ elsif schema?(value)
95
+ yield value, [keyword]
96
+ end
97
+ when :list
98
+ Array(value).each_with_index { |child, index| yield child, [keyword, index] if schema?(child) }
99
+ when :map
100
+ value.each { |name, child| yield child, [keyword, name] if schema?(child) } if value.is_a?(Hash)
101
+ when :dependencies
102
+ if value.is_a?(Hash)
103
+ value.each do |name, child|
104
+ yield child, [keyword, name] if schema?(child)
105
+ end
106
+ end
107
+ end
108
+ end
109
+ end
110
+
111
+ private def schema?(value)
112
+ value == true || value == false || value.is_a?(Hash)
113
+ end
114
+ end
115
+ end
116
+
117
+ private_constant :Internal
118
+ end
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "dialect"
4
+
5
+ module Schemurai
6
+ module Internal
7
+ module DialectKeywords
8
+ COMMON = {
9
+ "type" => Dialect::Keyword.new(mask: Dialect::TYPE),
10
+ "enum" => Dialect::Keyword.new(mask: Dialect::ENUM),
11
+ "const" => Dialect::Keyword.new(mask: Dialect::ENUM),
12
+ "allOf" => Dialect::Keyword.new(mask: Dialect::COMBINER, subschema_shape: :list),
13
+ "anyOf" => Dialect::Keyword.new(mask: Dialect::COMBINER, subschema_shape: :list),
14
+ "oneOf" => Dialect::Keyword.new(mask: Dialect::COMBINER, subschema_shape: :list),
15
+ "not" => Dialect::Keyword.new(mask: Dialect::COMBINER, subschema_shape: :single),
16
+ "if" => Dialect::Keyword.new(mask: Dialect::COMBINER, subschema_shape: :single),
17
+ "then" => Dialect::Keyword.new(mask: 0, subschema_shape: :single),
18
+ "else" => Dialect::Keyword.new(mask: 0, subschema_shape: :single),
19
+ "maximum" => Dialect::Keyword.new(mask: Dialect::NUMBER),
20
+ "minimum" => Dialect::Keyword.new(mask: Dialect::NUMBER),
21
+ "exclusiveMaximum" => Dialect::Keyword.new(mask: Dialect::NUMBER),
22
+ "exclusiveMinimum" => Dialect::Keyword.new(mask: Dialect::NUMBER),
23
+ "multipleOf" => Dialect::Keyword.new(mask: Dialect::NUMBER),
24
+ "maxLength" => Dialect::Keyword.new(mask: Dialect::STRING),
25
+ "minLength" => Dialect::Keyword.new(mask: Dialect::STRING),
26
+ "pattern" => Dialect::Keyword.new(mask: Dialect::STRING),
27
+ "contentEncoding" => Dialect::Keyword.new(mask: Dialect::STRING),
28
+ "contentMediaType" => Dialect::Keyword.new(mask: Dialect::STRING),
29
+ "maxItems" => Dialect::Keyword.new(mask: Dialect::ARRAY),
30
+ "minItems" => Dialect::Keyword.new(mask: Dialect::ARRAY),
31
+ "uniqueItems" => Dialect::Keyword.new(mask: Dialect::ARRAY),
32
+ "contains" => Dialect::Keyword.new(mask: Dialect::ARRAY, subschema_shape: :single),
33
+ "maxProperties" => Dialect::Keyword.new(mask: Dialect::OBJECT),
34
+ "minProperties" => Dialect::Keyword.new(mask: Dialect::OBJECT),
35
+ "required" => Dialect::Keyword.new(mask: Dialect::OBJECT),
36
+ "properties" => Dialect::Keyword.new(mask: Dialect::OBJECT, subschema_shape: :map),
37
+ "patternProperties" => Dialect::Keyword.new(mask: Dialect::OBJECT, subschema_shape: :map),
38
+ "additionalProperties" => Dialect::Keyword.new(mask: Dialect::OBJECT, subschema_shape: :single),
39
+ "propertyNames" => Dialect::Keyword.new(mask: Dialect::OBJECT, subschema_shape: :single)
40
+ }.freeze
41
+
42
+ LEGACY = {
43
+ "dependencies" => Dialect::Keyword.new(mask: Dialect::OBJECT, subschema_shape: :dependencies),
44
+ "definitions" => Dialect::Keyword.new(mask: 0, subschema_shape: :map)
45
+ }.freeze
46
+
47
+ MODERN = {
48
+ "$defs" => Dialect::Keyword.new(mask: 0, subschema_shape: :map),
49
+ "$recursiveRef" => Dialect::Keyword.new(mask: Dialect::COMBINER),
50
+ "dependentSchemas" => Dialect::Keyword.new(mask: Dialect::OBJECT, subschema_shape: :map),
51
+ "dependentRequired" => Dialect::Keyword.new(mask: Dialect::OBJECT),
52
+ "minContains" => Dialect::Keyword.new(mask: Dialect::ARRAY),
53
+ "maxContains" => Dialect::Keyword.new(mask: Dialect::ARRAY),
54
+ "unevaluatedItems" => Dialect::Keyword.new(mask: Dialect::ARRAY, subschema_shape: :single),
55
+ "unevaluatedProperties" => Dialect::Keyword.new(mask: Dialect::OBJECT, subschema_shape: :single),
56
+ "contentSchema" => Dialect::Keyword.new(mask: Dialect::STRING, subschema_shape: :single)
57
+ }.freeze
58
+
59
+ private_constant :COMMON, :LEGACY, :MODERN
60
+
61
+ module_function def draft7
62
+ COMMON.merge(
63
+ LEGACY,
64
+ "items" => Dialect::Keyword.new(mask: Dialect::ARRAY, subschema_shape: :single_or_list),
65
+ "additionalItems" => Dialect::Keyword.new(mask: Dialect::ARRAY, subschema_shape: :single)
66
+ ).freeze
67
+ end
68
+
69
+ module_function def draft2019_09
70
+ COMMON.merge(
71
+ LEGACY,
72
+ MODERN,
73
+ "items" => Dialect::Keyword.new(mask: Dialect::ARRAY, subschema_shape: :single_or_list),
74
+ "additionalItems" => Dialect::Keyword.new(mask: Dialect::ARRAY, subschema_shape: :single)
75
+ ).freeze
76
+ end
77
+
78
+ module_function def draft2020_12
79
+ COMMON.merge(
80
+ LEGACY,
81
+ MODERN,
82
+ "$dynamicRef" => Dialect::Keyword.new(mask: Dialect::COMBINER),
83
+ "prefixItems" => Dialect::Keyword.new(mask: Dialect::ARRAY, subschema_shape: :list),
84
+ "items" => Dialect::Keyword.new(mask: Dialect::ARRAY, subschema_shape: :single)
85
+ ).freeze
86
+ end
87
+ end
88
+ end
89
+
90
+ private_constant :Internal
91
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../dialect_keywords"
4
+
5
+ module Schemurai
6
+ module Internal
7
+ module Dialects
8
+ module Draft201909
9
+ META_SCHEMA_URI = "https://json-schema.org/draft/2019-09/schema"
10
+ KEYWORDS = DialectKeywords.draft2019_09
11
+
12
+ DIALECT = Dialect.new(
13
+ name: :draft2019_09,
14
+ uri: META_SCHEMA_URI,
15
+ keywords: KEYWORDS,
16
+ ref_siblings: true
17
+ )
18
+
19
+ Dialect.register(DIALECT)
20
+
21
+ private_constant :META_SCHEMA_URI, :KEYWORDS
22
+ end
23
+ end
24
+ end
25
+
26
+ private_constant :Internal
27
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../dialect_keywords"
4
+
5
+ module Schemurai
6
+ module Internal
7
+ module Dialects
8
+ module Draft202012
9
+ META_SCHEMA_URI = "https://json-schema.org/draft/2020-12/schema"
10
+ KEYWORDS = DialectKeywords.draft2020_12
11
+
12
+ DIALECT = Dialect.new(
13
+ name: :draft2020_12,
14
+ uri: META_SCHEMA_URI,
15
+ keywords: KEYWORDS,
16
+ ref_siblings: true
17
+ )
18
+
19
+ Dialect.register(DIALECT)
20
+
21
+ private_constant :META_SCHEMA_URI, :KEYWORDS
22
+ end
23
+ end
24
+ end
25
+
26
+ private_constant :Internal
27
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../dialect_keywords"
4
+
5
+ module Schemurai
6
+ module Internal
7
+ module Dialects
8
+ module Draft7
9
+ META_SCHEMA_URI = "http://json-schema.org/draft-07/schema"
10
+ KEYWORDS = DialectKeywords.draft7
11
+
12
+ DIALECT = Dialect.new(
13
+ name: :draft7,
14
+ uri: META_SCHEMA_URI,
15
+ keywords: KEYWORDS,
16
+ ref_siblings: false
17
+ )
18
+
19
+ Dialect.register(DIALECT, default: true)
20
+
21
+ private_constant :META_SCHEMA_URI, :KEYWORDS
22
+ end
23
+ end
24
+ end
25
+
26
+ private_constant :Internal
27
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Schemurai
4
+ # Internal result of evaluating one schema location. Draft 7 only needs the
5
+ # validity bit, but newer drafts also propagate the instance locations
6
+ # consumed by applicators for unevaluatedProperties and unevaluatedItems.
7
+ class Evaluation
8
+ EMPTY_LOCATIONS = [].freeze
9
+
10
+ attr_reader :evaluated_properties, :evaluated_items
11
+
12
+ def self.valid(evaluated_properties: EMPTY_LOCATIONS, evaluated_items: EMPTY_LOCATIONS)
13
+ if evaluated_properties.equal?(EMPTY_LOCATIONS) && evaluated_items.equal?(EMPTY_LOCATIONS)
14
+ return @valid ||= new(true, EMPTY_LOCATIONS, EMPTY_LOCATIONS)
15
+ end
16
+
17
+ new(true, evaluated_properties, evaluated_items)
18
+ end
19
+
20
+ def self.invalid
21
+ @invalid ||= new(false, EMPTY_LOCATIONS, EMPTY_LOCATIONS)
22
+ end
23
+
24
+ def initialize(valid, evaluated_properties, evaluated_items)
25
+ @valid = valid
26
+ @evaluated_properties = evaluated_properties.freeze
27
+ @evaluated_items = evaluated_items.freeze
28
+ freeze
29
+ end
30
+
31
+ def valid?
32
+ @valid
33
+ end
34
+
35
+ def merge(other)
36
+ return self.class.invalid unless valid? && other.valid?
37
+
38
+ self.class.valid(
39
+ evaluated_properties: (evaluated_properties | other.evaluated_properties),
40
+ evaluated_items: (evaluated_items | other.evaluated_items)
41
+ )
42
+ end
43
+ end
44
+
45
+ private_constant :Evaluation
46
+ end