lutaml-model 0.8.52 → 0.8.54

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: 92e0193fa58094112522b5a7717aed2f568d77096eb0ae3bc1a246be28c05bf0
4
- data.tar.gz: 20dcd124c22c695cc0a8fca65d04422ac84f468515ef153d909e5a6dcf5eac67
3
+ metadata.gz: ffb19b7e87ec763167dc806fb0fd0b37a1ca292e7627333ba1238f06c3e5f17e
4
+ data.tar.gz: 340c6f96fb82f0d972ca654f548db51bdef12155bc94ae95e3eaf6eacf96af29
5
5
  SHA512:
6
- metadata.gz: 0cc3e71dceb6ae87db00453efbd905de28dc10d528cd38426b29c2c0da1a67d64d41a59095e45a9da1daa97888a4d001c8211b0ad64a3a011ef46949e71206a6
7
- data.tar.gz: c444800a986c0e34bc1502fd671f53d7ff4776377d89b62225b857c923f4b97058042094145caf0d0a54db37576bedd546a733e114a444118ff2e13241092d83
6
+ metadata.gz: d79e2b5b7fce4f6ba3115cdb190365b8a1fc1212922f00cabf2e9855fa85c2350808c0844f78c55015de4e8c8cf480cbdf5390e8afaa0a401f01ad6e2a1d93a3
7
+ data.tar.gz: c6a29c349be599ddfcbcb53087c17178de7de17aa1afa46fb5e2fc6eb2b3c4b0c0189b68a26aa77d0e2d561966c4001c0bd04abacf2d6e987d8846185b6add29
@@ -13,9 +13,11 @@ on:
13
13
  repository_dispatch:
14
14
  types: [ do-release ]
15
15
 
16
+ # Least-privilege ceiling for the called rubygems-release.yml: its release job
17
+ # needs contents:write (git tag push) and id-token:write (OIDC Trusted Publishing).
18
+ # packages:write is not required for RubyGems Trusted Publishing.
16
19
  permissions:
17
20
  contents: write
18
- packages: write
19
21
  id-token: write
20
22
 
21
23
  jobs:
@@ -24,5 +26,10 @@ jobs:
24
26
  with:
25
27
  next_version: ${{ github.event.inputs.next_version }}
26
28
  secrets:
27
- rubygems-api-key: ${{ secrets.LUTAML_CI_RUBYGEMS_API_KEY }}
29
+ # OMIT rubygems-api-key: metanorma/ci switches to OIDC Trusted Publishing
30
+ # when the secret is empty (USE_OIDC = rubygems-api-key == ''). Trusted
31
+ # Publishing is already configured for lutaml gems on rubygems.org —
32
+ # do not re-introduce LUTAML_CI_RUBYGEMS_API_KEY here.
33
+ # pat_token is optional (private-gem checkout + GH Packages). Keep if
34
+ # the org still uses private deps; drop once pure public.
28
35
  pat_token: ${{ secrets.LUTAML_CI_PAT_TOKEN }}
@@ -613,6 +613,14 @@ as `collection: true` when the element repeats. A rule without
613
613
  `when_attribute` that shares the name only claims occurrences no
614
614
  discriminator rule claimed, so every occurrence is captured exactly once.
615
615
 
616
+ On the key-value formats the same coexistence applies to
617
+ `when_attribute` rules sharing one key, regardless of declaration order.
618
+ A rule *without* `when_attribute` is an unconditional claim on the bare
619
+ slot: declaring one replaces prior bare rules on that key — including a
620
+ parent class's rule the subclass remaps to a different attribute — while
621
+ `when_attribute` partitions on the key survive. A partition rule replaces
622
+ only a prior partition with the same target; otherwise it accumulates.
623
+
616
624
  By default an occurrence no rule claims — an uncovered discriminator
617
625
  value, or a missing discriminator attribute — is silently dropped.
618
626
  Declare `unmatched: :raise` on a discriminator rule to fail the parse
@@ -148,17 +148,40 @@ module Lutaml
148
148
  serialize: serialize,
149
149
  )
150
150
  # lutaml-model#88: rules may share a wire key (when_attribute
