From 7823f33260e298b1b6b98a255ceddeb25af558a5 Mon Sep 17 00:00:00 2001 From: HassanAkbar Date: Fri, 31 Jul 2026 12:30:39 +0500 Subject: [PATCH 1/5] add plugin base and wire validator plugins into schematron --- lib/dcc.rb | 20 +++++++ lib/dcc/error.rb | 3 + lib/dcc/plugin.rb | 6 +- lib/dcc/plugin/base.rb | 64 ++++++++++++++++++++++ lib/dcc/validate/schematron/profile.rb | 37 ++++++++----- spec/dcc/plugin/base_spec.rb | 60 ++++++++++++++++++++ spec/dcc/plugin_spec.rb | 1 + spec/dcc/validate/schematron_spec.rb | 52 ++++++++++++++++++ spec/dcc_spec.rb | 30 ++++++++++ spec/fixtures/plugins/dcc/sample_plugin.rb | 13 +++++ 10 files changed, 270 insertions(+), 16 deletions(-) create mode 100644 lib/dcc/plugin/base.rb create mode 100644 spec/dcc/plugin/base_spec.rb create mode 100644 spec/fixtures/plugins/dcc/sample_plugin.rb diff --git a/lib/dcc.rb b/lib/dcc.rb index ed8a1c5..2cc3195 100644 --- a/lib/dcc.rb +++ b/lib/dcc.rb @@ -78,6 +78,26 @@ def build(version: 3, &) Builder.call(version: version, &) end + # Load plugin gems by name. + # + # A gem name maps to its entry file the usual way: "dcc-audit" loads + # "dcc/audit". A slash-separated path is accepted unchanged. + # + # @param names [Array] plugin gem names. + # @raise [Dcc::PluginError] if a plugin's entry file cannot be loaded. + # @return [Array] the entry-file path for each name given. + # A plugin already loaded is left alone and its path still returned. + def load_plugins(*names) + names.flatten.map do |name| + path = name.to_s.tr("-", "/") + require path + path + rescue ::LoadError => e + raise PluginError, "could not load plugin #{name}: tried " \ + "require #{path.inspect} (#{e.message})" + end + end + # Migrate a parsed DCC object from one schema version to another. # @param dcc [Dcc::V2::DigitalCalibrationCertificate, Dcc::V3::DigitalCalibrationCertificate] # @param from [String] source version, e.g. "2.3.0". diff --git a/lib/dcc/error.rb b/lib/dcc/error.rb index 0c02515..f897975 100644 --- a/lib/dcc/error.rb +++ b/lib/dcc/error.rb @@ -40,6 +40,9 @@ class UnknownVersionError < Error; end # Raised when the builder DSL is used incorrectly. class BuilderError < Error; end + # Raised when a plugin gem's entry file cannot be loaded. + class PluginError < Error; end + # Raised when a soft dependency (`xmldsig`, `sinatra`, etc.) is required # but not installed. class MissingDependencyError < Error diff --git a/lib/dcc/plugin.rb b/lib/dcc/plugin.rb index 1922750..4ed4fff 100644 --- a/lib/dcc/plugin.rb +++ b/lib/dcc/plugin.rb @@ -5,13 +5,17 @@ # without modifying the core codebase (Open/Closed Principle). # # @example Register a custom validator -# Dcc::Plugin.register(:validators, MyCustomRule) +# class MyPlugin +# include Dcc::Plugin::Base +# register_validator MyCustomRule +# end # # @example List all registered validators # Dcc::Plugin.all(:validators) module Dcc module Plugin autoload :Registry, "dcc/plugin/registry" + autoload :Base, "dcc/plugin/base" class << self # @param category [Symbol] e.g. :validators, :converters, :cli_commands. diff --git a/lib/dcc/plugin/base.rb b/lib/dcc/plugin/base.rb new file mode 100644 index 0000000..decdba4 --- /dev/null +++ b/lib/dcc/plugin/base.rb @@ -0,0 +1,64 @@ +# frozen_string_literal: true + +module Dcc + module Plugin + # Included by a plugin class so it can declare what it adds to Dcc. + # + # @example + # class MyPlugin + # include Dcc::Plugin::Base + # register_validator MyRule + # end + module Base + # @param base [Class] the including class. + def self.included(base) + base.extend(ClassMethods) + end + + # `Profile#call` does `rule_class.new.check_on(dcc)`, so a validator + # has to be a class. An instance responds to `#check_on` and so looks + # right, but would fail deep inside validation with a message naming + # neither the plugin nor the author's line. + # + # @param entry [Object] the candidate rule. + # @raise [ArgumentError] unless entry is a class defining #check_on. + # @return [Class] the entry. + def self.rule_class!(entry) + return entry if rule_class?(entry) + + raise ::ArgumentError, + "expected a rule class responding to #check_on, " \ + "got #{describe(entry)}" + end + + # @param entry [Object] + # @return [Boolean] + def self.rule_class?(entry) + entry.is_a?(::Class) && entry.method_defined?(:check_on) + end + private_class_method :rule_class? + + # @param entry [Object] + # @return [String] + def self.describe(entry) + return entry.inspect if entry.is_a?(::Class) + + "an instance of #{entry.class}" + end + private_class_method :describe + + # Declaration helpers available on the including class. + module ClassMethods + # Add a Schematron rule to the active validation profile. + # + # @param rule_class [Class] a rule class defining `#check_on(dcc)`. + # @raise [ArgumentError] unless rule_class is such a class. + # @return [Class] the rule class. + def register_validator(rule_class) + checked = ::Dcc::Plugin::Base.rule_class!(rule_class) + ::Dcc::Plugin.register(:validators, checked) + end + end + end + end +end diff --git a/lib/dcc/validate/schematron/profile.rb b/lib/dcc/validate/schematron/profile.rb index 181aa92..515e607 100644 --- a/lib/dcc/validate/schematron/profile.rb +++ b/lib/dcc/validate/schematron/profile.rb @@ -15,25 +15,32 @@ module Schematron # result.ok? # => true # result.issues.size # => 0 class Profile + # The rules shipped with the gem, in execution order. + DEFAULT_RULES = [ + Rules::UsedMethodsPlacement, + Rules::UsedSoftwarePlacement, + Rules::InfluenceConditionsPlacement, + Rules::SchemaVersionCheck, + Rules::IdRefIdLinking, + Rules::IsoCodeValidation, + Rules::DateRangeCheck, + Rules::ReleaseFormatCheck, + Rules::UncertaintyConsistency, + Rules::UnitFormatCheck, + Rules::NonSiDeclaration, + Rules::LanguageCodeDedup, + Rules::XmlListSpacing, + ].freeze + private_constant :DEFAULT_RULES + attr_reader :dcc, :rules + # Plugin validators are read here, not at class-definition time, so + # a plugin loaded after this class still takes effect. Frozen because + # `attr_reader :rules` hands the array straight to callers. def initialize(dcc) @dcc = dcc - @rules = [ - Rules::UsedMethodsPlacement, - Rules::UsedSoftwarePlacement, - Rules::InfluenceConditionsPlacement, - Rules::SchemaVersionCheck, - Rules::IdRefIdLinking, - Rules::IsoCodeValidation, - Rules::DateRangeCheck, - Rules::ReleaseFormatCheck, - Rules::UncertaintyConsistency, - Rules::UnitFormatCheck, - Rules::NonSiDeclaration, - Rules::LanguageCodeDedup, - Rules::XmlListSpacing, - ] + @rules = (DEFAULT_RULES + ::Dcc::Plugin.all(:validators)).freeze end # Run all rules and return a `Dcc::Validate::Result`. diff --git a/spec/dcc/plugin/base_spec.rb b/spec/dcc/plugin/base_spec.rb new file mode 100644 index 0000000..3130bb2 --- /dev/null +++ b/spec/dcc/plugin/base_spec.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe Dcc::Plugin::Base do + before { Dcc::Plugin.reset! } + after { Dcc::Plugin.reset! } + + let(:plugin_class) do + Class.new do + include Dcc::Plugin::Base + end + end + + let(:rule) do + Class.new do + def check_on(_dcc) + [] + end + end + end + + it "registers a validator through the class-level helper" do + plugin_class.register_validator(rule) + expect(Dcc::Plugin.all(:validators)).to include(rule) + end + + it "returns the rule class from register_validator" do + expect(plugin_class.register_validator(rule)).to be(rule) + end + + it "rejects an instance where a rule class was expected" do + expect { plugin_class.register_validator(rule.new) } + .to raise_error(ArgumentError, /got an instance of/) + end + + it "rejects a class that does not define check_on" do + expect { plugin_class.register_validator(Class.new) } + .to raise_error(ArgumentError, /responding to #check_on/) + end + + # Every real rule in the gem subclasses `Rules::Base` and defines no + # `check_on` of its own, so the guard has to see the inherited one. + describe "a rule that defines no check_on of its own" do + let(:subclass) { Class.new(rule) } + + it "carries no check_on of its own" do + expect(subclass.instance_methods(false)).not_to include(:check_on) + end + + it "inherits check_on from its superclass" do + expect(subclass.instance_method(:check_on).owner).to be(rule) + end + + it "is accepted on that inherited method alone" do + plugin_class.register_validator(subclass) + expect(Dcc::Plugin.all(:validators)).to include(subclass) + end + end +end diff --git a/spec/dcc/plugin_spec.rb b/spec/dcc/plugin_spec.rb index a3af1f1..d2a280e 100644 --- a/spec/dcc/plugin_spec.rb +++ b/spec/dcc/plugin_spec.rb @@ -8,6 +8,7 @@ def name; "sample"; end RSpec.describe Dcc::Plugin do before { described_class.reset! } + after { described_class.reset! } describe ".register and .all" do it "stores plugins by category" do diff --git a/spec/dcc/validate/schematron_spec.rb b/spec/dcc/validate/schematron_spec.rb index f60782a..3b458ab 100644 --- a/spec/dcc/validate/schematron_spec.rb +++ b/spec/dcc/validate/schematron_spec.rb @@ -77,3 +77,55 @@ expect(issues).to be_an(Array) end end + +# A plugin-supplied rule. Fires on any document carrying a schemaVersion, +# so it is guaranteed to trigger on the valid.xml fixture. +class PluginProbeRule < Dcc::Validate::Schematron::Rules::Base + def check_on(dcc) + return [] unless Dcc::TypeGuards.has_attribute?(dcc, :schema_version) + + [issue(severity: :error, message: "plugin rule fired")] + end +end + +RSpec.describe Dcc::Validate::Schematron::Profile do + let(:dcc) { Dcc.parse(File.read(fixtures_path("dcclib", "valid.xml"))) } + + before do + Dcc::V3.load_all! + Dcc::Plugin.reset! + end + + after { Dcc::Plugin.reset! } + + it "keeps the built-in rules" do + expect(described_class.new(dcc).rules) + .to include(Dcc::Validate::Schematron::Rules::DateRangeCheck) + end + + it "freezes the rule list it exposes" do + expect(described_class.new(dcc).rules).to be_frozen + end + + it "omits plugin rules that were never registered" do + expect(described_class.new(dcc).rules).not_to include(PluginProbeRule) + end + + it "appends a registered plugin validator" do + Dcc::Plugin.register(:validators, PluginProbeRule) + expect(described_class.new(dcc).rules).to include(PluginProbeRule) + end + + it "fires a plugin rule during a real Schematron run" do + Dcc::Plugin.register(:validators, PluginProbeRule) + result = Dcc::Validate::Schematron.call(dcc) + expect(result.issues.map(&:code)) + .to include("dcc.schematron.plugin_probe_rule") + end + + it "reports no plugin code when nothing is registered" do + result = Dcc::Validate::Schematron.call(dcc) + expect(result.issues.map(&:code)) + .not_to include("dcc.schematron.plugin_probe_rule") + end +end diff --git a/spec/dcc_spec.rb b/spec/dcc_spec.rb index f556811..cfcc932 100644 --- a/spec/dcc_spec.rb +++ b/spec/dcc_spec.rb @@ -2,6 +2,8 @@ require "spec_helper" +$LOAD_PATH.unshift(fixtures_path("plugins")) + RSpec.describe Dcc do describe ".parser_for" do it "returns Dcc::V2 for version 2" do @@ -63,4 +65,32 @@ expect(Dcc.io_like?(nil)).to be(false) end end + + describe ".load_plugins" do + before { Dcc::Plugin.reset! } + after { Dcc::Plugin.reset! } + + it "maps a gem name to its entry-file path" do + expect(described_class.load_plugins("dcc-version")) + .to eq(["dcc/version"]) + end + + it "names the plugin when its entry file is missing" do + expect { described_class.load_plugins("dcc-nope-not-real") } + .to raise_error(Dcc::PluginError, /dcc-nope-not-real/) + end + + it "names the path it tried when loading fails" do + expect { described_class.load_plugins("dcc-nope-not-real") } + .to raise_error(Dcc::PluginError, %r{dcc/nope/not/real}) + end + + # Only this example may load the fixture: `require` is idempotent per + # process, so a second one would find nothing registered. + it "requires the plugin so its declarations take effect" do + described_class.load_plugins("dcc-sample_plugin") + expect(Dcc::Plugin.all(:validators)) + .to include(DccSamplePlugin::SampleRule) + end + end end diff --git a/spec/fixtures/plugins/dcc/sample_plugin.rb b/spec/fixtures/plugins/dcc/sample_plugin.rb new file mode 100644 index 0000000..5ea3862 --- /dev/null +++ b/spec/fixtures/plugins/dcc/sample_plugin.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +# Loaded by `Dcc.load_plugins("dcc-sample_plugin")` in the plugin specs. +# Stands in for a real `dcc-*` plugin gem's entry file. +class DccSamplePlugin + include Dcc::Plugin::Base + + # A plugin's own rule, declared as the file loads. + class SampleRule < ::Dcc::Validate::Schematron::Rules::Base + end + + register_validator SampleRule +end From 6f4125ce00d62d33c068c9ce48b26067dd7ef7ee Mon Sep 17 00:00:00 2001 From: HassanAkbar Date: Fri, 31 Jul 2026 16:45:06 +0500 Subject: [PATCH 2/5] keep slash-separated plugin paths unchanged --- lib/dcc.rb | 3 ++- spec/dcc_spec.rb | 5 +++++ spec/fixtures/plugins/dcc/sample_plugin.rb | 2 +- 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/lib/dcc.rb b/lib/dcc.rb index 2cc3195..ed5e7a6 100644 --- a/lib/dcc.rb +++ b/lib/dcc.rb @@ -89,7 +89,8 @@ def build(version: 3, &) # A plugin already loaded is left alone and its path still returned. def load_plugins(*names) names.flatten.map do |name| - path = name.to_s.tr("-", "/") + path = name.to_s + path = path.tr("-", "/") unless path.include?("/") require path path rescue ::LoadError => e diff --git a/spec/dcc_spec.rb b/spec/dcc_spec.rb index cfcc932..068889c 100644 --- a/spec/dcc_spec.rb +++ b/spec/dcc_spec.rb @@ -75,6 +75,11 @@ .to eq(["dcc/version"]) end + it "leaves a slash-separated path alone, hyphens included" do + expect { described_class.load_plugins("dcc/nope-not-real") } + .to raise_error(Dcc::PluginError, %r{require "dcc/nope-not-real"}) + end + it "names the plugin when its entry file is missing" do expect { described_class.load_plugins("dcc-nope-not-real") } .to raise_error(Dcc::PluginError, /dcc-nope-not-real/) diff --git a/spec/fixtures/plugins/dcc/sample_plugin.rb b/spec/fixtures/plugins/dcc/sample_plugin.rb index 5ea3862..81e1f61 100644 --- a/spec/fixtures/plugins/dcc/sample_plugin.rb +++ b/spec/fixtures/plugins/dcc/sample_plugin.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -# Loaded by `Dcc.load_plugins("dcc-sample_plugin")` in the plugin specs. +# Loaded by `Dcc.load_plugins("dcc-sample_plugin")` in `spec/dcc_spec.rb`. # Stands in for a real `dcc-*` plugin gem's entry file. class DccSamplePlugin include Dcc::Plugin::Base From c376c7663c4d7b00fc2b8dfdfcbe34d4857b6de2 Mon Sep 17 00:00:00 2001 From: HassanAkbar Date: Fri, 31 Jul 2026 17:01:10 +0500 Subject: [PATCH 3/5] let plugin dependency errors through untouched --- lib/dcc.rb | 17 +++++++++++++++-- spec/dcc_spec.rb | 11 ++++++++++- spec/fixtures/plugins/dcc/brokendep.rb | 6 ++++++ 3 files changed, 31 insertions(+), 3 deletions(-) create mode 100644 spec/fixtures/plugins/dcc/brokendep.rb diff --git a/lib/dcc.rb b/lib/dcc.rb index ed5e7a6..7e73266 100644 --- a/lib/dcc.rb +++ b/lib/dcc.rb @@ -87,18 +87,31 @@ def build(version: 3, &) # @raise [Dcc::PluginError] if a plugin's entry file cannot be loaded. # @return [Array] the entry-file path for each name given. # A plugin already loaded is left alone and its path still returned. + # + # A plugin whose own `require` fails raises from inside a file we did + # find. Reporting that as a missing entry file points the author at the + # wrong problem, so only the entry path's own LoadError is wrapped. def load_plugins(*names) names.flatten.map do |name| - path = name.to_s - path = path.tr("-", "/") unless path.include?("/") + path = plugin_path(name) require path path rescue ::LoadError => e + raise unless e.path == path + raise PluginError, "could not load plugin #{name}: tried " \ "require #{path.inspect} (#{e.message})" end end + # "dcc-audit" is a gem name and maps to "dcc/audit". Anything already + # carrying a slash is a require path and is left alone, hyphens included. + def plugin_path(name) + text = name.to_s + text.include?("/") ? text : text.tr("-", "/") + end + private :plugin_path + # Migrate a parsed DCC object from one schema version to another. # @param dcc [Dcc::V2::DigitalCalibrationCertificate, Dcc::V3::DigitalCalibrationCertificate] # @param from [String] source version, e.g. "2.3.0". diff --git a/spec/dcc_spec.rb b/spec/dcc_spec.rb index 068889c..8c52992 100644 --- a/spec/dcc_spec.rb +++ b/spec/dcc_spec.rb @@ -2,7 +2,9 @@ require "spec_helper" -$LOAD_PATH.unshift(fixtures_path("plugins")) +# Appended, not prepended: the fixture tree mirrors real `dcc/` paths, so +# giving it precedence would let a fixture shadow the gem's own files. +$LOAD_PATH.push(fixtures_path("plugins")) RSpec.describe Dcc do describe ".parser_for" do @@ -75,6 +77,13 @@ .to eq(["dcc/version"]) end + # The entry file was found. Wrapping this would blame the plugin's own + # path for a dependency it failed to require. + it "lets a failure from inside the plugin through untouched" do + expect { described_class.load_plugins("dcc-brokendep") } + .to raise_error(LoadError, /a_gem_that_does_not_exist/) + end + it "leaves a slash-separated path alone, hyphens included" do expect { described_class.load_plugins("dcc/nope-not-real") } .to raise_error(Dcc::PluginError, %r{require "dcc/nope-not-real"}) diff --git a/spec/fixtures/plugins/dcc/brokendep.rb b/spec/fixtures/plugins/dcc/brokendep.rb new file mode 100644 index 0000000..5b8893e --- /dev/null +++ b/spec/fixtures/plugins/dcc/brokendep.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +# Stands in for a plugin whose entry file loads fine but whose own +# dependency is missing. `Dcc.load_plugins` must let this LoadError +# through rather than reporting this file as the one it could not find. +require "a_gem_that_does_not_exist" From b1555c5e78392e64d6a45d0fc53522a9bfff08dc Mon Sep 17 00:00:00 2001 From: HassanAkbar Date: Fri, 31 Jul 2026 19:18:23 +0500 Subject: [PATCH 4/5] narrow plugin system gaps to discovery and converters --- TODO.complete/34-plugin-system.md | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/TODO.complete/34-plugin-system.md b/TODO.complete/34-plugin-system.md index 769dc09..bf74618 100644 --- a/TODO.complete/34-plugin-system.md +++ b/TODO.complete/34-plugin-system.md @@ -3,13 +3,13 @@ **Status:** PARTIAL ## Gaps -- `lib/dcc/plugin/base.rb` does not exist, and there is no - `Dcc.load_plugins` or gem-prefix auto-discovery. -- Nothing consumes the registry. `Dcc::Validate`, `Dcc::Convert` and - `Dcc::Cli` never read `Dcc::Plugin.all`, so a registered plugin has no - effect on validation, conversion or the CLI. -- `spec/dcc/plugin_spec.rb` asserts only that the registry stores and returns - objects. +- There is no gem-prefix auto-discovery. `Dcc.load_plugins` loads plugins by + explicit name; nothing scans installed gems for the `dcc/*` prefix. +- Only validators are wired end to end. `Dcc::Plugin::Base` declares + `register_validator` and nothing else, and neither `Dcc::Convert` nor + `Dcc::Cli` reads `Dcc::Plugin.all`, so a converter or CLI subcommand can + be neither declared nor consumed. `Dcc::Validate` does read the registry, + via `Dcc::Validate::Schematron::Profile`. ## Goal Registry-based plugin system for custom validators, converters, and CLI commands. From b2bc341d9c4cd7f1ab9f0cba974882642cf54c93 Mon Sep 17 00:00:00 2001 From: HassanAkbar Date: Thu, 13 Aug 2026 16:28:28 +0500 Subject: [PATCH 5/5] Give anonymous rule classes a usable issue code --- lib/dcc/validate/schematron/rule.rb | 9 ++++- spec/dcc/validate/schematron_spec.rb | 57 ++++++++++++++++++++++++++++ 2 files changed, 65 insertions(+), 1 deletion(-) diff --git a/lib/dcc/validate/schematron/rule.rb b/lib/dcc/validate/schematron/rule.rb index 176afaa..d7206e9 100644 --- a/lib/dcc/validate/schematron/rule.rb +++ b/lib/dcc/validate/schematron/rule.rb @@ -13,9 +13,16 @@ def severity ::Dcc::Validate::Severity::ERROR end + # A plugin can register an anonymous rule class, whose `name` is nil. + # Falling back keeps one such rule from taking the whole run down and + # losing every issue the built-in rules already found. + # # @return [String] rule code used in `Issue#code`. def code - "dcc.schematron.#{self.class.name.split('::').last.gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2').gsub( + class_name = self.class.name + return "dcc.schematron.anonymous" unless class_name + + "dcc.schematron.#{class_name.split('::').last.gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2').gsub( /([a-z\d])([A-Z])/, '\1_\2' ).downcase}" end diff --git a/spec/dcc/validate/schematron_spec.rb b/spec/dcc/validate/schematron_spec.rb index 3b458ab..425d6e1 100644 --- a/spec/dcc/validate/schematron_spec.rb +++ b/spec/dcc/validate/schematron_spec.rb @@ -129,3 +129,60 @@ def check_on(dcc) .not_to include("dcc.schematron.plugin_probe_rule") end end + +# `register_validator` accepts any class defining `#check_on`, anonymous ones +# included, and `Rule#code` derives its code from the class name. An anonymous +# class has no name, so a rule built this way used to raise from inside the run. +RSpec.describe Dcc::Validate::Schematron::Rule do + let(:dcc) { Dcc.parse(File.read(fixtures_path("dcclib", "valid.xml"))) } + + # Held in a `let`, never assigned to a constant — assigning `Class.new` to a + # constant names the class and hides the very case under test. + let(:anonymous_rule) do + Class.new(Dcc::Validate::Schematron::Rules::Base) do + def check_on(_dcc) + [issue(severity: :error, message: "anonymous rule fired")] + end + end + end + + before do + Dcc::V3.load_all! + Dcc::Plugin.reset! + end + + after { Dcc::Plugin.reset! } + + it "has no class name to derive a code from" do + expect(anonymous_rule.name).to be_nil + end + + it "codes an anonymous rule without raising" do + expect(anonymous_rule.new.code).to eq("dcc.schematron.anonymous") + end + + it "still derives a named rule's code from its class name" do + expect(Dcc::Validate::Schematron::Rules::DateRangeCheck.new.code) + .to eq("dcc.schematron.date_range_check") + end + + context "when registered as a plugin validator" do + before { Dcc::Plugin.register(:validators, anonymous_rule) } + + it "completes the run instead of raising" do + expect { Dcc::Validate::Schematron.call(dcc) }.not_to raise_error + end + + it "emits the anonymous rule's issue" do + expect(Dcc::Validate::Schematron.call(dcc).issues.map(&:code)) + .to include("dcc.schematron.anonymous") + end + + # The point of coding rather than raising: one anonymous plugin rule must + # not cost the caller every issue the built-in rules already found. + it "keeps the issues the built-in rules found" do + expect(Dcc::Validate::Schematron.call(dcc).issues.map(&:code)) + .to include("dcc.schematron.used_software_placement") + end + end +end