lutaml-model 0.8.96 → 0.8.98

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: 7b4bd8b10dc92c3a53f615bb3e13c907d1cc4976eb547a5a2da41aa2837bef17
4
- data.tar.gz: e1e5eacb6d80dc8108d65a5264d5e9d872d4259103672f6a88e0d912b437002f
3
+ metadata.gz: e9f7cb26a9f87fbfe0feb461b25dd3e8b50e32d64bbbd32b23f848e96355965b
4
+ data.tar.gz: 1961e58a64424ca1d69181f3e873d107e8f1d8762f1d1155c1de953c883ceaa1
5
5
  SHA512:
6
- metadata.gz: efa1a2368c6f2f0352dd928eb33c4c45c9580a431ca3847fe709fcb1026f29deaf2f55e7ec52eafaebdac537ef0c5d55588b22d37d6f6bb27d332165ad9a795a
7
- data.tar.gz: 5380981def9666c4ec1fa0f4cb7f977dcba141a5278003a96486d814e7c812d2083339baa3d9d8079c3029d7aef0e84226be40eb188315bd5270c974baed1dda
6
+ metadata.gz: 8624d39f5b4d556f6c72e7f76fa6ab262490a1583d5776756373dd4ea220bb1ba89e3f57f7774efa8f5f8c16f631f312ac149ed93ed651c15223068265bea629
7
+ data.tar.gz: 1249cac19b6f80880f5eb0c29cb0b2df17ff5813021573f6c2b63c923f6b2d63d2f579b9d14ebf18b8dae774df208787cef61cbfc507848fd99afffbc28b1404
@@ -67,11 +67,22 @@ When the XML adapter resolves to leptris, XML (de)serialization
67
67
  compiles the model's mapping into a leptris descriptor plan and runs
68
68
  the whole document through one native pass — measured 6.7x parse and
69
69
  4x serialize with -76% / -80% allocations on the plan-path corpus.
70
- Models the plan cannot express exactly (same-name discriminator
71
- partitions, namespace-qualified models with deferred-rule shapes) and
72
- shapes with custom semantics (custom methods, polymorphism, unions,
73
- ordered children) hydrate through the interpretive pipeline or a
74
- per-subtree fragment of it, unchanged.
70
+ Namespace-qualified models ride the path too: serialization spells
71
+ prefixed element and attribute names through the same namespace
72
+ planning the interpretive writer uses, and every `(prefix, uri)`
73
+ pair is declared once on the output root. Models the plan cannot
74
+ express exactly (same-name discriminator partitions, deferred-rule
75
+ shapes) and shapes with custom semantics (custom methods,
76
+ polymorphism, unions, ordered children) hydrate through the
77
+ interpretive pipeline or a per-subtree fragment of it, unchanged.
78
+
79
+ Version floor note: leptris 1.9.320+ retains child-row namespace
80
+ URIs engine-side and matches plain attribute rows against
81
+ namespace-qualified wire spellings. On older engines, models whose
82
+ plans carry exact-URI child rows or plain attribute rows under a
83
+ namespaced chain parse interpretively for correctness — a per-model
84
+ fallback, not an error. Bundles resolving leptris 1.9.320+ get the
85
+ full fast path.
75
86
 
76
87
  The path is on by default and is a no-op without leptris (standard
77
88
  adapters keep the classic pipeline). Opt out for the whole process:
@@ -1240,7 +1240,16 @@ instance_object = nil)
1240
1240
  # instead of three per serialized scalar. Reference and
1241
1241
  # custom-serializer rows keep the wrapped path.
1242
1242
  if resolved_type <= Type::Reference ||
1243
- Type::Value.format_type_serializer_for(format, resolved_type)&.dig(:to)
1243
+ (registered_to = Type::Value.format_type_serializer_for(format, resolved_type)&.dig(:to))
1244
+ # String→String under the default :xml serializer is the
1245
+ # identity — the wrapper mint per string value on the XML
1246
+ # serialize path is pure waste. A consumer override of the
1247
+ # serializer disables the fast path via the proc comparison.
1248
+ if resolved_type == Type::String && value.is_a?(::String) &&
1249
+ registered_to == Lutaml::Xml::Type::Serializers::STRING_XML_TO
1250
+ return value
1251
+ end
1252
+
1244
1253
  value = wrap_in_type_if_needed(value, resolved_type)
1245
1254
  return value.public_send(TO_FORMAT_METHODS[format] || :"to_#{format}")
1246
1255
  end
@@ -16,6 +16,21 @@ module Lutaml
16
16
 
17
17
  format_schema(schema, options)
18
18
  end
19
+
20
+ # Generate one schema document covering several model classes.
21
+ # The roots share a single $defs map and no top-level $ref is
22
+ # emitted; each root stays addressable under $defs by its class
23
+ # name. Use generate for a single root with a top-level $ref.
24
+ def generate_many(klasses, options = {})
25
+ schema = {
26
+ "$schema" => options[:schema],
27
+ "$id" => options[:id],
28
+ "description" => options[:description],
29
+ "$defs" => Generator::DefinitionsCollection.from_classes(klasses).to_schema,
30
+ }.compact
31
+
32
+ format_schema(schema, options)
33
+ end
19
34
  end
20
35
 
21
36
  attr_reader :schema, :klass
@@ -14,6 +14,16 @@ module Lutaml
14
14
  collection
15
15
  end
16
16
 
17
+ # Build one collection covering several roots. The seen-set
18
+ # is shared across roots so a class referenced by more than
19
+ # one of them registers once.
20
+ def from_classes(klasses)
21
+ collection = new
22
+ seen = {}
23
+ klasses.each { |klass| register_definition(collection, klass, seen) }
24
+ collection
25
+ end
26
+
17
27
  def process_attributes(collection, klass, seen = {})
18
28
  register = extract_register_from(klass)
19
29
  klass.attributes.each_value do |attribute|
@@ -95,7 +95,12 @@ module Lutaml
95
95
  options.key?(:mappings) || options.key?(:adapter) ||
96
96
  options.key?(:_adapter_override) ||
97
97
  options.key?(:indent) || options.key?(:xml_declaration) ||
98
- options.key?(:declaration) || options.key?(:doctype)
98
+ options.key?(:declaration) || options.key?(:doctype) ||
99
+ # prefix requests ride the interpretive writer's use_prefix
100
+ # option (planner Tier-1) — the plan emission has no
101
+ # option-aware spelling. The instance-level option prep
102
+ # rewrites :prefix to :use_prefix before this gate.
103
+ options.key?(:prefix) || options.key?(:use_prefix)
99
104
 