151
- # partitions) — store per-key arrays, like the XML mapping. A
152
- # rule redefining the same target (`to:`) replaces its
153
- # predecessor, so subclass and redeclaration overrides keep the
154
- # pre-array replace semantics.
151
+ # partitions) — store per-key arrays, like the XML mapping.
152
+ # Replacement policy, MECE by rule kind:
153
+ # * a bare rule (no when_attribute) replaces prior BARE rules
154
+ # on the same wire key — subclass remaps of a key to a
155
+ # different attribute (e.g. base `ref → Ref`, subclass
156
+ # `ref → text`) must not leave both rules active — while
157
+ # when_attribute partitions on the key survive regardless
158
+ # of declaration order;
159
+ # * a when_attribute rule replaces only a prior partition
160
+ # with the same target (`to:`), otherwise it accumulates.
155
161
  existing = @mappings[mapping_name]
156
162
  if existing.nil?
157
163
  @mappings[mapping_name] = [rule]
158
- elsif (index = existing.index { |r| r.to == rule.to })
159
- existing[index] = rule
164
+ elsif rule.when_attribute.empty?
165
+ # Bare claim: prior bare rules on the key are superseded;
166
+ # partitions survive regardless of declaration order.
167
+ @mappings[mapping_name] =
168
+ existing.reject { |r| r.when_attribute.empty? } + [rule]
160
169
  else
161
- existing << rule
170
+ index = kv_partition_replace_index(existing, rule)
171
+ if index
172
+ existing[index] = rule
173
+ else
174
+ existing << rule
175
+ end
176
+ end
177
+ end
178
+
179
+ # Index of the prior partition a when_attribute rule replaces
180
+ # (same target), or nil. Extracted: a block in an elsif condition
181
+ # mis-binds (the known do/end trap).
182
+ def kv_partition_replace_index(existing, rule)
183
+ existing.index do |r|
184
+ r.to == rule.to && !r.when_attribute.empty?
162
185
  end
163
186
  end
164
187
 
@@ -161,6 +161,18 @@ module Lutaml
161
161
  )
162
162
  end
163
163
 
164
+ # Resolution that never materializes deferred model classes:
165
+ # raises UnknownTypeError for a not-yet-loaded class name.
166
+ # Mapping-time validation uses this so finalizing one mapping
167
+ # cannot fire an autoload that re-enters another model file
168
+ # mid-definition.
169
+ def loaded_type(context_or_register = nil)
170
+ return if unresolved_type.nil?
171
+
172
+ context = normalize_context(context_or_register)
173
+ resolver.resolve(unresolved_type, context, materialize: false)
174
+ end
175
+
164
176
  def type(context_or_register = nil)
165
177
  return if unresolved_type.nil?
166
178
 
@@ -62,7 +62,9 @@ module Lutaml
62
62
  # @param context [TypeContext] The resolution context
63
63
  # @return [Class] The resolved type class
64
64
  # @raise [UnknownTypeError] If type cannot be resolved
65
- def resolve(name, context)
65
+ def resolve(name, context, materialize: true)
66
+ return @delegate.resolve(name, context, materialize: false) unless materialize
67
+
66
68
  cache_key = if name.is_a?(Class)
67
69
  [context.id, name]
68
70
  else
@@ -204,6 +204,7 @@ module Lutaml
204
204
 
205
205
  # Clear memoized attribute merge (see .attributes)
206
206
  @merged_attributes_cache = nil
207
+ @instantiate_writers = nil
207
208
 
208
209
  # Clear per-Attribute type caches (stale entries from GC'd TypeContext objects)
209
210
  class_attributes.each_value(&:clear_type_cache)
@@ -368,22 +369,23 @@ module Lutaml
368
369
  def instantiate(attrs = {}, register = nil)
369
370
  instance = allocate_for_deserialization(register)
370
371
  register_id = instance.lutaml_register
