Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 17 additions & 36 deletions .rubocop_todo.yml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# This configuration was generated by
# `rubocop --auto-gen-config`
# on 2026-01-26 12:48:52 UTC using RuboCop version 1.82.1.
# on 2026-01-27 00:27:35 UTC using RuboCop version 1.82.1.
# The point is for the user to remove these configuration records
# one by one as the offenses are removed from the code base.
# Note that changes in the inspected code, or installation of new
Expand All @@ -12,42 +12,40 @@
# SupportedStyles: with_first_argument, with_fixed_indentation
Layout/ArgumentAlignment:
Exclude:
- 'script/diagnose_ietf_test.rb'
- 'lib/svg_conform/requirements/invalid_id_references_requirement.rb'

# Offense count: 4
# Offense count: 1
# This cop supports safe autocorrection (--autocorrect).
# Configuration parameters: EnforcedStyleAlignWith.
# SupportedStylesAlignWith: either, start_of_block, start_of_line
Layout/BlockAlignment:
Exclude:
- 'spec/svg_conform/profiles/svg_1_2_rfc_profile_spec.rb'

# Offense count: 3
# This cop supports safe autocorrection (--autocorrect).
Layout/BlockEndNewline:
Exclude:
- 'spec/svg_conform/profiles/svg_1_2_rfc_profile_spec.rb'

# Offense count: 6
# Offense count: 1
# This cop supports safe autocorrection (--autocorrect).
# Configuration parameters: Width, AllowedPatterns.
Layout/IndentationWidth:
# Configuration parameters: AllowMultipleStyles, EnforcedHashRocketStyle, EnforcedColonStyle, EnforcedLastArgumentHashStyle.
# SupportedHashRocketStyles: key, separator, table
# SupportedColonStyles: key, separator, table
# SupportedLastArgumentHashStyles: always_inspect, always_ignore, ignore_implicit, ignore_explicit
Layout/HashAlignment:
Exclude:
- 'spec/svg_conform/profiles/svg_1_2_rfc_profile_spec.rb'
- 'spec/svg_conform/validation_context_spec.rb'

# Offense count: 642
# Offense count: 662
# This cop supports safe autocorrection (--autocorrect).
# Configuration parameters: Max, AllowHeredoc, AllowURI, AllowQualifiedName, URISchemes, AllowRBSInlineAnnotation, AllowCopDirectives, AllowedPatterns, SplitStrings.
# URISchemes: http, https
Layout/LineLength:
Enabled: false

# Offense count: 1
# Offense count: 2
# This cop supports safe autocorrection (--autocorrect).
# Configuration parameters: AllowInHeredoc.
Layout/TrailingWhitespace:
Exclude:
- 'script/diagnose_ietf_test.rb'
- 'lib/svg_conform/requirements/invalid_id_references_requirement.rb'
- 'spec/svg_conform/validation_context_spec.rb'

# Offense count: 3
# Configuration parameters: AllowedMethods.
Expand Down Expand Up @@ -119,7 +117,7 @@ Metrics/BlockNesting:
Metrics/CyclomaticComplexity:
Enabled: false

# Offense count: 258
# Offense count: 259
# Configuration parameters: CountComments, CountAsOne, AllowedMethods, AllowedPatterns.
Metrics/MethodLength:
Max: 154
Expand Down Expand Up @@ -160,7 +158,7 @@ RSpec/DescribeClass:
- 'spec/svg_conform/references/integration_spec.rb'
- 'spec/svgcheck_compatibility_spec.rb'

# Offense count: 144
# Offense count: 157
# Configuration parameters: CountAsOne.
RSpec/ExampleLength:
Max: 53
Expand All @@ -181,7 +179,7 @@ RSpec/MultipleDescribes:
Exclude:
- 'spec/svg_conform/batch_report_spec.rb'

# Offense count: 116
# Offense count: 126
RSpec/MultipleExpectations:
Max: 8

Expand All @@ -190,17 +188,6 @@ Rake/MethodDefinitionInTask:
Exclude:
- 'lib/tasks/svgcheck.rake'