100
105
  adapter_name = Lutaml::Model::Config.adapter_for(:xml)
101
106
  adapter_name &&= adapter_name.name
@@ -133,6 +138,7 @@ module Lutaml
133
138
  register = Lutaml::Model::Config.default_register
134
139
  plan = Lutaml::Xml::Leptris::PlanCompiler.compile(self, register)
135
140
  return nil unless plan
141
+ return nil unless Lutaml::Xml::Leptris::PlanCompiler.engine_walk_safe?(plan)
136
142
 
137
143
  root = ::Leptris::XML.parse(data.to_s).root
138
144
  return nil if root.nil?
@@ -172,12 +172,30 @@ module Lutaml
172
172
  #
173
173
  # @param register [Symbol, nil] The register context
174
174
  def ensure_imports!(register = nil)
175
- ensure_model_imports!(register)
176
- ensure_choice_imports!(register)
177
- ensure_restrict_attributes!(register)
178
- # Hook for format-specific mapping import resolution.
179
- # XML overrides this to call mappings[:xml]&.ensure_mappings_imported!(register)
180
- ensure_format_mapping_imports!(register)
175
+ # Bounded re-entry for the resolution chain. Each leg's
176
+ # record_import_attempt tail calls attributes(), which calls
177
+ # back into ensure_imports!; nested passes converge imports
178
+ # whose targets only become resolvable after an earlier leg
179
+ # (so they must not be dropped), but with a permanently
180
+ # pending import the tail re-entered unboundedly and crashed
181
+ # with SystemStackError (#938). Two levels allow one nested
182
+ # convergence pass; deeper re-entry is suppressed per
183
+ # register and the counter unwinds on error.
184
+ register_id = extract_register_id(register)
185
+ (@ensure_imports_depth ||= {})[register_id] ||= 0
186
+ return if @ensure_imports_depth[register_id] >= 2
187
+
188
+ @ensure_imports_depth[register_id] += 1
189
+ begin
190
+ ensure_model_imports!(register)
191
+ ensure_choice_imports!(register)
192
+ ensure_restrict_attributes!(register)
193
+ # Hook for format-specific mapping import resolution.
194
+ # XML overrides this to call mappings[:xml]&.ensure_mappings_imported!(register)
195
+ ensure_format_mapping_imports!(register)
196
+ ensure
197
+ @ensure_imports_depth[register_id] -= 1
198
+ end
181
199
  end
182
200
 
183
201
  # Hook for format-specific mapping import resolution.
@@ -357,7 +375,7 @@ module Lutaml
357
375
  # chain's frames per level pushed that recursion past the
358
376
  # stack limit — models without pending deferred imports skip
359
377
  # it entirely.
360
- ensure_imports!(register_id) if finalized? && deferred_imports_pending?
378
+ ensure_imports!(register_id) if finalized? && deferred_imports_pending?(register_id)
361
379
  instance = allocate
362
380
  instance.finalize_deserialization(register_id)
363
381
  instance
@@ -162,11 +162,39 @@ module Lutaml
162
162
  # @param register_id [Symbol, nil] The register context
163
163
  # Whether deferred (symbol-form) imports are still pending —
164
164
  # gates the allocation-time ensure chain (#933).
165
- def deferred_imports_pending?
166
- importable_models.any? { |_method, models| models.any? } ||
167
- importable_choices.any? do |_choice, imports|
168
- imports.any? { |_method, models| models.any? }
165
+ # The attribute-set fingerprint at the moment an import
166
+ # resolution attempt ran — deferred_imports_pending? compares
167
+ # against it to skip re-resolution on stable sets.
168
+ def record_import_attempt(register_id)
169
+ # Raw stores only — the lazy attributes() re-enters
170
+ # ensure_imports! and, when @models_imported was just set to
171
+ # false (unresolved imports), cycles through this very
172
+ # method forever (the uniword register_in SystemStackError).
173
+ register_attrs = (@register_records ||= {})[register_id]
174
+ (@import_attempt_fingerprints ||= {})[register_id] =
175
+ @attributes.size + @choice_attributes.to_a.size +
176
+ (register_attrs ? register_attrs[:attributes].to_a.size : 0)
177
+ end
178
+
179
+ def deferred_imports_pending?(register_id = nil)
180
+ register_id ||= Lutaml::Model::Config.default_register
181
+ has_importables = importable_models.any? { |_m, models| models.any? } ||
182
+ importable_choices.any? do |_c, imports|
183
+ imports.any? { |_m, models| models.any? }
169
184
  end || restrict_attributes.any?
185
+ return false unless has_importables
186
+
187
+ # Unresolvable imports stay pending forever; re-running the
188
+ # ensure chain (and its state-defaults invalidation) on every
189
+ # allocation thrashed import-heavy models quadratically.
190
+ # Resolution re-arms only when the attribute set CHANGED
191
+ # since the last attempt — a late declaration after a failed
192
+ # resolution re-attempts; a stable set skips.
193
+ attempted = (@import_attempt_fingerprints ||= {})[register_id]
194
+ return true if attempted.nil?
195
+
196
+ current = attributes(register_id).size + @choice_attributes.to_a.size
197
+ current != attempted
170
198
  end
171
199
 
172
200
  def ensure_model_imports!(register_id = nil)
@@ -200,6 +228,7 @@ module Lutaml
200
228
  end
201
229
 
202
230
  @models_imported[register_id] = all_resolved
231
+ record_import_attempt(register_id)
203
232
  end
204
233
 
205
234
  # Ensure all choice imports are resolved for a specific register
@@ -239,6 +268,7 @@ module Lutaml
239
268
  end
240
269
 
241
270
  @choices_imported[register_id] = true if all_resolved
271
+ record_import_attempt(register_id)
242
272
  # Choice imports can add attributes without passing through
243
273
  # define_attribute_methods — recompile state defaults so the
244
274
  # seeding and raw readers cover them (#933). Only when an
@@ -13,6 +13,12 @@ module Lutaml
13
13
  case value
14
14
  when ::Time then value
15
15
  when ::DateTime then value.to_time
16
+ when ::Integer
17
+ # YAML 1.1 sexagesimal: psych resolves an unquoted hh:mm:ss
18
+ # scalar to seconds since midnight (25200 for "07:00:00").
19
+ # Feeding that integer through Time.parse yields a garbage
20
+ # date — reconstruct the wall-clock time of the current day.
21
+ ::Date.today.to_time + value
16
22
  else ::Time.parse(value.to_s)