371
- given = {}
372
- attrs.each do |key, value|
373
- name = key.to_sym
374
- next if name == :lutaml_register
375
-
376
- unless attributes(register_id).key?(name)
377
- raise Error, "unknown attribute '#{name}' for #{self}"
378
- end
379
-
380
- given[name] = value
381
- end
382
- attributes(register_id).each do |name, attr|
372
+ attrs_by = attributes(register_id)
373
+ writers = instantiate_writers(register_id)
374
+ given = 0
375
+ attrs_by.each do |name, attr|
383
376
  next if attr.derived?
384
377
 
385
- if given.key?(name)
386
- instance.public_send(:"#{name}=", given[name])
378
+ # Symbol keys hit directly (native-extension callers);
379
+ # string keys fall back with one allocated name.
380
+ key = if attrs.key?(name)
381
+ given += 1
382
+ name
383
+ elsif (str_name = name.to_s) && attrs.key?(str_name)
384
+ given += 1
385
+ str_name
386
+ end
387
+ if key
388
+ instance.public_send(writers[name], attrs[key])
387
389
  else
388
390
  # Absent keys seed their default explicitly, mirroring
389
391
  # initialize_attributes: every mapped attribute ends up
@@ -396,13 +398,29 @@ module Lutaml
396
398
  else
397
399
  attr.cast_value(default, register_id)
398
400
  end
399
- instance.public_send(:"#{name}=", value)
401
+ instance.public_send(writers[name], value)
400
402
  instance.using_default_for(name)
401
403
  end
402
404
  end
405
+ reserved = (attrs.key?(:lutaml_register) ? 1 : 0) +
406
+ (attrs.key?("lutaml_register") ? 1 : 0)
407
+ if given + reserved < attrs.size
408
+ known = attrs_by.keys
409
+ unknown = attrs.keys.find { |k| !known.include?(k.to_sym) }
410
+ raise Error, "unknown attribute '#{unknown}' for #{self}"
411
+ end
403
412
  instance
404
413
  end
405
414
 
415
+ # Writer method symbols for instantiate, memoized per register
416
+ # alongside the attribute merge (cleared with it).
417
+ def instantiate_writers(register_id = nil)
418
+ (@instantiate_writers ||= {})[register_id] ||= attributes(register_id)
419
+ .each_with_object({}) do |(name, _attr), memo|
420
+ memo[name] = :"#{name}="
421
+ end
422
+ end
423
+
406
424
  # Define register-specific attribute methods on the class itself.
407
425
  #
408
426
  # Called once per (class, register) combination. Replaces per-instance
@@ -51,7 +51,11 @@ module Lutaml
51
51
  # @example
52
52
  # TypeResolver.resolve(:string, context) #=> Lutaml::Model::Type::String
53
53
  # TypeResolver.resolve(MyClass, context) #=> MyClass (pass-through)
54
- def self.resolve(name, context)
54
+ # materialize: false resolves only what the registries know
55
+ # without const_get — definition-time validation must not fire
56
+ # autoloads (a pending autoload re-enters the model file being
57
+ # defined and re-evaluates its mapping on the populated state).
58
+ def self.resolve(name, context, materialize: true)
55
59
  # Apply substitutions even if already a class
56
60
  # This is important for type substitution (e.g., Glaze -> RegisterGlaze)
57
61
  if name.is_a?(Class)
@@ -79,8 +83,9 @@ module Lutaml
79
83
  return apply_substitutions(type, context) if type
80
84
 
81
85
  # 4. Try Type.const_get for CamelCase type names (e.g., "Decimal" -> Type::Decimal)
82
- # This maintains backward compatibility with old Register behavior
83
- if name.is_a?(String)
86
+ # This maintains backward compatibility with old Register behavior.
87
+ # Skipped for non-materializing resolution: const_get fires autoloads.
88
+ if materialize && name.is_a?(String)
84
89
  begin
85
90
  type = Lutaml::Model::Type.const_get(name)
86
91
  return apply_substitutions(type, context) if type
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Lutaml
4
4
  module Model
5
- VERSION = "0.8.52"
5
+ VERSION = "0.8.54"
6
6
  end
7
7
  end
@@ -1112,7 +1112,7 @@ module Lutaml
1112
1112
  next unless target
1113
1113
 
1114
1114
  begin
