lutaml-model 0.8.2 → 0.8.4

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 (77) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop_todo.yml +23 -23
  3. data/README.adoc +213 -1
  4. data/docs/_guides/document-validation.adoc +303 -0
  5. data/docs/_guides/index.adoc +1 -0
  6. data/docs/_guides/xml-mapping.adoc +9 -1
  7. data/docs/_guides/xml_mappings/07_best_practices.adoc +36 -0
  8. data/docs/_guides/xml_mappings/08_troubleshooting.adoc +89 -0
  9. data/docs/_tutorials/lutaml-xml-architecture.adoc +6 -1
  10. data/lib/lutaml/model/attribute.rb +19 -1
  11. data/lib/lutaml/model/error/liquid_drop_already_registered_error.rb +11 -0
  12. data/lib/lutaml/model/error/ordered_content_mapping_error.rb +17 -0
  13. data/lib/lutaml/model/global_context.rb +1 -0
  14. data/lib/lutaml/model/liquefiable.rb +12 -15
  15. data/lib/lutaml/model/mapping/mapping_rule.rb +10 -2
  16. data/lib/lutaml/model/mapping_hash.rb +1 -1
  17. data/lib/lutaml/model/services/transformer.rb +67 -32
  18. data/lib/lutaml/model/transform.rb +41 -4
  19. data/lib/lutaml/model/uninitialized_class.rb +11 -5
  20. data/lib/lutaml/model/validation/concerns/has_issues.rb +27 -0
  21. data/lib/lutaml/model/validation/context.rb +36 -0
  22. data/lib/lutaml/model/validation/issue.rb +62 -0
  23. data/lib/lutaml/model/validation/layer_result.rb +34 -0
  24. data/lib/lutaml/model/validation/profile.rb +66 -0
  25. data/lib/lutaml/model/validation/registry.rb +60 -0
  26. data/lib/lutaml/model/validation/remediation.rb +33 -0
  27. data/lib/lutaml/model/validation/remediation_result.rb +20 -0
  28. data/lib/lutaml/model/validation/report.rb +39 -0
  29. data/lib/lutaml/model/validation/rule.rb +59 -0
  30. data/lib/lutaml/model/validation.rb +2 -1
  31. data/lib/lutaml/model/validation_framework.rb +77 -0
  32. data/lib/lutaml/model/version.rb +1 -1
  33. data/lib/lutaml/model.rb +4 -0
  34. data/lib/lutaml/xml/adapter/nokogiri_adapter.rb +9 -2
  35. data/lib/lutaml/xml/adapter/oga_adapter.rb +11 -3
  36. data/lib/lutaml/xml/adapter/ox_adapter.rb +5 -2
  37. data/lib/lutaml/xml/adapter/rexml_adapter.rb +10 -3
  38. data/lib/lutaml/xml/adapter_element.rb +26 -2
  39. data/lib/lutaml/xml/data_model.rb +14 -0
  40. data/lib/lutaml/xml/document.rb +3 -0
  41. data/lib/lutaml/xml/element.rb +8 -2
  42. data/lib/lutaml/xml/mapping.rb +9 -0
  43. data/lib/lutaml/xml/model_transform.rb +42 -0
  44. data/lib/lutaml/xml/schema/xsd/base.rb +4 -1
  45. data/lib/lutaml/xml/schema/xsd/schema_path.rb +6 -0
  46. data/lib/lutaml/xml/serialization/instance_methods.rb +3 -1
  47. data/lib/lutaml/xml/transformation/ordered_applier.rb +46 -2
  48. data/lib/lutaml/xml/transformation.rb +40 -1
  49. data/lib/lutaml/xml/xml_element.rb +8 -7
  50. data/lutaml-model.gemspec +1 -2
  51. data/spec/lutaml/model/attribute_default_cache_spec.rb +58 -0
  52. data/spec/lutaml/model/liquefiable_spec.rb +22 -6
  53. data/spec/lutaml/model/liquid_compatibility_spec.rb +442 -0
  54. data/spec/lutaml/model/ordered_content_spec.rb +5 -5
  55. data/spec/lutaml/model/services/transformer_spec.rb +43 -0
  56. data/spec/lutaml/model/transform_cache_spec.rb +62 -0
  57. data/spec/lutaml/model/transform_dynamic_attributes_spec.rb +41 -0
  58. data/spec/lutaml/model/uninitialized_class_deep_dup_spec.rb +39 -0
  59. data/spec/lutaml/model/uninitialized_class_spec.rb +14 -2
  60. data/spec/lutaml/model/validation/concerns/has_issues_spec.rb +76 -0
  61. data/spec/lutaml/model/validation/context_spec.rb +60 -0
  62. data/spec/lutaml/model/validation/issue_spec.rb +77 -0
  63. data/spec/lutaml/model/validation/layer_result_spec.rb +66 -0
  64. data/spec/lutaml/model/validation/profile_spec.rb +134 -0
  65. data/spec/lutaml/model/validation/registry_spec.rb +94 -0
  66. data/spec/lutaml/model/validation/remediation_result_spec.rb +23 -0
  67. data/spec/lutaml/model/validation/remediation_spec.rb +72 -0
  68. data/spec/lutaml/model/validation/report_spec.rb +58 -0
  69. data/spec/lutaml/model/validation/rule_spec.rb +134 -0
  70. data/spec/lutaml/model/validation/uninitialized_class_validate_spec.rb +29 -0
  71. data/spec/lutaml/model/validation/validation_error_spec.rb +29 -0
  72. data/spec/lutaml/model/validation/validation_framework_spec.rb +110 -0
  73. data/spec/lutaml/xml/content_model_validation_spec.rb +157 -0
  74. data/spec/lutaml/xml/mapping_spec.rb +12 -7
  75. data/spec/lutaml/xml/schema/xsd/glob_spec.rb +12 -0
  76. metadata +46 -21
  77. data/spec/fixtures/liquid_templates/_ceramics.liquid +0 -3
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+
5
+ RSpec.describe "Transform caching" do
6
+ after { Lutaml::Model::Transform.clear_cache! }
7
+
8
+ describe ".cached_transform" do
9
+ it "returns same instance for same context and register" do
10
+ klass = Class.new(Lutaml::Model::Serializable) do
11
+ attribute :name, :string
12
+ end
13
+
14
+ t1 = Lutaml::Model::Transform.cached_transform(klass, :default)
15
+ t2 = Lutaml::Model::Transform.cached_transform(klass, :default)
16
+ expect(t1).to equal(t2)
17
+ end
18
+
19
+ it "returns different instances for different contexts" do
20
+ klass_a = Class.new(Lutaml::Model::Serializable) do
21
+ attribute :name, :string
22
+ end
23
+ klass_b = Class.new(Lutaml::Model::Serializable) do
24
+ attribute :title, :string
25
+ end
26
+
27
+ t1 = Lutaml::Model::Transform.cached_transform(klass_a, :default)
28
+ t2 = Lutaml::Model::Transform.cached_transform(klass_b, :default)
29
+ expect(t1).not_to equal(t2)
30
+ end
31
+ end
32
+
33
+ describe ".clear_cache!" do
34
+ it "clears the cache" do
35
+ klass = Class.new(Lutaml::Model::Serializable) do
36
+ attribute :name, :string
37
+ end
38
+
39
+ Lutaml::Model::Transform.cached_transform(klass, :default)
40
+ expect(Lutaml::Model::Transform.cache_size).to be > 0
41
+
42
+ Lutaml::Model::Transform.clear_cache!
43
+ expect(Lutaml::Model::Transform.cache_size).to eq(0)
44
+ end
45
+ end
46
+
47
+ describe "cache eviction" do
48
+ it "evicts entries when exceeding MAX_CACHE_SIZE" do
49
+ stub_const("Lutaml::Model::Transform::MAX_CACHE_SIZE", 4)
50
+
51
+ classes = Array.new(6) do |i|
52
+ Class.new(Lutaml::Model::Serializable) do
53
+ attribute :"attr_#{i}", :string
54
+ end
55
+ end
56
+
57
+ classes.each { |k| Lutaml::Model::Transform.cached_transform(k, :default) }
58
+
59
+ expect(Lutaml::Model::Transform.cache_size).to be <= 4
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+
5
+ RSpec.describe "Transform with dynamically added attributes" do
6
+ before do
7
+ Lutaml::Model::GlobalContext.clear_caches
8
+ end
9
+
10
+ it "picks up attributes added after initial class definition" do
11
+ base_class = Class.new(Lutaml::Model::Serializable) do
12
+ attribute :name, :string
13
+
14
+ xml do
15
+ root "test"
16
+ map_element "name", to: :name
17
+ end
18
+
19
+ def self.name
20
+ "DynamicAttributeTestClass"
21
+ end
22
+ end
23
+
24
+ # Parse once to populate Transform cache
25
+ base_class.from_xml("<test><name>initial</name></test>")
26
+
27
+ # Dynamically add a new attribute and mapping (like xmi EaRoot.load_extension)
28
+ base_class.class_eval do
29
+ attribute :extra, :string
30
+
31
+ xml do
32
+ map_element "extra", to: :extra
33
+ end
34
+ end
35
+
36
+ # The Transform must see the newly added attribute
37
+ result = base_class.from_xml("<test><name>hello</name><extra>world</extra></test>")
38
+ expect(result.name).to eq("hello")
39
+ expect(result.extra).to eq("world")
40
+ end
41
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+
5
+ RSpec.describe "UninitializedClass deep_dup compatibility" do
6
+ let(:uninitialized) { Lutaml::Model::UninitializedClass.instance }
7
+
8
+ # Replicates the rng gem's ExternalRefResolver#deep_dup pattern
9
+ def deep_dup(obj)
10
+ case obj
11
+ when Array
12
+ obj.map { |o| deep_dup(o) }
13
+ when Hash
14
+ obj.each_with_object({}) { |(k, v), h| h[deep_dup(k)] = deep_dup(v) }
15
+ when NilClass, Symbol, Numeric, TrueClass, FalseClass
16
+ obj
17
+ else
18
+ obj.dup
19
+ end
20
+ end
21
+
22
+ it "does not raise TypeError when deep_dup encounters UninitializedClass in a hash value" do
23
+ data = { "key" => "value", "missing" => uninitialized }
24
+ result = deep_dup(data)
25
+ expect(result["missing"]).to equal(uninitialized)
26
+ end
27
+
28
+ it "does not raise TypeError when deep_dup encounters UninitializedClass in an array" do
29
+ data = ["hello", uninitialized, "world"]
30
+ result = deep_dup(data)
31
+ expect(result[1]).to equal(uninitialized)
32
+ end
33
+
34
+ it "does not raise TypeError when deep_dup encounters UninitializedClass as hash key" do
35
+ data = { uninitialized => "value" }
36
+ result = deep_dup(data)
37
+ expect(result.keys.first).to equal(uninitialized)
38
+ end
39
+ end
@@ -77,8 +77,8 @@ RSpec.describe Lutaml::Model::UninitializedClass do
77
77
  end
