Loading lib/labkit/covered_experience.rb +1 −1 Original line number Diff line number Diff line Loading @@ -12,7 +12,7 @@ module Labkit class << self extend Forwardable def_delegators :registry, :[], :invalid_definitions def_delegators :registry, :[] def registry @registry ||= Registry.new Loading lib/labkit/covered_experience/experience.rb 0 → 100644 +8 −0 Original line number Diff line number Diff line # frozen_string_literal: true module Labkit module CoveredExperience class Experience < Data.define(:description, :feature_category, :urgency) end end end lib/labkit/covered_experience/registry.rb +50 −43 Original line number Diff line number Diff line # frozen_string_literal: true require 'yaml' require 'pathname' require 'json-schema' require 'forwardable' require 'json-schema' require 'pathname' require 'yaml' require 'labkit/logging/json_logger' require 'labkit/covered_experience/experience' module Labkit module CoveredExperience Loading @@ -13,53 +14,56 @@ module Labkit SCHEMA_PATH = File.expand_path('../../../config/covered_experiences/schema.json', __dir__) def_delegator :registry, :empty? def_delegator :@experiences, :empty? # @param dir [String, Pathname] Directory path containing YAML file definitions # Defaults to 'config/covered_experiences' relative to the calling application's root def initialize(dir: nil) @dir = dir @experiences = load_on_demand @invalid_definitions = {} end # Retrieve a definition from the registry # Retrieve a definition experience given a covered_experience_id. # # @param covered_experience_id [String, Symbol] Covered experience identifier # @return [Hash, nil] Definition hash if present, otherwise nil # @return [Experience, nil] An experience if present, otherwise nil def [](covered_experience_id) registry[covered_experience_id.to_s] @experiences[covered_experience_id.to_s] end # Retrieve invalid definitions (ignored because they didn't pass the schema validation) # Retrieve invalid definitions (ignored because they didn't pass schema validation) # # @return [Hash] Invalid definitions keyed by covered_experience_id # @return [Hash] Invalid validation messages keyed by covered_experience_id def invalid_definitions registry && @invalid_definitions @invalid_definitions.dup.freeze end private # Lazy-load the registry of covered experience definitions. # This will load all YAML files from the default directory, # on first access and memoize the registry for subsequent access. # # If .init_registry is called with a dir, it will clear the memoized # registry and load the new directory's definitions. # Initialize a hash that loads experiences on-demand # # @return [Hash] The memoized registry of covered experience definitions def registry @registry ||= load_definitions # @return [Hash] Hash with lazy loading behavior def load_on_demand unless directory_exists?(directory_path) warn("Directory not readable: #{directory_path}") return {} end # Initialize the registry by loading YAML files from the specified directory def load_definitions @registry = {} @invalid_definitions = {} warn("Directory not readable: #{directory_path}") unless directory_exists?(directory_path) load_yaml_files(directory_path) Hash.new do |result, experience_id| experience = load_experience(experience_id.to_s) # we also store nil to memoize the value and avoid triggering load_experience again result[experience_id.to_s] = experience end end @registry.freeze # Load a covered experience definition. # # @param experience_id [String] Experience identifier # @return [Experience, nil] Loaded experience or nil if not found/invalid def load_experience(experience_id) file_path = experience_definitions[experience_id] read_experience(file_path, experience_id) if file_path end # Resolve the directory path, using default if @dir not provided. Loading @@ -75,28 +79,31 @@ module Labkit directory_path.exist? && directory_path.directory? && directory_path.readable? end def load_yaml_files(directory_path) directory_path .glob('*.yml') .each { |f| load_yaml_file(f) } def experience_definitions @experience_definitions ||= directory_path.glob('*.yml').each_with_object({}) do |file_path, hash| hash[file_path.basename('.yml').to_s] = file_path end end def load_yaml_file(file_path) # Read and validate a definition experience file # # @param file_path [Pathname] Path to the definition file # @param experience_id [String] Expected experience ID # @return [Experience, nil] Parsed experience or nil if invalid def read_experience(file_path, experience_id) content = YAML.safe_load(file_path.read) return nil unless content.is_a?(Hash) return unless content.is_a?(Hash) covered_experience_id = file_path.basename('.yml').to_s errors = JSON::Validator.fully_validate(schema, content) return Experience.new(**content) if errors.empty? if errors.empty? @registry[covered_experience_id] = content.dup.freeze else @invalid_definitions[covered_experience_id] = errors.join(', ') @invalid_definitions[experience_id] = errors.join(', ') warn("Invalid schema for #{file_path}") end nil rescue Psych::SyntaxError => e warn("Invalid YAML file #{file_path}: #{e.message}") warn("Invalid definition file #{file_path}: #{e.message}") rescue StandardError => e warn("Unexpected error processing #{file_path}: #{e.message}") end Loading @@ -106,7 +113,7 @@ module Labkit end def warn(message) logger.warn(component: to_s, message: message) logger.warn(component: self.class.name, message: message) end def logger Loading spec/labkit/covered_experience/registry_spec.rb +14 −9 Original line number Diff line number Diff line Loading @@ -4,6 +4,7 @@ require 'spec_helper' require 'tmpdir' require 'fileutils' require 'labkit/covered_experience/registry' require 'labkit/covered_experience/experience' RSpec.describe Labkit::CoveredExperience::Registry do let(:temp_dir) { Dir.mktmpdir } Loading Loading @@ -43,17 +44,18 @@ RSpec.describe Labkit::CoveredExperience::Registry do it 'loads all YAML files and returns registry' do registry = described_class.new(dir: covered_experiences_dir) expect(registry[:merge_request_creation]).to be_a(Hash) expect(registry[:issue_creation]).to be_a(Hash) expect(registry[:merge_request_creation]).to be_a(Labkit::CoveredExperience::Experience) expect(registry[:issue_creation]).to be_a(Labkit::CoveredExperience::Experience) end it 'stores complete covered experience data' do registry = described_class.new(dir: covered_experiences_dir) mr_experience = registry['merge_request_creation'] expect(mr_experience['description']).to eq('Creating a new merge request in a project') expect(mr_experience['feature_category']).to eq('source_code_management') expect(mr_experience['urgency']).to eq('sync_fast') expect(registry['merge_request_creation'].to_h).to match({ description: 'Creating a new merge request in a project', feature_category: 'source_code_management', urgency: 'sync_fast' }) end it 'freezes the stored data' do Loading @@ -70,7 +72,7 @@ RSpec.describe Labkit::CoveredExperience::Registry do registry = described_class.new(dir: covered_experiences_dir) expect(registry[:yaml_test]).to be_nil expect(registry[:yml_test]).to be_a(Hash) expect(registry[:yml_test]).to be_a(Labkit::CoveredExperience::Experience) end end Loading @@ -88,7 +90,7 @@ RSpec.describe Labkit::CoveredExperience::Registry do it 'expands relative paths from current working directory' do registry = described_class.new(dir: relative_dir) expect(registry[:merge_request_creation]).to be_a(Hash) expect(registry[:merge_request_creation]).to be_a(Labkit::CoveredExperience::Experience) end end Loading @@ -98,7 +100,7 @@ RSpec.describe Labkit::CoveredExperience::Registry do registry = described_class.new(dir: covered_experiences_dir) expect(registry[:absolute_test]).to be_a(Hash) expect(registry[:absolute_test]).to be_a(Labkit::CoveredExperience::Experience) end end Loading Loading @@ -131,6 +133,9 @@ RSpec.describe Labkit::CoveredExperience::Registry do registry = described_class.new(dir: covered_experiences_dir) expect(registry).to be_empty # we need to force access the invalid schema to make it populate invalid_definitions expect(registry['invalid_schema']).to be_nil expect(registry.invalid_definitions).to match("invalid_schema" => String) end end Loading Loading
lib/labkit/covered_experience.rb +1 −1 Original line number Diff line number Diff line Loading @@ -12,7 +12,7 @@ module Labkit class << self extend Forwardable def_delegators :registry, :[], :invalid_definitions def_delegators :registry, :[] def registry @registry ||= Registry.new Loading
lib/labkit/covered_experience/experience.rb 0 → 100644 +8 −0 Original line number Diff line number Diff line # frozen_string_literal: true module Labkit module CoveredExperience class Experience < Data.define(:description, :feature_category, :urgency) end end end
lib/labkit/covered_experience/registry.rb +50 −43 Original line number Diff line number Diff line # frozen_string_literal: true require 'yaml' require 'pathname' require 'json-schema' require 'forwardable' require 'json-schema' require 'pathname' require 'yaml' require 'labkit/logging/json_logger' require 'labkit/covered_experience/experience' module Labkit module CoveredExperience Loading @@ -13,53 +14,56 @@ module Labkit SCHEMA_PATH = File.expand_path('../../../config/covered_experiences/schema.json', __dir__) def_delegator :registry, :empty? def_delegator :@experiences, :empty? # @param dir [String, Pathname] Directory path containing YAML file definitions # Defaults to 'config/covered_experiences' relative to the calling application's root def initialize(dir: nil) @dir = dir @experiences = load_on_demand @invalid_definitions = {} end # Retrieve a definition from the registry # Retrieve a definition experience given a covered_experience_id. # # @param covered_experience_id [String, Symbol] Covered experience identifier # @return [Hash, nil] Definition hash if present, otherwise nil # @return [Experience, nil] An experience if present, otherwise nil def [](covered_experience_id) registry[covered_experience_id.to_s] @experiences[covered_experience_id.to_s] end # Retrieve invalid definitions (ignored because they didn't pass the schema validation) # Retrieve invalid definitions (ignored because they didn't pass schema validation) # # @return [Hash] Invalid definitions keyed by covered_experience_id # @return [Hash] Invalid validation messages keyed by covered_experience_id def invalid_definitions registry && @invalid_definitions @invalid_definitions.dup.freeze end private # Lazy-load the registry of covered experience definitions. # This will load all YAML files from the default directory, # on first access and memoize the registry for subsequent access. # # If .init_registry is called with a dir, it will clear the memoized # registry and load the new directory's definitions. # Initialize a hash that loads experiences on-demand # # @return [Hash] The memoized registry of covered experience definitions def registry @registry ||= load_definitions # @return [Hash] Hash with lazy loading behavior def load_on_demand unless directory_exists?(directory_path) warn("Directory not readable: #{directory_path}") return {} end # Initialize the registry by loading YAML files from the specified directory def load_definitions @registry = {} @invalid_definitions = {} warn("Directory not readable: #{directory_path}") unless directory_exists?(directory_path) load_yaml_files(directory_path) Hash.new do |result, experience_id| experience = load_experience(experience_id.to_s) # we also store nil to memoize the value and avoid triggering load_experience again result[experience_id.to_s] = experience end end @registry.freeze # Load a covered experience definition. # # @param experience_id [String] Experience identifier # @return [Experience, nil] Loaded experience or nil if not found/invalid def load_experience(experience_id) file_path = experience_definitions[experience_id] read_experience(file_path, experience_id) if file_path end # Resolve the directory path, using default if @dir not provided. Loading @@ -75,28 +79,31 @@ module Labkit directory_path.exist? && directory_path.directory? && directory_path.readable? end def load_yaml_files(directory_path) directory_path .glob('*.yml') .each { |f| load_yaml_file(f) } def experience_definitions @experience_definitions ||= directory_path.glob('*.yml').each_with_object({}) do |file_path, hash| hash[file_path.basename('.yml').to_s] = file_path end end def load_yaml_file(file_path) # Read and validate a definition experience file # # @param file_path [Pathname] Path to the definition file # @param experience_id [String] Expected experience ID # @return [Experience, nil] Parsed experience or nil if invalid def read_experience(file_path, experience_id) content = YAML.safe_load(file_path.read) return nil unless content.is_a?(Hash) return unless content.is_a?(Hash) covered_experience_id = file_path.basename('.yml').to_s errors = JSON::Validator.fully_validate(schema, content) return Experience.new(**content) if errors.empty? if errors.empty? @registry[covered_experience_id] = content.dup.freeze else @invalid_definitions[covered_experience_id] = errors.join(', ') @invalid_definitions[experience_id] = errors.join(', ') warn("Invalid schema for #{file_path}") end nil rescue Psych::SyntaxError => e warn("Invalid YAML file #{file_path}: #{e.message}") warn("Invalid definition file #{file_path}: #{e.message}") rescue StandardError => e warn("Unexpected error processing #{file_path}: #{e.message}") end Loading @@ -106,7 +113,7 @@ module Labkit end def warn(message) logger.warn(component: to_s, message: message) logger.warn(component: self.class.name, message: message) end def logger Loading
spec/labkit/covered_experience/registry_spec.rb +14 −9 Original line number Diff line number Diff line Loading @@ -4,6 +4,7 @@ require 'spec_helper' require 'tmpdir' require 'fileutils' require 'labkit/covered_experience/registry' require 'labkit/covered_experience/experience' RSpec.describe Labkit::CoveredExperience::Registry do let(:temp_dir) { Dir.mktmpdir } Loading Loading @@ -43,17 +44,18 @@ RSpec.describe Labkit::CoveredExperience::Registry do it 'loads all YAML files and returns registry' do registry = described_class.new(dir: covered_experiences_dir) expect(registry[:merge_request_creation]).to be_a(Hash) expect(registry[:issue_creation]).to be_a(Hash) expect(registry[:merge_request_creation]).to be_a(Labkit::CoveredExperience::Experience) expect(registry[:issue_creation]).to be_a(Labkit::CoveredExperience::Experience) end it 'stores complete covered experience data' do registry = described_class.new(dir: covered_experiences_dir) mr_experience = registry['merge_request_creation'] expect(mr_experience['description']).to eq('Creating a new merge request in a project') expect(mr_experience['feature_category']).to eq('source_code_management') expect(mr_experience['urgency']).to eq('sync_fast') expect(registry['merge_request_creation'].to_h).to match({ description: 'Creating a new merge request in a project', feature_category: 'source_code_management', urgency: 'sync_fast' }) end it 'freezes the stored data' do Loading @@ -70,7 +72,7 @@ RSpec.describe Labkit::CoveredExperience::Registry do registry = described_class.new(dir: covered_experiences_dir) expect(registry[:yaml_test]).to be_nil expect(registry[:yml_test]).to be_a(Hash) expect(registry[:yml_test]).to be_a(Labkit::CoveredExperience::Experience) end end Loading @@ -88,7 +90,7 @@ RSpec.describe Labkit::CoveredExperience::Registry do it 'expands relative paths from current working directory' do registry = described_class.new(dir: relative_dir) expect(registry[:merge_request_creation]).to be_a(Hash) expect(registry[:merge_request_creation]).to be_a(Labkit::CoveredExperience::Experience) end end Loading @@ -98,7 +100,7 @@ RSpec.describe Labkit::CoveredExperience::Registry do registry = described_class.new(dir: covered_experiences_dir) expect(registry[:absolute_test]).to be_a(Hash) expect(registry[:absolute_test]).to be_a(Labkit::CoveredExperience::Experience) end end Loading Loading @@ -131,6 +133,9 @@ RSpec.describe Labkit::CoveredExperience::Registry do registry = described_class.new(dir: covered_experiences_dir) expect(registry).to be_empty # we need to force access the invalid schema to make it populate invalid_definitions expect(registry['invalid_schema']).to be_nil expect(registry.invalid_definitions).to match("invalid_schema" => String) end end Loading