1115
- resolved = target.type(Lutaml::Model::Config.default_register)
1115
+ resolved = target.loaded_type(Lutaml::Model::Config.default_register)
1116
1116
  rescue Lutaml::Model::UnknownTypeError
1117
1117
  next
1118
1118
  end
@@ -731,18 +731,29 @@ _effective_register)
731
731
  # each_value scan below is dead whenever the index exists.
732
732
  return nil if root_index && !candidates
733
733
 
734
- matched_attr = if candidates
735
- candidates.find do |attr|
736
- local_name_match?(attr, local_name, rule_uri,
737
- flexible_local)
738
- end
739
- else
740
- doc.root.attributes.each_value.find do |attr|
741
- local_name_match?(attr, local_name, rule_uri,
742
- flexible_local)
743
- end
744
- end
745
- matched_attr&.value
734
+ matched = if candidates
735
+ candidates.select do |attr|
736
+ local_name_match?(attr, local_name, rule_uri,
737
+ flexible_local)
738
+ end
739
+ else
740
+ doc.root.attributes.each_value.select do |attr|
741
+ local_name_match?(attr, local_name, rule_uri,
742
+ flexible_local)
743
+ end
744
+ end
745
+ # Attribute identity is (namespace URI, local name) — #841. The
746
+ # lenient probe is a recovery mechanism for the single
747
+ # undeclared-prefix case; several distinct matches are an
748
+ # ambiguity the document order must not arbitrate.
749
+ if matched.size > 1
750
+ identities = matched.map(&:namespaced_name).join(", ")
751
+ warn "[Lutaml::Model] ambiguous attribute for rule " \
752
+ "#{rule_name.inspect}: #{identities} all match by local " \
753
+ "name on <#{doc.root.unprefixed_name}>; no value bound"
754
+ return nil
755
+ end
756
+ matched.first&.value
746
757
  end
747
758
 
748
759
  # Namespace discipline of the lenient local-name fallback
@@ -110,6 +110,11 @@ module Lutaml
110
110
 
111
111
  if rule.attribute?
112
112
  return nil unless scalar_type?(attr, register)
113
+ # Attribute plan rows are local-name keyed; a type-level
114
+ # namespace makes the attribute (URI, local)-identified
115
+ # (lutaml-model#744) — the interpretive matcher owns it
116
+ # until plan rows carry namespace identity.
117
+ return nil if attr.type_namespace_class(register)
113
118
 
114
119
  attr_rows << [rule, attr]
115
120
  plan_attrs << { name: rule.name.to_s }
@@ -385,11 +385,24 @@ module Lutaml
385
385
  ensure_attribute_index
386
386
 
387
387
  if attribute_name.is_a?(Array)
388
+ # Alias list: at most one spelling variant may be present.
389
+ # Several simultaneous matches are an authoring ambiguity —
390
+ # attribute identity is (namespace URI, local name) — #841 —
391
+ # and array order must not arbitrate.
392
+ found = nil
388
393
  attribute_name.each do |name|
389
394
  val = @attribute_index[name]
390
- return val unless val.nil?
395
+ next if val.nil?
396
+
397
+ if found
398
+ warn "[Lutaml::Model] ambiguous attribute aliases " \
399
+ "#{found[0].inspect} and #{name.inspect} both present " \
400
+ "on <#{unprefixed_name}>; no value bound"
401
+ return nil
402
+ end
403
+ found = [name, val]
391
404
  end
392
- nil
405
+ found&.last
393
406
  else
394
407
  @attribute_index[attribute_name]
395
408
  end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DeferHost