17
23
  end
18
24
  rescue ArgumentError
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Lutaml
4
4
  module Model
5
- VERSION = "0.8.96"
5
+ VERSION = "0.8.98"
6
6
  end
7
7
  end
@@ -48,6 +48,12 @@ module Lutaml
48
48
  end
49
49
  end.cache
50
50
 
51
+ # Name-matched element row kinds eligible for the :unqualified
52
+ # ns form (leptris#1560). Text rows (content) match no element
53
+ # name and wildcard rows carry their own :any.
54
+ ROW_KINDS_NS_UNQUALIFIED = %i[scalar collection nested callback
55
+ raw].freeze
56
+
51
57
  class << self
52
58
  def compile(model_class, register)
53
59
  # Version gate, not a capability probe: the plan path needs
@@ -74,9 +80,13 @@ module Lutaml
74
80
  # is THREAD-LOCAL (a shared in-progress set races: a concurrent
75
81
  # same-key compile would cache false permanently — the #828
76
82
  # lesson); the nil at the cycle point is NOT cached — the
77
- # outermost build completes and caches the real verdict.
83
+ # outermost build completes and caches the real verdict. The
84
+ # :cycle stat makes the guard visible in plan_stats — without
85
+ # it a cyclic graph (uniword pict↔shape, sdt→sdt) reads as
86
+ # "nil with an empty histogram", indistinguishable from a
87
+ # silent build failure.
78
88
  stack = (Thread.current[:plan_compiler_stack] ||= [])
79
- return nil if stack.include?(key)
89
+ return opt_out!(:cycle) if stack.include?(key)
80
90
 
81
91
  stack.push(key)
82
92
  begin
@@ -86,8 +96,49 @@ module Lutaml
86
96
  end
87
97
  end
88
98
 
99
+ # Two engine walk defects gate walk-side consumers here; both
100
+ # bail models to the interpretive matcher, whose exact-first-
101
+ # then-any-qualification precedence binds what the walk drops.
102
+ # The serialize side (PlanSerializer) reads the Ruby tree and
103
+ # stays on the fast path. leptris 1.9.320.0 (#1587) fixes
104
+ # both defects — the scan is skipped there and every model
105
+ # rides the walk again.
106
+ #
107
+ # leptris#1585: exact-URI CHILD-row ns_uri strings are not
108
+ # retained engine-side — once the build anchors are GC'd (a
109
+ # warm walk plus one GC suffices), those rows stop matching
110
+ # and hydration silently answers nil.
111
+ #
112
+ # leptris#1586: plain attribute rows do not leniently match
113
+ # namespace-qualified wire attributes (uniword fontTable:
114
+ # every w:name/w:val against a plain attr row hydrates nil).
115
+ # A plan level carrying plain attr rows under a namespaced
116
+ # chain therefore cannot be trusted on the walk; attribute
117
+ # rows with an exact URI (or :any) and plans without any
118
+ # namespace form are unaffected.
119
+ def engine_walk_safe?(plan)
120
+ return true if Leptris.walk_ns_safety_compatible?
121
+
122
+ tree_safe?(plan[:tree], false)
123
+ end
124
+
89
125
  private
90
126
 
127
+ def tree_safe?(tree, ancestor_ns)
128
+ namespaced = ancestor_ns || tree[:ns].is_a?(Hash)
129
+ attrs = tree[:attributes] || []
130
+ return false if namespaced &&
131
+ attrs.any? { |a| !a[:ns].is_a?(Hash) && a[:ns] != :any }
132
+
133
+ (tree[:children] || []).all? do |row|
134
+ return false if row[:ns].is_a?(Hash)
135
+
136
+ next true unless row[:kind] == :nested && row[:plan]
137
+
138
+ tree_safe?(row[:plan], namespaced)
139
+ end
140
+ end
141
+
91
142
  def build(model_class, register)
92
143
  return nil unless model_class.is_a?(Class) &&
93
144
  model_class.include?(::Lutaml::Model::Serialize)
@@ -162,7 +213,7 @@ module Lutaml
162
213
  return opt_out!(:fragment_with_ns) if fragment_needed && model_ns
163
214
 
164
215
  if rule.attribute?