# Offense count: 5
# This cop supports safe autocorrection (--autocorrect).
# Configuration parameters: EnforcedStyle, ProceduralMethods, FunctionalMethods, AllowedMethods, AllowedPatterns, AllowBracesOnProceduralOneLiners, BracesRequiredMethods.
# SupportedStyles: line_count_based, semantic, braces_for_chaining, always_braces
# ProceduralMethods: benchmark, bm, bmbm, create, each_with_object, measure, new, realtime, tap, with_object
# FunctionalMethods: let, let!, subject, watch
# AllowedMethods: lambda, proc, it
Style/BlockDelimiters:
Exclude:
- 'spec/svg_conform/profiles/svg_1_2_rfc_profile_spec.rb'

# Offense count: 2
# This cop supports safe autocorrection (--autocorrect).
# Configuration parameters: EnforcedStyle, AllowComments.
Expand All @@ -222,12 +209,6 @@ Style/MissingRespondToMissing:
Exclude:
- 'lib/svg_conform/cli.rb'

# Offense count: 1
# This cop supports safe autocorrection (--autocorrect).
Style/MultilineIfModifier:
Exclude:
- 'script/diagnose_ietf_test.rb'

# Offense count: 4
# Configuration parameters: AllowedMethods.
# AllowedMethods: respond_to_missing?
Expand Down
36 changes: 36 additions & 0 deletions lib/svg_conform/classification_cache.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
# frozen_string_literal: true

module SvgConform
# Cache for requirement classification results
# Instance-level (per SaxValidationHandler) to avoid shared mutable state
# No mutexes needed - each handler has its own cache
class ClassificationCache
def initialize
@cache = {}
end

# Fetch from cache or compute the value
def fetch(key)
@cache[key] ||= yield
end

# Clear specific key or entire cache
def clear(key = nil)
if key
@cache.delete(key)
else
@cache.clear
end
end

# Return number of cached entries
def size
@cache.size
end

# Check if key exists in cache
def key?(key)
@cache.key?(key)
end
end
end
3 changes: 3 additions & 0 deletions lib/svg_conform/profile.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@

module SvgConform
# Base class for SVG validation profiles using lutaml-model serialization
#
# Profile acts as a factory for requirements and remediations.
# Each validation creates fresh instances to avoid state pollution.
class Profile < Lutaml::Model::Serializable
PROFILES_DIR = File.expand_path("../../config/profiles", __dir__)

Expand Down
83 changes: 83 additions & 0 deletions lib/svg_conform/requirements/base_requirement.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,89 @@
module SvgConform
module Requirements
# Base class for all validation requirements
#
# == Validation modes
#
# Requirements support two validation modes:
#
# === DOM validation (remediation mode)
#
# Used when the full document is available and remediation is needed.
# Requirements implement +validate_document+ to traverse the document once.
#
# requirement.validate_document(document, context)
#
# === SAX validation (streaming mode)
#
# Used for memory-efficient streaming validation without loading the full document.
# Requirements implement either immediate or deferred validation patterns.
#
# ==== Immediate validation (14 requirements)
#
# Validates as it encounters each node during SAX parsing.
# No state is needed - validation is complete after +validate_sax_element+ returns.
#
# def validate_sax_element(element, context)
# # Immediate validation logic
# context.add_error(...) if invalid
# end
#
# Requirements using immediate validation:
# - AllowedElementsRequirement
# - FontFamilyRequirement
# - ColorRestrictionsRequirement
# - ViewboxRequiredRequirement
# - NamespaceRequirement
# - IdCollectionRequirement
# - StylePromotionRequirement
# - NoExternalImagesRequirement
# - NoExternalFontsRequirement
# - NamespaceAttributesRequirement
# - ForbiddenContentRequirement
# - StyleRequirement
# - LinkValidationRequirement (registers to ReferenceManifest)
#
# ==== Deferred validation (3 requirements)
#
# Collects data during SAX parsing and validates at document end.
# Requires a nested State class to store collected data.
#
# class State
# attr_accessor :collected_data
#
# def initialize
# @collected_data = []
# end
# end
#
# def needs_deferred_validation?
# true
# end
#
# def collect_sax_data(element, context)
# state = context.state_for(self)
# state.collected_data << extract_data(element)
# end
#
# def validate_sax_complete(context)
# state = context.state_for(self)
# # Validate collected data
# end
#
# Requirements using deferred validation:
# - IdReferenceRequirement (needs forward references to IDs)
# - InvalidIdReferencesRequirement (needs to collect IDs first)
# - NoExternalCssRequirement (needs to check style elements)
#
# == State management
#
# Requirements needing deferred validation must define a nested State class.
# The ValidationContext manages state instances per requirement via +state_for+:
#
# state = context.state_for(self) # Returns State instance for this requirement
#
# Each validation gets a fresh state instance, preventing state pollution when
# reusing the same profile for multiple validations.
class BaseRequirement < Lutaml::Model::Serializable
include SvgConform::NodeHelpers
include SvgConform::Interfaces::RequirementInterface
Expand Down
57 changes: 29 additions & 28 deletions lib/svg_conform/requirements/id_reference_requirement.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,28 +6,29 @@
module SvgConform
module Requirements
class IdReferenceRequirement < BaseRequirement
def needs_deferred_validation?
true
# Nested State class - requirement owns its state structure
class State
attr_accessor :collected_ids, :url_refs, :href_refs, :other_refs