4
+ class NeverLoadedModel < Lutaml::Model::Serializable
5
+ attribute :value, :string
6
+
7
+ xml do
8
+ element "never"
9
+ map_element "value", to: :value
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+
5
+ # A subclass that remaps a parent wire key onto a different attribute
6
+ # must replace the parent's rule, not accumulate beside it. Pre-#88
7
+ # (hash-keyed by wire name) this was automatic; the #88 array form
8
+ # only replaced on matching `to:`, leaving both rules active and
9
+ # breaking consumers like glossarist's V2::Citation (base maps
10
+ # `ref → Ref`, V2 remaps `ref → text`).
11
+ RSpec.describe "Key-value subclass wire-key remap" do
12
+ it "replaces the parent rule when the subclass remaps the same key to a different attribute" do
13
+ base = Class.new(Lutaml::Model::Serializable) do
14
+ attribute :ref, :string
15
+ attribute :label, :string
16
+
17
+ key_value do
18
+ map :ref, to: :ref
19
+ end
20
+ end
21
+
22
+ child = Class.new(base) do
23
+ key_value do
24
+ map :ref, to: :label
25
+ end
26
+ end
27
+
28
+ ref_spellings = [:ref, "ref"].freeze
29
+ rules = child.mappings_for(:yaml).mappings.select do |r|
30
+ ref_spellings.include?(r.name)
31
+ end
32
+ # Only the child's rule must remain
33
+ expect(rules.map(&:to)).to eq([:label])
34
+ end
35
+
36
+ it "still accumulates when_attribute partitions of the same wire key" do
37
+ klass = Class.new(Lutaml::Model::Serializable) do
38
+ attribute :a, :string
39
+ attribute :b, :string
40
+
41
+ key_value do
42
+ map :body, to: :a, when_attribute: { "type" => "a" }
43
+ map :body, to: :b, when_attribute: { "type" => "b" }
44
+ end
45
+ end
46
+
47
+ body_spellings = [:body, "body"].freeze
48
+ expected_targets = %i[a b].freeze
49
+ rules = klass.mappings_for(:yaml).mappings.select do |r|
50
+ body_spellings.include?(r.name)
51
+ end
52
+ expect(rules.map(&:to)).to match_array(expected_targets)
53
+ end
54
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+
5
+ # Definition-time attribute-type validation (lutaml-model#296) must
6
+ # resolve types WITHOUT materializing deferred model classes. Eager
7
+ # resolution const_get-fires pending autoloads, which re-enters the
8
+ # model file being defined, re-opens its classes, and re-evaluates an
9
+ # xml mapping block against the already-populated mapping — rejected
10
+ # for map_all_content ("map_all is not allowed ... with map_all").
11
+ RSpec.describe "Definition-time type validation" do
12
+ # The probe REQUIRES const mechanics (Object.const_set + autoload):
13
+ # stub_const would define a plain constant and defeat the test.
14
+ it "leaves a pending autoload unfired while validating a mapping" do
15
+ probe = Module.new
16
+ Object.const_set(:DeferHostProbe, probe)
17
+ probe.autoload(:NeverLoadedModel,
18
+ File.expand_path("../../fixtures/autoload_probe/never_loaded_model", __dir__))
19
+
20
+ expect do
21
+ Class.new(Lutaml::Model::Serializable) do
22
+ attribute :ref, "DeferHostProbe::NeverLoadedModel"
23
+
24
+ xml do
25
+ element "doc"
26
+ map_element "ref", to: :ref
27
+ end
28
+ end
29
+ end.not_to raise_error
30
+
31
+ registered = probe.autoload?(:NeverLoadedModel)
32
+ expect(registered).to be_truthy,
33
+ "mapping validation materialized the deferred type — it must " \
34
+ "stay pending for deferred-import resolution"
35
+ ensure
36
+ # rubocop:disable-next RSpec/RemoveConst -- unloading the autoload probe
37
+ Object.send(:remove_const, :DeferHostProbe) if Object.const_defined?(:DeferHostProbe)
38
+ end
39
+ end
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+
5
+ # Attribute identity is (namespace URI, local name) — lutaml-model#841.
6
+ # Where matching previously resolved silently by order (document order
7
+ # in the lenient recovery, array order in alias lists), several
8
+ # distinct matches are an ambiguity: no value binds and the collision
9
+ # is surfaced on stderr.
10
+ module AttributeAmbiguitySurfaceSpec
11
+ # Lenient recovery: sole-claimant rule spelled URI:local while the
12
+ # document's prefixes stay undeclared (#754 recovery case).
13
+ class LenientProbe < Lutaml::Model::Serializable
14
+ attribute :ext, :string
15
+
16
+ xml do
17
+ element "probe"
18
+ map_attribute "urn:example:v:ext", to: :ext
19
+ end
20
+ end
21
+
22
+ # Alias list: spelling variants of one name — at most one may occur.
23
+ class AliasProbe < Lutaml::Model::Serializable
24
+ attribute :status, :string
25
+
26
+ xml do
27
+ element "probe"
28
+ map_attribute %w[status product-status], to: :status
29
+ end
30
+ end
31
+ end
32
+
33
+ RSpec.describe "Ambiguous attribute matches surface instead of resolving by order" do
34
+ describe "lenient local-name recovery" do
35
+ it "recovers the single undeclared-prefix attribute" do
36
+ probe = AttributeAmbiguitySurfaceSpec::LenientProbe.from_xml(
37
+ %(<probe v:ext="1"/>),
38
+ )
39
+ expect(probe.ext).to eq("1")
40
+ end
41
+
42
+ it "binds no value when several same-local attributes match" do
43
+ expect do
44
+ probe = AttributeAmbiguitySurfaceSpec::LenientProbe.from_xml(
45
+ %(<probe v:ext="1" w:ext="2"/>),
46
+ )
47
+ expect(probe.ext).to be_nil
48
+ end.to output(/ambiguous attribute/i).to_stderr
49
+ end
50
+
51
+ it "prefers the exact qualified match when the prefix IS declared" do
52
+ probe = AttributeAmbiguitySurfaceSpec::LenientProbe.from_xml(
53
+ %(<probe xmlns:v="urn:example:v" v:ext="1" w:ext="2"/>),
54
+ )
55
+ expect(probe.ext).to eq("1")
56
+ end
57
+ end
58
+
59
+ describe "alias lists" do
60
+ it "reads the single present alias" do
61
+ probe = AttributeAmbiguitySurfaceSpec::AliasProbe.from_xml(
62
+ %(<probe product-status="draft"/>),
63
+ )
64
+ expect(probe.status).to eq("draft")
65
+
66
+ probe = AttributeAmbiguitySurfaceSpec::AliasProbe.from_xml(
67
+ %(<probe status="open"/>),
68
+ )
69
+ expect(probe.status).to eq("open")
70
+ end
71
+
72
+ it "binds no value when two aliases are simultaneously present" do
73
+ expect do
74
+ probe = AttributeAmbiguitySurfaceSpec::AliasProbe.from_xml(
75
+ %(<probe status="open" product-status="draft"/>),
76
+ )
77
+ expect(probe.status).to be_nil
78
+ end.to output(/ambiguous attribute aliases/i).to_stderr
79
+ end
80
+ end
81
+ end
@@ -64,6 +64,39 @@ RSpec.describe "XML plan fast path" do
64
64
  expect(parsed).to be_a(node)