165
- opt = compile_attribute_row(rule, attr, register,
216
+ opt = compile_attribute_row(rule, attr, register, mapping,
166
217
  attr_rows, plan_attrs,
167
218
  ns_attr_names, attr_local_claims)
168
219
  return opt_out!(opt) if opt
@@ -225,7 +276,22 @@ module Lutaml
225
276
  return opt_out!(:non_scalar) unless scalar_type?(attr, register)
226
277
 
227
278
  row = { name: rule.name.to_s }
228
- row[:ns] = child_ns(rule, model_ns) if rule.namespace_set?
279
+ if rule.namespace_set?
280
+ row[:ns] = child_ns(rule, model_ns)
281
+ else
282
+ # The interpretive writer qualifies an element row
283
+ # by its attribute's type namespace when the rule
284
+ # declares none (resolve step 4). Rows without the
285
+ # type form inherit the tree namespace and land
286
+ # under the wrong default xmlns (uniword
287
+ # CoreProperties: <dc:title> spelled <title>).
288
+ type_ns_form = element_type_ns_form(rule, attr,
289
+ register, model_ns)
290
+ return opt_out!(:elem_type_ns_symbol) if type_ns_form == :bail_symbol
291
+ return opt_out!(:elem_type_ns_aliases) if type_ns_form == :bail_aliases
292
+
293
+ row[:ns] = type_ns_form if type_ns_form
294
+ end
229
295
  # lutaml-model#88: same-name rows partitioned by the
230
296
  # rule's discriminator ride the engine's exclusive
231
297
  # predicate match (leptris 1.9.221+, #1272).
@@ -267,6 +333,22 @@ module Lutaml
267
333
  # inherit the default xmlns, so the strict no-URI default
268
334
  # dropped every child of a namespaced document.
269
335
  flags << :ns_lenient
336
+
337
+ # Namespace-less model child rows bind the strict unwritten
338
+ # spelling (bare, or inherited default xmlns) on engines
339
+ # with the leptris#1560 :unqualified form — the interpretive
340
+ # matcher's exact no-namespace semantic, refusing prefixed
341
+ # spellings where the ns_lenient superset bound them.
342
+ # Rows with an explicit form (rule ns, type ns, :none, :any)
343
+ # are untouched; name-matched element row kinds only.
344
+ if model_ns.nil? && Leptris.ns_unqualified_rows_compatible?
345
+ rows.each do |r|
346
+ next if r[:ns]
347
+
348
+ r[:ns] = :unqualified if ROW_KINDS_NS_UNQUALIFIED.include?(r[:kind])
349
+ end
350
+ end
351
+
270
352
  tree = { name: mapping.root_element.to_s,
271
353
  attributes: plan_attrs, children: rows }
272
354
  tree[:ns] = model_ns if model_ns
@@ -337,8 +419,8 @@ module Lutaml
337
419
  # non-sole-claimant alias families have no plan-side
338
420
  # resolution order); :blank / :inherit arrive as Symbols —
339
421
  # no URI to express in a row.
340
- def compile_attribute_row(rule, attr, register, attr_rows,
341
- plan_attrs, ns_attr_names,
422
+ def compile_attribute_row(rule, attr, register, mapping,
423
+ attr_rows, plan_attrs, ns_attr_names,
342
424
  attr_local_claims)
343
425
  return :non_scalar unless scalar_type?(attr, register)
344
426
 
@@ -346,6 +428,24 @@ module Lutaml
346
428
  type_ns = attr.type_namespace_class(register)
347
429
  claim = attr_local_claims[rule.name.to_s]
348
430
  if type_ns.nil?
431
+ # attribute_form_default :qualified on the model's
432
+ # namespace qualifies locally-declared attributes under
433
+ # the parent URI — the interpretive
434
+ # resolve_attribute_namespace contract (WML: updateFields
435
+ # w:val under xmlns="..."). The W3C default is
436
+ # :unqualified, so == :qualified means explicitly set.
437
+ # Rules with an explicit :blank stay unprefixed.
438
+ ns_class = mapping.namespace_class
439
+ if rule.namespace_param != :blank && ns_class &&
440
+ ns_class.attribute_form_default == :qualified
441
+ claim[:ns] += 1
442
+ plan_attrs << { name: rule.name.to_s,
443
+ ns: { exact: ns_class.uri.to_s,
444
+ prefix: ns_class.prefix_default&.to_s } }
445
+ ns_attr_names << rule.name.to_s
446
+ return nil
447
+ end
448
+
349
449
  claim[:plain] += 1
350
450
  plan_attrs << { name: rule.name.to_s }
351
451
  return nil
@@ -435,6 +535,29 @@ module Lutaml
435
535
  { exact: uri.to_s, prefix: rule.prefix&.to_s }
436
536
  end
437
537
 
538
+ # Type-namespace ns form for a scalar element row, mirroring
539
+ # the interpretive resolve_element_namespace step 4: the
540
+ # attribute's type namespace qualifies the row when the rule
541
+ # declares none. Rows inheriting the tree namespace (type ns
542
+ # nil, :inherit, or equal to the model's own URI) get no form.
543
+ # :blank and alias families have no row-expressible form —
544
+ # the model stays interpretive.
545
+ def element_type_ns_form(rule, attr, register, model_ns)
546
+ return nil if rule.namespace_set?
547
+
548
+ type_ns = attr.type_namespace_class(register)
549
+ return nil if type_ns.nil? || type_ns == :inherit
550
+ return :bail_symbol if type_ns.is_a?(Symbol)
551
+ return :bail_aliases unless Leptris.attr_ns_rows_compatible? &&
552
+ type_ns.all_uris.size == 1
553
+
554
+ form = { exact: type_ns.uri.to_s,
555
+ prefix: type_ns.prefix_default&.to_s }
556
+ return nil if form[:exact] == model_ns&.[](:exact)
557
+
558
+ form
559
+ end
560
+
438
561
  def content_rows(rows)
439
562
  rows.count { |r| r[:kind] == :content }
440
563
  end
@@ -38,7 +38,7 @@ module Lutaml
38
38
  return nil if plan[:ordered] && instance.element_order.nil?
39
39
 
40
40
  doc = ::Leptris::XML::Document.create
41
- ns_ctx = Namespaces.new(plan)
41
+ ns_ctx = Namespaces.new(instance, plan, register)
42
42
  return nil if ns_ctx.unspellable?
43
43
 
44
44
  root = doc.create_element(
@@ -61,13 +61,51 @@ module Lutaml
61
61
  # first-encounter order — the #1558 contract on the DOM
62
62
  # path. A nested plan's own tree ns overrides the inherited
63
63
  # one; rows without an ns form inherit.
64
+ # Namespace spelling sourced from the interpretive writer's
65
+ # own machinery: NamespaceCollector + DeclarationPlanner
66
+ # decide prefix-vs-default per namespace exactly as the
67
+ # interpretive path does — no heuristic divergence (the
68
+ # uniword FontScheme/Image regressions). Option-bearing
69
+ # calls (prefix: true) never reach here; the serializer's
70
+ # plain spelling is the interpretive writer's plain
71
+ # spelling.
64
72
  class Namespaces
65
73
  attr_reader :unspellable
66
74
 
67
- def initialize(plan)
75
+ def initialize(instance, plan, register)
68
76
  @forms = {}
77
+ @hoisted = {}
69
78
  @declared = {}
70
79
  @unspellable = false
80
+ mapping = plan[:mapping]
81
+ # The interpretive writer plans from the BUILT element
82
+ # tree: absent attributes (nil values) and absent
83
+ # children contribute no namespace needs, so an element
84
+ # whose own namespace would otherwise be prefix-hoisted
85
+ # (W3C: prefixed attributes need a prefix declaration)
86
+ # falls back to default-ns format. Collecting from the
87
+ # instance reproduces that — collecting from nil (type
88
+ # analysis) hoists every declared need and diverges
89
+ # (`<w:b xmlns:w>` vs the interpretive `<b xmlns>` when
90
+ # val=true omits the only prefixed attribute).
91
+ ns_prefix = if instance.is_a?(::Lutaml::Model::Serialize)
92
+ instance.xml_namespace_prefix
93
+ end
94
+ collect_options = { mapper_class: instance.class }
95
+ if ns_prefix && !ns_prefix.empty?
96
+ collect_options[:__xml_namespace_prefix] = ns_prefix
97
+ end
98
+ needs = Lutaml::Xml::NamespaceCollector.new(register).collect(
99
+ instance, mapping, **collect_options
100
+ )
101
+ planner = Lutaml::Xml::DeclarationPlanner.new(register)
102
+ declared_plan = planner.plan(
103
+ instance.class, mapping, needs, options: {}
104
+ )
105
+ declared_plan.root_node&.hoisted_declarations&.each do |prefix, uri|
106
+ @hoisted[uri] = prefix
107
+ @forms[uri] = { prefix: prefix, uri: uri }
108
+ end
71
109
  collect(plan[:tree])
72
110
  end
73
111
 
@@ -76,42 +114,55 @@ module Lutaml
76
114
  end
77
115
 
78
116
  def collect(tree)
79
- register(tree[:ns])
80
- (tree[:attributes] || []).each { |attr| register(attr[:ns]) }
117
+ check(tree[:ns])
118
+ (tree[:attributes] || []).each { |a| check(a[:ns]) }
81
119
  (tree[:children] || []).each do |row|
82
- register(row[:ns]) if row[:ns].is_a?(Hash)
120
+ check(row[:ns]) if row[:ns].is_a?(Hash)
83
121
  collect(row[:plan]) if row[:kind] == :nested && row[:plan]
84
122
  end
85
123
  end
86
124
 
87
- def register(ns_form)
125
+ # An exact form the planner did not declare cannot be
126
+ # spelled — the serializer falls back interpretively.
127
+ def check(ns_form)
88
128
  return if ns_form.nil? || ns_form == :none || ns_form == :any
89
129
 
90
- form = { prefix: ns_form[:prefix], uri: ns_form[:exact] }
91
- @forms[ns_form[:exact]] ||= form
92
- @unspellable = true if ns_form[:exact] && !ns_form.key?(:prefix)
130
+ uri = ns_form[:exact]
131
+ @unspellable = true if uri && !@forms.key?(uri)
132
+ end
133
+
134
+ # The planner's hoisted prefix for a URI (nil when hoisted
135
+ # as the default namespace).
136
+ def prefix_for(uri)
137
+ @hoisted[uri]
93
138
  end
94
139
 
95
- # The wire spelling for a local name under this form.
140
+ # The wire spelling for a local name under this form: the
141
+ # planner's hoisted prefix for the URI, or unprefixed when
142
+ # hoisted as the default namespace.
96
143
  def spell(local, ns_form = nil)
97
- form = ns_form.is_a?(Hash) ? ns_form : @forms[ns_form]
98
- prefix = form && form[:prefix]
144
+ uri = if ns_form.is_a?(Hash)
145
+ ns_form[:exact]
146
+ else
147
+ @forms.key?(ns_form) ? @forms[ns_form][:uri] : nil
148
+ end
149
+ prefix = uri ? @hoisted[uri] : nil
99
150
  prefix ? "#{prefix}:#{local}" : local
100
151
  end
101
152
 
102
153
  def form_for(ns_form)
103
154
  return nil if ns_form.nil? || ns_form == :none
104
155
 
105
- ns_form.is_a?(Hash) ? ns_form : @forms[ns_form]
156
+ ns_form.is_a?(Hash) ? { prefix: @hoisted[ns_form[:exact]], uri: ns_form[:exact] } : @forms[ns_form]
106
157
  end
107
158
 
108
- def declare_on(root)
109
- @forms.each_value do |form|
110
- key = form[:uri]
159
+ def declare_on(root, _attr_form_prefix = nil)
160
+ @hoisted.each do |uri, prefix|
161
+ key = uri
111
162
  next if key.nil? || @declared[key]
112
163
 
113
164
  @declared[key] = true
114
- root.add_namespace_definition(form[:prefix], form[:uri])
165
+ root.add_namespace_definition(prefix, uri)
115
166
  end
116
167
  end
117
168
  end
@@ -135,6 +186,8 @@ module Lutaml
135
186
  inherited_ns = nil)
136
187
  reg ||= register
137
188
  write_attributes(element, instance, plan, reg, ns_ctx)
189
+ ns_index = ns_index_for(plan)
190
+ nested_plans = nested_plans_for(plan)
138
191
  plan[:rows].each do |rule, attr, kind, spelling, delegate|
139
192
  value = value_of(instance, attr, delegate)
140
193
  next unless rule.render?(value, instance)
@@ -145,9 +198,7 @@ module Lutaml
145
198
  # several occurrences serializes one element per item,
146
199
  # as the interpretive writer does. A plain scalar skips
147
200
  # the Array() wrap — the common case.
148
- row_ns = plan[:tree][:children]&.find do |r|
149
- r[:name] == rule.name.to_s
150
- end&.[](:ns) || inherited_ns
201
+ row_ns = ns_index[row_name(rule, spelling)] || inherited_ns
151
202
  if value.is_a?(::Array)
152
203
  value.each do |item|
153
204
  add_leaf(element,
@@ -162,9 +213,7 @@ module Lutaml
162
213
  attrs: rule.when_attribute)
163
214
  end
164
215
  when :collection_native, :collection_cb
165
- row_ns = plan[:tree][:children]&.find do |r|
166
- r[:name] == rule.name.to_s
167
- end&.[](:ns) || inherited_ns
216
+ row_ns = ns_index[rule.name.to_s] || inherited_ns
168
217
  Array(value).each do |item|
169
218
  add_leaf(element,
170
219
  ns_ctx ? ns_ctx.spell(rule.name.to_s, row_ns) : rule.name.to_s,
@@ -173,7 +222,8 @@ module Lutaml
173
222
  end
174
223
  when :nested, :ordered_deferred
175
224
  Array(value).each do |item|
176
- child_plan = PlanCompiler.compile(item.class, reg)
225
+ child_plan = nested_plans[item.class] ||
226
+ (nested_plans[item.class] = PlanCompiler.compile(item.class, reg))
177
227
  child_ns = child_plan[:tree][:ns] || inherited_ns
178
228
  child = element.create_child(
179
229
  ns_ctx ? ns_ctx.spell(child_plan[:tree][:name], child_ns) : child_plan[:tree][:name],
@@ -195,6 +245,25 @@ module Lutaml
195
245
  end
196
246
  end
197
247
 
248
+ # Per-plan lazy indexes: the name→ns lookup and the
249
+ # per-class child-plan resolution are per-instance calls on
250
+ # the serialize hot path — memoized once per compiled plan
251
+ # they cost nothing per row (the old per-row children#find
252
+ # plus PlanCompiler key arrays were ~4 allocs/row).
253
+ def ns_index_for(plan)
254
+ plan[:ns_index] ||= begin
255
+ h = {}
256
+ (plan[:tree][:children] || []).each do |r|
257
+ h[r[:name]] = r[:ns] unless h.key?(r[:name])
258
+ end
259
+ h
260
+ end
261
+ end
262
+
263
+ def nested_plans_for(plan)
264
+ plan[:nested_plans] ||= {}
265
+ end
266
+
198
267
  # Element_order-driven emission (the interpretive order
199
268
  # applier's contract): text runs and comments verbatim, each
200
269
  # element entry consuming the next item of its collection
@@ -276,7 +345,8 @@ module Lutaml
276
345
  end
277
346
  next unless item
278
347
 
279
- child_plan = PlanCompiler.compile(item.class, reg)
348
+ child_plan = nested_plans_for(plan)[item.class] ||
349
+ (nested_plans_for(plan)[item.class] = PlanCompiler.compile(item.class, reg))
280
350
  child = element.create_child(child_plan[:tree][:name])
281
351
  if child_plan[:ordered]
282
352
  return nil unless build_ordered(child, item, child_plan,
@@ -300,11 +370,21 @@ module Lutaml
300
370
  def write_attributes(element, instance, plan, reg = nil,
301
371
  ns_ctx = nil)
302
372
  recorded = instance.attribute_order
303
- attr_prefixes = {}
304
- if ns_ctx
373
+ attr_prefixes = nil
374
+ if ns_ctx && !(plan[:tree][:attributes] || []).empty?
375
+ attr_prefixes = {}
305
376
  (plan[:tree][:attributes] || []).each do |a|
306
377
  form = a[:ns].is_a?(Hash) ? a[:ns] : nil
307
- attr_prefixes[a[:name]] = form && form[:prefix]
378
+ # Namespaced attributes always spell prefixed (W3C:
379
+ # attributes cannot use the default namespace) — the
380
+ # interpretive writer emits the type namespace's own
381
+ # prefix even when the element hoists the same URI as
382
+ # the default namespace (updateFields w:val under
383
+ # xmlns="...").
384
+ attr_prefixes[a[:name]] =
385
+ if form
386
+ form[:prefix] || ns_ctx.prefix_for(form[:exact])
387
+ end
308
388
  end
309
389
  end
310
390
  if recorded && !recorded.empty?
@@ -316,13 +396,13 @@ module Lutaml
316
396
  plan[:attr_rows].map { |rule, _a, _s, _d| rule.name.to_s }
317
397
  names.each do |name|
318
398
  write_attribute(element, instance, name, by_name[name],
319
- reg, attr_prefixes[name])
399
+ reg, attr_prefixes && attr_prefixes[name])
320
400
  end
321
401
  else
322
402
  plan[:attr_rows].each do |rule, attr, _sp, delegate|
323
403
  write_attribute(element, instance, rule.name.to_s,
324
404
  [rule, attr, delegate], reg,
325
- attr_prefixes[rule.name.to_s])
405
+ attr_prefixes && attr_prefixes[rule.name.to_s])
326
406
  end
327
407
  end
328
408
  end
@@ -16,6 +16,7 @@ module Lutaml
16
16
  register = Lutaml::Model::Config.default_register
17
17
  plan = PlanCompiler.compile(model_class, register)
18
18
  return nil unless plan
19
+ return nil unless PlanCompiler.engine_walk_safe?(plan)
19
20
 
20
21
  document = ::Leptris::XML.parse(xml)
21
22
  root = document.root
@@ -33,6 +33,23 @@ module Lutaml
33
33
  # map_any_element catch-all. Ships with the 1.9.313 binding.
34
34
  MIN_LEPTRIS_WILDCARD_VERSION = "1.9.313.0"
35
35
 
36
+ # Walk-side namespace safety (leptris#1587, closing #1585 and
37
+ # #1586): child-row exact-URI ns_uri is retained engine-side
38
+ # (deep-copied at build, freed with the plan) and plain attribute
39
+ # rows leniently match namespace-qualified wire attributes
40
+ # (exact-first-then-any, top level and nested). Both shipped in
41
+ # 1.9.320.0; older engines keep the affected models interpretive
42
+ # through PlanCompiler.engine_walk_safe?.
43
+ MIN_LEPTRIS_WALK_NS_SAFETY_VERSION = "1.9.320.0"
44
+
45
+ # The :unqualified child-row ns form (leptris#1560: matches only
46
+ # the UNWRITTEN spelling — bare or inherited-default-ns — and
47
+ # refuses prefixed spellings, the interpretive matcher's exact
48
+ # no-namespace semantic) ships in binding 1.9.323.0. Older
49
+ # engines keep namespace-less model rows on the ns_lenient
50
+ # superset (local-name matching under any qualification).
51
+ MIN_LEPTRIS_NS_UNQUALIFIED_VERSION = "1.9.323.0"
52
+
36
53
  def self.plan_path_compatible?
37
54
  return false unless defined?(Gem)
38
55
 
@@ -61,6 +78,25 @@ module Lutaml
61
78
  spec && spec.version >= Gem::Version.new(MIN_LEPTRIS_WILDCARD_VERSION)
62
79
  end
63
80
 
81
+ # Memoized: consulted per parse on the walk gate hot path, and
82
+ # the loaded leptris spec cannot change within a process.
83
+ @walk_ns_safety = nil
84
+ def self.walk_ns_safety_compatible?
85
+ return @walk_ns_safety unless @walk_ns_safety.nil?
86
+ return (@walk_ns_safety = false) unless defined?(Gem)
87
+
88
+ spec = Gem.loaded_specs["leptris"]
89
+ @walk_ns_safety =
90
+ !!(spec && spec.version >= Gem::Version.new(MIN_LEPTRIS_WALK_NS_SAFETY_VERSION))
91
+ end
92
+
93
+ def self.ns_unqualified_rows_compatible?
94
+ return false unless defined?(Gem)
95
+
96
+ spec = Gem.loaded_specs["leptris"]
97
+ spec && spec.version >= Gem::Version.new(MIN_LEPTRIS_NS_UNQUALIFIED_VERSION)
98
+ end
99
+
64
100
  autoload :PlanCompiler, "lutaml/xml/leptris/plan_compiler"
65
101
  autoload :PlanHydrator, "lutaml/xml/leptris/plan_hydrator"
66
102
  autoload :PlanOrder, "lutaml/xml/leptris/plan_order"
@@ -384,21 +384,15 @@ module Lutaml
384
384
  value = if !rule.attribute? && rule.name.nil? &&
385
385
  xml_mapping.any_element_rule.equal?(rule)
386
386
  # map_any_element: every child element no explicit
387
- # element rule claims, in document order. A name-less
388
- # MappingRule is otherwise indistinguishable from a
389
- # content rule (the dispatch consumed it as doc.text);
390
- # name-derived lookup also cannot express this — it
391
- # re-finds by name, losing order and double-picking.
392
- # The pass walks the document children once; the
393
- # normalize/cast pipeline below handles the rest.
394
- claimed = Set.new(
395
- xml_mapping.mappings(effective_register)
396
- .select { |r| !r.attribute? && r.name }
397
- .flat_map { |r| [r.name.to_s] },
387
+ # element rule claims, in document order (claimed
388
+ # set + scalar text extraction live in
389
+ # any_element_members). A name-less MappingRule is
390
+ # otherwise indistinguishable from a content rule;
391
+ # name-derived lookup re-finds by name, losing
392
+ # order and double-picking.
393
+ any_element_members(
394
+ doc, xml_mapping, attr, effective_register
398
395
  )
399
- doc.element_children.reject do |child|
400
- claimed.include?(child.unprefixed_name)
401
- end
402
396
  elsif rule.raw_mapping?
403
397
  scoped_raw_inner_xml(doc, xml_mapping)
404
398
  elsif rule.content_mapping?
@@ -1590,6 +1584,36 @@ effective_register = lutaml_register)
1590
1584
  end
1591
1585
  end
1592
1586
 
1587
+ # map_any_element members: every child element no explicit
1588
+ # element rule claims, in document order; scalar targets take
1589
+ # the captured elements' text — the same normalize step scalar
1590
+ # elements get (the cast pipeline receives strings, not wrappers
1591
+ # whose to_s is their inspect); Serializable targets keep the
1592
+ # wrappers and cast into instances.
1593
+ def any_element_members(doc, mapping, attr, register)
1594
+ claimed = Set.new(
1595
+ mapping.mappings(register)
1596
+ .select { |r| !r.attribute? && r.name }
1597
+ .flat_map { |r| [r.name.to_s] },
1598
+ )
1599
+ unclaimed = doc.element_children.reject do |child|
1600
+ claimed.include?(child.unprefixed_name)
1601
+ end
1602
+ return unclaimed if attr.nil? ||
1603
+ attr_type_is_serializable(attr, register)
1604
+
1605
+ unclaimed.map do |child|
1606
+ child_text = child.nil_element? ? nil : child&.text
1607
+ child_cdata = child&.cdata
1608
+ text = if child_text.is_a?(Array) || child_cdata.is_a?(Array)
1609
+ nil
1610
+ else
1611
+ child_text&.+ child_cdata
1612
+ end
1613
+ text.nil? ? child : text
1614
+ end
1615
+ end
1616
+
1593
1617
  def attr_type_is_serializable(attr, effective_register)
1594
1618
  attr&.serializable_type?(effective_register) || false
1595
1619
  end
@@ -409,9 +409,25 @@ module Lutaml
409
409
  # Handle collections and arrays
410
410
  if child_instance.is_a?(Array) || child_instance.is_a?(Lutaml::Model::Collection)
411
411
  instances = child_instance.is_a?(Lutaml::Model::Collection) ? child_instance.collection : child_instance
412
- # Collect needs from all instances and merge
412
+ # Collect needs from the FIRST instance of each distinct
413
+ # class and merge once — instances of one class share the
414
+ # mapping graph, so re-collecting every member re-walks
415
+ # identical structure (row-heavy models spent most of
416
+ # their serialize budget here). Value-dependent divergences
417
+ # in later members surface as unspellable forms on the
418
+ # serializer and fall back interpretively, never as wrong
419
+ # output. Merge-once is equivalent to merge-N: every read
420
+ # path of the merged needs is any?/set-based.
413
421
  merged_needs = NamespaceNeeds.new
422
+ seen_classes = nil
414
423
  instances.each do |item|
424
+ item_class = item.class
425
+ if seen_classes
426
+ next if seen_classes[item_class]
427
+ else
428
+ seen_classes = {}
429
+ end
430
+ seen_classes[item_class] = true
415
431
  item_ns_prefix = if item.is_a?(::Lutaml::Model::Serialize)
416
432
  item.xml_namespace_prefix
417
433
  end
@@ -8,13 +8,20 @@ module Lutaml
8
8
  module Serializers
9
9
  module_function
10
10
 
11
+ # The default :xml String serializer is the identity for String
12
+ # values. Attribute#serialize_value fast-paths String→String
13
+ # through an identity comparison with this exact proc, so a
14
+ # consumer override of the serializer disables that fast path
15
+ # automatically.
16
+ STRING_XML_TO = ->(inst) { inst.value&.to_s }.freeze
17
+
11
18
  def register_all!
12
19
  v = Lutaml::Model::Type::Value
13
20
 
14
21
  # String — value&.to_s
15
22
  v.register_format_type_serializer(
16
23
  :xml, Lutaml::Model::Type::String,
17
- to: ->(inst) { inst.value&.to_s },
24
+ to: STRING_XML_TO,
18
25
  from: ->(val) { Lutaml::Model::Type::String.cast(val) }
19
26
  )
20
27
 
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+
5
+ # Issue #938: a deferred (symbol-form) import that cannot resolve leaves
6
+ # the import pass with all_resolved = false. record_import_attempt then
7
+ # calls attributes(), which re-enters ensure_imports!; the completion
8
+ # marker stored false before that call, so the re-entrant pass ran the
9
+ # import loop again and recursed to SystemStackError. Consumers with
10
+ # large mutually-referencing type graphs (uniword OOXML sets) hit this
11
+ # on load.
12
+ module ModelImportReentry938
13
+ class OrphanSource < Lutaml::Model::Serializable
14
+ attribute :label, :string
15
+ end
16
+
17
+ class PendingImporter < Lutaml::Model::Serializable
18
+ attribute :name, :string
19
+ import_model_attributes :NoSuchModelAnywhere938
20
+ end
21
+
22
+ class ResolvableImporter < Lutaml::Model::Serializable
23
+ attribute :name, :string
24
+ end
25
+
26
+ Lutaml::Model::GlobalRegister
27
+ .lookup(Lutaml::Model::Config.default_register)
28
+ .register_model(OrphanSource, id: :ModelImportReentry938OrphanSource)
29
+ ResolvableImporter
30
+ .import_model_attributes(:ModelImportReentry938OrphanSource)
31
+ end
32
+
33
+ RSpec.describe "deferred model import re-entry" do
34
+ it "does not recurse when a deferred import cannot resolve" do
35
+ expect { ModelImportReentry938::PendingImporter.attributes(:default) }
36
+ .not_to raise_error
37
+ end
38
+
39
+ it "resolves a deferred import without recursing on re-access" do
40
+ importer = ModelImportReentry938::ResolvableImporter
41
+ 3.times { importer.attributes(:default) }
42
+
43
+ expect(importer.attributes(:default)).to have_key(:name)
44
+ expect(importer.attributes(:default)).to have_key(:label)
45
+ end
46
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+
5
+ module GenerateManySpec
6
+ class Source < Lutaml::Model::Serializable
7
+ attribute :name, :string
8
+ end
9
+
10
+ class Other < Lutaml::Model::Serializable
11
+ attribute :label, :string
12
+ end
13
+
14
+ class Consumer < Lutaml::Model::Serializable
15
+ attribute :inner, Source
16
+ end
17
+ end
18
+
19
+ RSpec.describe Lutaml::Model::Schema::BaseSchema do
20
+ describe ".generate_many" do
21
+ it "covers every root in one document without a top-level $ref" do
22
+ parsed = JSON.parse(
23
+ Lutaml::Model::Schema::JsonSchema.generate_many(
24
+ [GenerateManySpec::Source, GenerateManySpec::Other],
25
+ ),
26
+ )
27
+
28
+ expect(parsed).not_to have_key("$ref")
29
+ expect(parsed["$defs"]).to have_key("GenerateManySpec_Source")
30
+ expect(parsed["$defs"]).to have_key("GenerateManySpec_Other")
31
+ end
32
+
33
+ it "registers a class shared by several roots once" do
34
+ parsed = JSON.parse(
35
+ Lutaml::Model::Schema::JsonSchema.generate_many(
36
+ [GenerateManySpec::Source, GenerateManySpec::Consumer],
37
+ ),
38
+ )
39
+
40
+ expect(parsed["$defs"].keys.size).to eq(2)
41
+ expect(parsed["$defs"]).to have_key("GenerateManySpec_Consumer")
42
+ end
43
+
44
+ it "formats YAML output" do
45
+ yaml = Lutaml::Model::Schema::YamlSchema.generate_many(
46
+ [GenerateManySpec::Source, GenerateManySpec::Other],
47
+ )
48
+
49
+ expect(yaml).to start_with("%YAML")
50
+ end
51
+ end
52
+
53
+ describe "Generator::DefinitionsCollection.from_classes" do
54
+ it "builds one collection covering all roots" do
55
+ collection = Lutaml::Model::Schema::Generator::DefinitionsCollection
56
+ .from_classes([GenerateManySpec::Source, GenerateManySpec::Consumer])
57
+
58
+ expect(collection.to_schema.keys.size).to eq(2)
59
+ end
60
+ end
61
+ end
data/spec/person_spec.rb CHANGED
@@ -115,13 +115,14 @@ RSpec.describe Person do
115
115
  expect(model.to_yaml).to eq(attributes_yaml.to_yaml)
116
116
  end
117
117
 
118
- # yeptris 0.6.29.x regressed the time-only scalar anchor: "07:00:00"
119
- # comes back as a fixed 2025-07-19 00:00 instead of today-07:00
120
- # (psych answers exactly; verified on the psych path). Re-pended
121
- # until the yeptris typed-materialize ships the fix — evidence on
122
- # moxml#336. 873a67bd un-pended these on the premise that the
123
- # 0.6.28.x behavior held; it did not survive the 0.6.29 cut.
124
- xit "deserializes from YAML" do # rubocop:disable RSpec/PendingWithoutReason -- yeptris time-only misparse; engine fix pending.
118
+ # Root cause of the long-pended time-only misparse: psych resolves an
119
+ # unquoted hh:mm:ss scalar as a YAML 1.1 sexagesimal integer (25200
120
+ # for "07:00:00"), and the :time cast fed that integer through
121
+ # Time.parse, which reads "25200" as a date (the fixed 2025-07-19
122
+ # garbage). The cast now reads sexagesimal integers as seconds since
123
+ # midnight — both the psych and yeptris paths answer today-07:00.
124
+ # Evidence chain: moxml#336, yeptris-ruby#265.
125
+ it "deserializes from YAML" do
125
126
  yaml = attributes_yaml.to_yaml
126
127
  person = described_class.from_yaml(yaml)
127
128
  expect(person.first_name).to eq("John")
@@ -133,8 +134,8 @@ RSpec.describe Person do
133
134
  expect(person.active).to be true
134
135
  end
135
136
 
136
- # Same yeptris time-only scalar misparse as above.
137
- xit "deserializes from YAML array" do # rubocop:disable RSpec/PendingWithoutReason -- yeptris time-only misparse; engine fix pending.
137
+ # Same sexagesimal time-only scalar as above.
138
+ it "deserializes from YAML array" do
138
139
  yaml = [attributes_yaml.dup, attributes_yaml.dup].to_yaml
139
140
 
140
141
  persons = described_class.from_yaml(yaml)
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: lutaml-model
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.8.96
4
+ version: 0.8.98
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ribose Inc.
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-10-07 00:00:00.000000000 Z
11
+ date: 2026-10-10 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: base64
@@ -1773,6 +1773,7 @@ files:
1773
1773
  - spec/lutaml/model/mapping_rule_perf_guard_spec.rb
1774
1774
  - spec/lutaml/model/mapping_type_validation_spec.rb
1775
1775
  - spec/lutaml/model/mixed_content_spec.rb
1776
+ - spec/lutaml/model/model_import_reentry_spec.rb
1776
1777
  - spec/lutaml/model/model_transformer_spec.rb
1777
1778
  - spec/lutaml/model/multiple_mapping_spec.rb
1778
1779
  - spec/lutaml/model/namespace_versioning_spec.rb
@@ -1795,6 +1796,7 @@ files:
1795
1796
  - spec/lutaml/model/root_mappings_spec.rb
1796
1797
  - spec/lutaml/model/rule_value_extractor_spec.rb
1797
1798
  - spec/lutaml/model/runtime_compatibility_spec.rb
1799
+ - spec/lutaml/model/schema/generate_many_spec.rb
1798
1800
  - spec/lutaml/model/schema/generation_regressions_spec.rb
1799
1801
  - spec/lutaml/model/schema/generator/definitions_collection_spec.rb
1800
1802
  - spec/lutaml/model/schema/json_schema_spec.rb