78
78
 
79
79
  context "when method doesn't end with '?'" do
80
- it "raises NoMethodError" do
81
- expect { uninitialized.unknown_method }.to raise_error(NoMethodError)
80
+ it "returns nil" do
81
+ expect(uninitialized.unknown_method).to be_nil
82
82
  end
83
83
  end
84
84
  end
@@ -93,4 +93,16 @@ RSpec.describe Lutaml::Model::UninitializedClass do
93
93
  expect(uninitialized.respond_to?(:unknown_method)).to be false
94
94
  end
95
95
  end
96
+
97
+ describe "#dup" do
98
+ it "returns self" do
99
+ expect(uninitialized.dup).to equal(uninitialized)
100
+ end
101
+ end
102
+
103
+ describe "#clone" do
104
+ it "returns self" do
105
+ expect(uninitialized.clone).to equal(uninitialized)
106
+ end
107
+ end
96
108
  end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+ require "lutaml/model/validation_framework"
5
+
6
+ RSpec.describe Lutaml::Model::Validation::HasIssues do
7
+ let(:error_issue) do
8
+ Lutaml::Model::Validation::Issue.new(
9
+ severity: "error", code: "E-001", message: "bad",
10
+ )
11
+ end
12
+
13
+ let(:warning_issue) do
14
+ Lutaml::Model::Validation::Issue.new(
15
+ severity: "warning", code: "W-001", message: "meh",
16
+ )
17
+ end
18
+
19
+ let(:info_issue) do
20
+ Lutaml::Model::Validation::Issue.new(
21
+ severity: "info", code: "I-001", message: "fyi",
22
+ )
23
+ end
24
+
25
+ let(:notice_issue) do
26
+ Lutaml::Model::Validation::Issue.new(
27
+ severity: "notice", code: "N-001", message: "note",
28
+ )
29
+ end
30
+
31
+ let(:container) do
32
+ all_issues = [error_issue, warning_issue, info_issue, notice_issue]
33
+ klass = Class.new do
34
+ include Lutaml::Model::Validation::HasIssues
35
+
36
+ attr_reader :issues
37
+
38
+ def initialize(issues)
39
+ @issues = issues
40
+ end
41
+ end
42
+ klass.new(all_issues)
43
+ end
44
+
45
+ it "filters errors" do
46
+ expect(container.errors).to eq([error_issue])
47
+ end
48
+
49
+ it "filters warnings" do
50
+ expect(container.warnings).to eq([warning_issue])
51
+ end
52
+
53
+ it "filters infos" do
54
+ expect(container.infos).to eq([info_issue])
55
+ end
56
+
57
+ it "filters notices" do
58
+ expect(container.notices).to eq([notice_issue])
59
+ end
60
+
61
+ it "returns empty arrays when no matching severity" do
62
+ only_errors = Class.new do
63
+ include Lutaml::Model::Validation::HasIssues
64
+
65
+ def issues
66
+ [Lutaml::Model::Validation::Issue.new(
67
+ severity: "error", code: "X", message: "y",
68
+ )]
69
+ end
70
+ end.new
71
+
72
+ expect(only_errors.warnings).to be_empty
73
+ expect(only_errors.infos).to be_empty
74
+ expect(only_errors.notices).to be_empty
75
+ end
76
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+ require "lutaml/model/validation_framework"
5
+
6
+ RSpec.describe Lutaml::Model::Validation::Context do
7
+ subject(:context) { described_class.new }
8
+
9
+ it "starts with empty errors" do
10
+ expect(context.errors).to be_empty
11
+ end
12
+
13
+ describe "#add_error" do
14
+ it "accumulates errors" do
15
+ issue = Lutaml::Model::Validation::Issue.new(
16
+ severity: "error", code: "T-001", message: "bad",
17
+ )
18
+ context.add_error(issue)
19
+ expect(context.errors.length).to eq(1)
20
+ expect(context.errors.first).to eq(issue)
21
+ end
22
+ end
23
+
24
+ describe "#add_errors" do
25
+ it "concatenates multiple errors" do
26
+ issues = Array.new(2) do |i|
27
+ Lutaml::Model::Validation::Issue.new(
28
+ severity: "error", code: "T-#{i}", message: "bad #{i}",
29
+ )
30
+ end
31
+ context.add_errors(issues)
32
+ expect(context.errors.length).to eq(2)
33
+ end
34
+ end
35
+
36
+ describe "#rule_state" do
37
+ it "provides per-rule state hash" do
38
+ state = context.rule_state("R-001")
39
+ state[:count] = 5
40
+ expect(context.rule_state("R-001")[:count]).to eq(5)
41
+ end
42
+
43
+ it "isolates state between rules" do
44
+ context.rule_state("R-001")[:val] = "a"
45
+ context.rule_state("R-002")[:val] = "b"
46
+ expect(context.rule_state("R-001")[:val]).to eq("a")
47
+ expect(context.rule_state("R-002")[:val]).to eq("b")
48
+ end
49
+ end
50
+
51
+ describe "#reset!" do
52
+ it "clears errors and state" do
53
+ context.add_error(double("issue"))
54
+ context.rule_state("R")[:x] = 1
55
+ context.reset!
56
+ expect(context.errors).to be_empty
57
+ expect(context.rule_state("R")).to be_empty
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+ require "lutaml/model/validation_framework"
5
+
6
+ RSpec.describe Lutaml::Model::Validation::Issue do
7
+ subject(:issue) do
8
+ described_class.new(
9
+ severity: "error",
10
+ code: "TEST-001",
11
+ message: "Something is wrong",
12
+ location: "file.xml",
13
+ line: 42,
14
+ suggestion: "Fix it",
15
+ )
16
+ end
17
+
18
+ describe "SEVERITIES constant" do
19
+ it "defines allowed severity levels" do
20
+ expect(described_class::SEVERITIES).to eq(%w[error warning info notice])
21
+ end
22
+ end
23
+
24
+ describe "severity validation" do
25
+ it "accepts valid severities" do
26
+ described_class::SEVERITIES.each do |sev|
27
+ expect { described_class.new(severity: sev, code: "T", message: "m") }
28
+ .not_to raise_error
29
+ end
30
+ end
31
+
32
+ it "rejects invalid severity" do
33
+ expect do
34
+ described_class.new(severity: "critical", code: "T", message: "m")
35
+ end.to raise_error(ArgumentError, /Invalid severity: critical/)
36
+ end
37
+
38
+ it "allows nil severity" do
39
+ expect { described_class.new(severity: nil, code: "T", message: "m") }
40
+ .not_to raise_error
41
+ end
42
+ end
43
+
44
+ describe "serialization" do
45
+ it "serializes to JSON" do
46
+ parsed = JSON.parse(issue.to_json)
47
+ expect(parsed["severity"]).to eq("error")
48
+ expect(parsed["code"]).to eq("TEST-001")
49
+ expect(parsed["message"]).to eq("Something is wrong")
50
+ expect(parsed["location"]).to eq("file.xml")
51
+ expect(parsed["line"]).to eq(42)
52
+ expect(parsed["suggestion"]).to eq("Fix it")
53
+ end
54
+
55
+ it "round-trips through JSON" do
56
+ restored = described_class.from_json(issue.to_json)
57
+ expect(restored.code).to eq("TEST-001")
58
+ expect(restored.message).to eq("Something is wrong")
59
+ end
60
+ end
61
+
62
+ describe "severity predicates" do
63
+ it "returns correct predicate for error" do
64
+ expect(issue).to be_error
65
+ expect(issue).not_to be_warning
66
+ expect(issue).not_to be_info
67
+ expect(issue).not_to be_notice
68
+ end
69
+
70
+ it "returns correct predicate for warning" do
71
+ warning = described_class.new(severity: "warning", code: "W",
72
+ message: "m")
73
+ expect(warning).to be_warning
74
+ expect(warning).not_to be_error
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+ require "lutaml/model/validation_framework"
5
+
6
+ RSpec.describe Lutaml::Model::Validation::LayerResult do
7
+ subject(:layer) do
8
+ described_class.new(
9
+ name: "Structure",
10
+ status: "fail",
11
+ duration_ms: 15,
12
+ issues: [error_issue, warning_issue],
13
+ )
14
+ end
15
+
16
+ let(:error_issue) do
17
+ Lutaml::Model::Validation::Issue.new(
18
+ severity: "error", code: "E-001", message: "bad",
19
+ )
20
+ end
21
+
22
+ let(:warning_issue) do
23
+ Lutaml::Model::Validation::Issue.new(
24
+ severity: "warning", code: "W-001", message: "meh",
25
+ )
26
+ end
27
+
28
+ it "serializes to JSON" do
29
+ parsed = JSON.parse(layer.to_json)
30
+ expect(parsed["name"]).to eq("Structure")
31
+ expect(parsed["status"]).to eq("fail")
32
+ expect(parsed["issues"].length).to eq(2)
33
+ end
34
+
35
+ describe "#pass? / #fail?" do
36
+ it "checks status" do
37
+ expect(layer).not_to be_pass
38
+ expect(layer).to be_fail
39
+ end
40
+
41
+ it "returns true for pass status" do
42
+ passing = described_class.new(name: "x", status: "pass")
43
+ expect(passing).to be_pass
44
+ end
45
+ end
46
+
47
+ describe "severity filtering via HasIssues" do
48
+ it "filters errors" do
49
+ expect(layer.errors.length).to eq(1)
50
+ expect(layer.errors.first.code).to eq("E-001")
51
+ end
52
+
53
+ it "filters warnings" do
54
+ expect(layer.warnings.length).to eq(1)
55
+ expect(layer.warnings.first.code).to eq("W-001")
56
+ end
57
+
58
+ it "returns empty for infos" do
59
+ expect(layer.infos).to be_empty
60
+ end
61
+
62
+ it "returns empty for notices" do
63
+ expect(layer.notices).to be_empty
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,134 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+ require "lutaml/model/validation_framework"
5
+ require "tmpdir"
6
+ require "yaml"
7
+
8
+ RSpec.describe Lutaml::Model::Validation::Profile do
9
+ let(:registry) { Lutaml::Model::Validation.new_registry }
10
+
11
+ let(:profile) do
12
+ described_class.new(
13
+ name: "basic",
14
+ description: "Basic checks",
15
+ rule_names: ["TestRule"],
16
+ )
17
+ end
18
+
19
+ it "stores profile attributes" do
20
+ expect(profile.name).to eq("basic")
21
+ expect(profile.description).to eq("Basic checks")
22
+ expect(profile.rule_names).to eq(["TestRule"])
23
+ expect(profile.imports).to eq([])
24
+ end
25
+
26
+ describe ".load" do
27
+ it "loads profile from YAML file" do
28
+ Dir.mktmpdir do |dir|
29
+ yaml_path = File.join(dir, "basic.yml")
30
+ File.write(yaml_path, YAML.dump({
31
+ "name" => "loaded",
32
+ "description" => "Loaded profile",
33
+ "rules" => ["RuleA", "RuleB"],
34
+ "import" => ["base"],
35
+ }))
36
+ loaded = described_class.load(yaml_path)
37
+ expect(loaded.name).to eq("loaded")
38
+ expect(loaded.rule_names).to eq(["RuleA", "RuleB"])
39
+ expect(loaded.imports).to eq(["base"])
40
+ end
41
+ end
42
+
43
+ it "rejects YAML without a name key" do
44
+ Dir.mktmpdir do |dir|
45
+ yaml_path = File.join(dir, "bad.yml")
46
+ File.write(yaml_path, YAML.dump({ "description" => "no name" }))
47
+ expect { described_class.load(yaml_path) }
48
+ .to raise_error(ArgumentError, /must contain a 'name' string key/)
49
+ end
50
+ end
51
+
52
+ it "rejects non-hash YAML content" do
53
+ Dir.mktmpdir do |dir|
54
+ yaml_path = File.join(dir, "array.yml")
55
+ File.write(yaml_path, YAML.dump(["just", "an", "array"]))
56
+ expect { described_class.load(yaml_path) }
57
+ .to raise_error(ArgumentError, /must contain a 'name' string key/)
58
+ end
59
+ end
60
+ end
61
+
62
+ describe "#resolve" do
63
+ context "with imports" do
64
+ let(:base_rule_class) do
65
+ Class.new(Lutaml::Model::Validation::Rule) do
66
+ def self.name = "BaseRule"
67
+ def code = "BASE-001"
68
+ end
69
+ end
70
+
71
+ let(:extra_rule_class) do
72
+ Class.new(Lutaml::Model::Validation::Rule) do
73
+ def self.name = "ExtraRule"
74
+ def code = "EXTRA-001"
75
+ end
76
+ end
77
+
78
+ let(:base_profile) do
79
+ described_class.new(name: "base", rule_names: ["BaseRule"])
80
+ end
81
+
82
+ let(:extended_profile) do
83
+ described_class.new(
84
+ name: "extended",
85
+ rule_names: ["ExtraRule"],
86
+ imports: ["base"],
87
+ )
88
+ end
89
+
90
+ it "resolves imports" do
91
+ registry.register(base_rule_class)
92
+ registry.register(extra_rule_class)
93
+ profiles = { "base" => base_profile, "extended" => extended_profile }
94
+
95
+ rules = extended_profile.resolve(registry, profiles)
96
+ codes = rules.map(&:code)
97
+ expect(codes).to include("BASE-001", "EXTRA-001")
98
+ end
99
+
100
+ it "ignores missing imports" do
101
+ registry.register(extra_rule_class)
102
+ orphan = described_class.new(
103
+ name: "orphan",
104
+ rule_names: ["ExtraRule"],
105
+ imports: ["nonexistent"],
106
+ )
107
+ rules = orphan.resolve(registry, {})
108
+ expect(rules.map(&:code)).to eq(["EXTRA-001"])
109
+ end
110
+
111
+ it "detects circular imports" do
112
+ a = described_class.new(name: "a", imports: ["b"])
113
+ b = described_class.new(name: "b", imports: ["a"])
114
+ profiles = { "a" => a, "b" => b }
115
+ expect { a.resolve(registry, profiles) }
116
+ .to raise_error(ArgumentError, /Circular profile import detected: a/)
117
+ end
118
+
119
+ it "detects deep circular imports" do
120
+ a = described_class.new(name: "a", imports: ["b"])
121
+ b = described_class.new(name: "b", imports: ["c"])
122
+ c = described_class.new(name: "c", imports: ["a"])
123
+ profiles = { "a" => a, "b" => b, "c" => c }
124
+ expect { a.resolve(registry, profiles) }
125
+ .to raise_error(ArgumentError, /Circular profile import detected/)
126
+ end
127
+ end
128
+
129
+ it "skips rules with unknown class names" do
130
+ result = profile.resolve(registry)
131
+ expect(result).to be_empty
132
+ end
133
+ end
134
+ end
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+ require "lutaml/model/validation_framework"
5
+
6
+ RSpec.describe Lutaml::Model::Validation::Registry do
7
+ subject(:registry) { described_class.new }
8
+
9
+ let(:rule_class) do
10
+ Class.new(Lutaml::Model::Validation::Rule) do
11
+ def code = "REG-001"
12
+ def category = :test
13
+ end
14
+ end
15
+
16
+ before { registry.register(rule_class) }
17
+
18
+ it "registers and returns rule instances" do
19
+ rules = registry.all
20
+ expect(rules.length).to eq(1)
21
+ expect(rules.first).to be_a(rule_class)
22
+ expect(rules.first.code).to eq("REG-001")
23
+ end
24
+
25
+ it "caches rule instances" do
26
+ first_call = registry.all
27
+ second_call = registry.all
28
+ expect(first_call).to equal(second_call)
29
+ end
30
+
31
+ it "invalidates cache on new registration" do
32
+ first = registry.all
33
+ new_rule = Class.new(Lutaml::Model::Validation::Rule) do
34
+ def code = "REG-002"
35
+ end
36
+ registry.register(new_rule)
37
+ expect(registry.all).not_to equal(first)
38
+ expect(registry.all.length).to eq(2)
39
+ end
40
+
41
+ it "prevents duplicate registration" do
42
+ registry.register(rule_class)
43
+ expect(registry.size).to eq(1)
44
+ end
45
+
46
+ it "filters by category" do
47
+ rules = registry.for_category(:test)
48
+ expect(rules.length).to eq(1)
49
+ expect(registry.for_category(:other)).to be_empty
50
+ end
51
+
52
+ it "finds by code" do
53
+ rule = registry.find("REG-001")
54
+ expect(rule).not_to be_nil
55
+ expect(rule.code).to eq("REG-001")
56
+ end
57
+
58
+ it "returns nil for unknown code" do
59
+ expect(registry.find("UNKNOWN")).to be_nil
60
+ end
61
+
62
+ it "resets" do
63
+ registry.reset!
64
+ expect(registry.size).to eq(0)
65
+ expect(registry.all).to be_empty
66
+ end
67
+
68
+ it "returns rule classes" do
69
+ expect(registry.rule_classes).to eq([rule_class])
70
+ end
71
+
72
+ it "returns a defensive copy of rule_classes" do
73
+ classes = registry.rule_classes
74
+ classes << String
75
+ expect(registry.rule_classes.length).to eq(1)
76
+ end
77
+
78
+ describe "#auto_discover" do
79
+ it "requires rule files from a directory" do
80
+ Dir.mktmpdir do |dir|
81
+ rule_file = File.join(dir, "discovered_rule.rb")
82
+ File.write(rule_file, <<~RUBY)
83
+ class DiscoveredTestRule < Lutaml::Model::Validation::Rule
84
+ def code = "DISC-001"
85
+ end
86
+ RUBY
87
+
88
+ registry.auto_discover(dir, pattern: "*_rule.rb")
89
+ registry.register(DiscoveredTestRule)
90
+ expect(registry.find("DISC-001")).not_to be_nil
91
+ end
92
+ end
93
+ end
94
+ end