65
65
  end
66
66
 
67
+ # lutaml-model#744: attribute plan rows are local-name keyed — a
68
+ # type-namespaced attribute (xmi:type vs type) must take the
69
+ # interpretive matcher, or the namespaced value is silently lost.
70
+ it "opts models with type-namespaced attributes out to the interpretive path" do
71
+ xmi_ns = Class.new(Lutaml::Xml::Namespace) do
72
+ uri "http://www.omg.org/spec/XMI/20131001"
73
+ prefix_default "xmi"
74
+ end
75
+ xmi_type = Class.new(Lutaml::Model::Type::String) do
76
+ xml { namespace xmi_ns }
77
+ end
78
+ parameter = Class.new(Lutaml::Model::Serializable) do
79
+ attribute :xmi_type, xmi_type
80
+ attribute :classifier_type, :string
81
+
82
+ xml do
83
+ element "ownedParameter"
84
+ map_attribute "type", to: :xmi_type
85
+ map_attribute "type", to: :classifier_type
86
+ end
87
+ end
88
+ stub_const("PlanFastPath::NamespacedParam", parameter)
89
+
90
+ parsed = parameter.from_xml(
91
+ '<ownedParameter xmlns:xmi="http://www.omg.org/spec/XMI/20131001" ' \
92
+ 'xmi:type="uml:Parameter" type="EAnone_void"/>',
93
+ )
94
+
95
+ expect(parsed.xmi_type).to eq("uml:Parameter")
96
+ expect(parsed.classifier_type).to eq("EAnone_void")
97
+ expect(Lutaml::Xml::PlanCompiler.compile(parameter, :default)).to be_nil
98
+ end
99
+
67
100
  it "is on by default and can be disabled" do