def initialize
@collected_ids = Set.new
@url_refs = []
@href_refs = []
@other_refs = []
end
end

# Reset state before each validation run to prevent state leakage
def reset_state
@collected_ids = Set.new
@collected_url_refs = []
@collected_href_refs = []
@collected_other_refs = []
def needs_deferred_validation?
true
end

def collect_sax_data(element, _context)
# Initialize collections on first call
@collected_ids ||= Set.new
@collected_url_refs ||= []
@collected_href_refs ||= []
@collected_other_refs ||= []

def collect_sax_data(element, context)
state = context.state_for(self)
# Collect IDs
id_value = element.raw_attributes["id"]
@collected_ids.add(id_value) if id_value && !id_value.empty?
if id_value && !id_value.empty?
state.collected_ids.add(id_value)
end

# Collect url() references
url_attributes = %w[fill stroke marker-start marker-mid marker-end
Expand All @@ -38,7 +39,7 @@ def collect_sax_data(element, _context)

url_refs = extract_url_references(attr_value)
url_refs.each do |ref_id|
@collected_url_refs << [element, ref_id, attr_name]
state.url_refs << [element, ref_id, attr_name]
end
end

Expand All @@ -47,15 +48,15 @@ def collect_sax_data(element, _context)
if style_attr
url_refs = extract_url_references(style_attr)
url_refs.each do |ref_id|
@collected_url_refs << [element, ref_id, "style"]
state.url_refs << [element, ref_id, "style"]
end
end

# Collect href references
href_value = element.raw_attributes["href"] || element.raw_attributes["xlink:href"]
if href_value&.start_with?("#")
ref_id = href_value[1..] # Remove #
@collected_href_refs << [element, ref_id]
state.href_refs << [element, ref_id]
end

# Collect other ID references
Expand All @@ -69,18 +70,18 @@ def collect_sax_data(element, _context)
ref_ids.each do |ref_id|
next if ref_id.empty?

@collected_other_refs << [element, ref_id, attr_name]
state.other_refs << [element, ref_id, attr_name]
end
end
end

def validate_sax_complete(context)
# Guard against nil collections (if collect_sax_data was never called)
return unless @collected_url_refs && @collected_href_refs && @collected_other_refs && @collected_ids
state = context.state_for(self)
return unless state.url_refs && state.href_refs && state.other_refs && state.collected_ids

# Validate all collected references
@collected_url_refs.each do |element, ref_id, attr_name|
next if @collected_ids.include?(ref_id)
state.url_refs.each do |element, ref_id, attr_name|
next if state.collected_ids.include?(ref_id)

message = if attr_name == "style"
"Reference to undefined ID '#{ref_id}' in style attribute"
Expand All @@ -95,8 +96,8 @@ def validate_sax_complete(context)
)
end

@collected_href_refs.each do |element, ref_id|
next if @collected_ids.include?(ref_id)
state.href_refs.each do |element, ref_id|
next if state.collected_ids.include?(ref_id)

context.add_error(
node: element,
Expand All @@ -105,8 +106,8 @@ def validate_sax_complete(context)
)
end

@collected_other_refs.each do |element, ref_id, attr_name|
next if @collected_ids.include?(ref_id)
state.other_refs.each do |element, ref_id, attr_name|
next if state.collected_ids.include?(ref_id)

context.add_error(
node: element,
Expand Down
Loading