68
101
  expect(Lutaml::Model::Configuration.new.xml_plan_fast_path).to be(true)
69
102
 
@@ -40,11 +40,19 @@ RSpec.describe "XmlElement performance guard specs" do
40
40
  expect(element.find_attribute_value("missing")).to be_nil
41
41
  end
42
42
 
43
- it "finds first match from array of names" do
44
- result = element.find_attribute_value(["missing1", "class", "id"])
43
+ it "finds the single present name from array of names" do
44
+ result = element.find_attribute_value(["missing1", "class", "missing2"])
45
45
  expect(result).to eq("foo")
46
46
  end
47
47
 
48
+ it "binds no value when two names in the array are both present" do
49
+ # Attribute identity is (namespace URI, local name) — #841;
50
+ # array order must not arbitrate an ambiguity.
51
+ expect do
52
+ expect(element.find_attribute_value(["class", "id"])).to be_nil
53
+ end.to output(/ambiguous attribute aliases/i).to_stderr
54
+ end
55
+
48
56
  it "returns nil when no name in array matches" do
49
57
  expect(element.find_attribute_value(["missing1", "missing2"])).to be_nil
50
58
  end
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.52
4
+ version: 0.8.54
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-09-21 00:00:00.000000000 Z
11
+ date: 2026-09-22 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: base64
@@ -977,6 +977,7 @@ files:
977
977
  - spec/benchmarks/xml_parsing_benchmark_spec.rb
978
978
  - spec/ceramic_spec.rb
979
979
  - spec/fixtures/address.rb
980
+ - spec/fixtures/autoload_probe/never_loaded_model.rb
980
981
  - spec/fixtures/ceramic.rb
981
982
  - spec/fixtures/geolexica_v2_concept.rb
982
983
  - spec/fixtures/geolexica_v2_sample.yaml
@@ -1685,6 +1686,7 @@ files:
1685
1686
  - spec/lutaml/jsonld/adapter_spec.rb
1686
1687
  - spec/lutaml/key_value/adapter/toml/teptris_adapter_spec.rb
1687
1688
  - spec/lutaml/key_value/group_instantiate_spec.rb
1689
+ - spec/lutaml/key_value/subclass_remap_spec.rb
1688
1690
  - spec/lutaml/key_value/transformation/collection_serializer_spec.rb
1689
1691
  - spec/lutaml/key_value/transformation/rule_compiler_spec.rb
1690
1692
  - spec/lutaml/key_value/transformation/value_serializer_spec.rb
@@ -1729,6 +1731,7 @@ files:
1729
1731
  - spec/lutaml/model/custom_vobject_adapter_spec.rb
1730
1732
  - spec/lutaml/model/default_register_spec.rb
1731
1733
  - spec/lutaml/model/defaults_spec.rb
1734
+ - spec/lutaml/model/deferred_type_no_materialize_spec.rb
1732
1735
  - spec/lutaml/model/delegation_spec.rb
1733
1736
  - spec/lutaml/model/derived_attribute_serialization_spec.rb
1734
1737
  - spec/lutaml/model/dynamic_attribute_spec.rb
@@ -1899,6 +1902,7 @@ files:
1899
1902
  - spec/lutaml/xml/adapter/ox_adapter_spec.rb
1900
1903
  - spec/lutaml/xml/adapter/rexml_adapter_spec.rb
1901
1904
  - spec/lutaml/xml/adapter/xml_namespace_spec.rb
1905
+ - spec/lutaml/xml/attribute_ambiguity_surface_spec.rb
1902
1906
  - spec/lutaml/xml/attribute_element_namespace_disjoint_spec.rb
1903
1907
  - spec/lutaml/xml/attribute_namespace_disjointness_spec.rb
1904
1908
  - spec/lutaml/xml/builder/builder_spec.rb