From c8f24c626c92fccd301905037aeab1129d15e6aa Mon Sep 17 00:00:00 2001 From: Anton Sokolov Date: Sat, 14 Feb 2026 01:39:58 +0300 Subject: [PATCH 01/33] Add `Utils` module with `label_module` functionality - Introduce `Stroma::Utils` for shared utility methods across the framework. - Add `label_module` method to assign descriptive labels to anonymous modules. - Use `set_temporary_name` (Ruby 3.3+) when available, with a fallback for older versions. - Provide type signatures in `stroma/utils.rbs` for static analysis. - Add RSpec tests to validate `label_module` behavior (`to_s`/`inspect` updates). --- lib/stroma/utils.rb | 34 ++++++++++++++++++++++++++++++++++ sig/lib/stroma/utils.rbs | 9 +++++++++ spec/stroma/utils_spec.rb | 18 ++++++++++++++++++ 3 files changed, 61 insertions(+) create mode 100644 lib/stroma/utils.rb create mode 100644 sig/lib/stroma/utils.rbs create mode 100644 spec/stroma/utils_spec.rb diff --git a/lib/stroma/utils.rb b/lib/stroma/utils.rb new file mode 100644 index 0000000..d52389a --- /dev/null +++ b/lib/stroma/utils.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +module Stroma + # Shared utility methods for the Stroma framework. + # + # ## Purpose + # + # Provides common helper methods used across multiple Stroma components. + # All methods are module functions - callable as both module methods + # and instance methods when included. + module Utils + module_function + + # Assigns a descriptive label to an anonymous module for debugging. + # Uses set_temporary_name (Ruby 3.3+) when available. + # + # TODO: Remove the else branch when Ruby 3.2 support is dropped. + # The define_singleton_method fallback is a temporary workaround + # that only affects #inspect and #to_s. Unlike set_temporary_name, + # it does not set #name, so the module remains technically anonymous. + # + # @param mod [Module] The module to label + # @param label [String] The descriptive label + # @return [void] + def label_module(mod, label) + if mod.respond_to?(:set_temporary_name) + mod.set_temporary_name(label) + else + mod.define_singleton_method(:inspect) { label } + mod.define_singleton_method(:to_s) { label } + end + end + end +end diff --git a/sig/lib/stroma/utils.rbs b/sig/lib/stroma/utils.rbs new file mode 100644 index 0000000..13fcc5f --- /dev/null +++ b/sig/lib/stroma/utils.rbs @@ -0,0 +1,9 @@ +module Stroma + module Utils + def self.label_module: (Module mod, String label) -> void + + private + + def label_module: (Module mod, String label) -> void + end +end diff --git a/spec/stroma/utils_spec.rb b/spec/stroma/utils_spec.rb new file mode 100644 index 0000000..99ebf29 --- /dev/null +++ b/spec/stroma/utils_spec.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +RSpec.describe Stroma::Utils do + describe ".label_module" do + let(:mod) { Module.new } + let(:label) { "Stroma::Test(example)" } + + before { described_class.label_module(mod, label) } + + it "sets inspect to the label" do + expect(mod.inspect).to eq(label) + end + + it "sets to_s to the label" do + expect(mod.to_s).to eq(label) + end + end +end From 149b4e2a37950d712621eb39ebade37eba8d6bba Mon Sep 17 00:00:00 2001 From: Anton Sokolov Date: Sat, 14 Feb 2026 01:41:23 +0300 Subject: [PATCH 02/33] Add `matrix_name` to `Entry` for phase method handling - Extend `Stroma::Entry` with the `matrix_name` attribute to identify the owning matrix. - Introduce `phase_method` to compute a phase method name using `matrix_name` and `key`. - Update type signatures in `stroma/entry.rbs` to include the new attribute and method. - Enhance specs to cover `matrix_name`, `phase_method`, and equality checks for the updated behavior. --- lib/stroma/entry.rb | 23 ++++++++++++++++++----- sig/lib/stroma/entry.rbs | 7 +++++-- spec/stroma/entry_spec.rb | 27 ++++++++++++++++++++++----- 3 files changed, 45 insertions(+), 12 deletions(-) diff --git a/lib/stroma/entry.rb b/lib/stroma/entry.rb index 1893c9f..bc1b6af 100644 --- a/lib/stroma/entry.rb +++ b/lib/stroma/entry.rb @@ -6,25 +6,38 @@ module Stroma # ## Purpose # # Immutable value object that holds information about a DSL module - # registered in the Stroma system. Each entry has a unique key - # and references a Module that will be included in service classes. + # registered in the Stroma system. Each entry has a unique key, + # references a Module that will be included in service classes, + # and knows its owning matrix name for phase method generation. # # ## Attributes # # - `key` (Symbol): Unique identifier for the DSL module (:inputs, :outputs, :actions) # - `extension` (Module): The actual DSL module to be included + # - `matrix_name` (Symbol): Name of the owning matrix # # ## Usage # # Entries are created internally by Registry.register: # # ```ruby - # Stroma::Registry.register(:inputs, MyInputsDSL) - # # Creates: Entry.new(key: :inputs, extension: MyInputsDSL) + # registry = Stroma::Registry.new(:my_lib) + # registry.register(:inputs, MyInputsDSL) + # # Creates: Entry.new(key: :inputs, extension: MyInputsDSL, matrix_name: :my_lib) # ``` # # ## Immutability # # Entry is immutable (Data object) - once created, it cannot be modified. - Entry = Data.define(:key, :extension) + Entry = Data.define(:key, :extension, :matrix_name) do + # Returns the phase method name for this entry. + # + # Computed from matrix_name and key. Used by the orchestrator + # to call each entry's phase in sequence. + # + # @return [Symbol] The phase method name (e.g., :_my_lib_phase_inputs!) + def phase_method + :"_#{matrix_name}_phase_#{key}!" + end + end end diff --git a/sig/lib/stroma/entry.rbs b/sig/lib/stroma/entry.rbs index 22c004b..2b1614f 100644 --- a/sig/lib/stroma/entry.rbs +++ b/sig/lib/stroma/entry.rbs @@ -4,8 +4,11 @@ module Stroma class Entry attr_reader key: Symbol attr_reader extension: Module + attr_reader matrix_name: Symbol - def initialize: (Symbol key, Module extension) -> void - | (key: Symbol, extension: Module) -> void + def initialize: (Symbol key, Module extension, Symbol matrix_name) -> void + | (key: Symbol, extension: Module, matrix_name: Symbol) -> void + + def phase_method: () -> Symbol end end diff --git a/spec/stroma/entry_spec.rb b/spec/stroma/entry_spec.rb index 52da467..05302bc 100644 --- a/spec/stroma/entry_spec.rb +++ b/spec/stroma/entry_spec.rb @@ -1,13 +1,25 @@ # frozen_string_literal: true RSpec.describe Stroma::Entry do - subject(:entry) { described_class.new(key: :test, extension: test_module) } + subject(:entry) { described_class.new(key: :test, extension: test_module, matrix_name: :my_lib) } let(:test_module) { Module.new } describe ".new" do it { expect(entry.key).to eq(:test) } it { expect(entry.extension).to eq(test_module) } + it { expect(entry.matrix_name).to eq(:my_lib) } + end + + describe "#phase_method" do + it "returns symbol combining matrix_name and key" do + expect(entry.phase_method).to eq(:_my_lib_phase_test!) + end + + it "handles different matrix names" do + other = described_class.new(key: :actions, extension: test_module, matrix_name: :servactory) + expect(other.phase_method).to eq(:_servactory_phase_actions!) + end end describe "immutability" do @@ -17,12 +29,13 @@ end describe "equality" do - let(:same_entry) { described_class.new(key: :test, extension: test_module) } - let(:different_key_entry) { described_class.new(key: :other, extension: test_module) } + let(:same_entry) { described_class.new(key: :test, extension: test_module, matrix_name: :my_lib) } + let(:different_key_entry) { described_class.new(key: :other, extension: test_module, matrix_name: :my_lib) } let(:different_module) { Module.new } - let(:different_extension_entry) { described_class.new(key: :test, extension: different_module) } + let(:different_extension_entry) { described_class.new(key: :test, extension: different_module, matrix_name: :my_lib) } + let(:different_matrix_entry) { described_class.new(key: :test, extension: test_module, matrix_name: :other_lib) } - it "is equal to entry with same key and extension" do + it "is equal to entry with same key, extension, and matrix_name" do expect(entry).to eq(same_entry) end @@ -34,6 +47,10 @@ expect(entry).not_to eq(different_extension_entry) end + it "is not equal to entry with different matrix_name" do + expect(entry).not_to eq(different_matrix_entry) + end + it "has same hash for equal entries" do expect(entry.hash).to eq(same_entry.hash) end From 9d8273787c455083998a7f91dd7506c954fcaf02 Mon Sep 17 00:00:00 2001 From: Anton Sokolov Date: Sat, 14 Feb 2026 01:42:41 +0300 Subject: [PATCH 03/33] Replace `InvalidHookType` with `InvalidMatrixName` - Remove `InvalidHookType` exception and its associated type signature and specs. - Introduce `InvalidMatrixName` exception to enforce valid matrix names matching `/\A[a-z_][a-z0-9_]*\z/`. - Add type signature and specs for `InvalidMatrixName` to ensure it inherits from `Base` and supports rescue functionality. - Update documentation in the `Base` class to reflect the new exception. --- lib/stroma/exceptions/base.rb | 2 +- lib/stroma/exceptions/invalid_hook_type.rb | 27 ------------------- lib/stroma/exceptions/invalid_matrix_name.rb | 24 +++++++++++++++++ ..._hook_type.rbs => invalid_matrix_name.rbs} | 2 +- ...pe_spec.rb => invalid_matrix_name_spec.rb} | 2 +- 5 files changed, 27 insertions(+), 30 deletions(-) delete mode 100644 lib/stroma/exceptions/invalid_hook_type.rb create mode 100644 lib/stroma/exceptions/invalid_matrix_name.rb rename sig/lib/stroma/exceptions/{invalid_hook_type.rbs => invalid_matrix_name.rbs} (59%) rename spec/stroma/exceptions/{invalid_hook_type_spec.rb => invalid_matrix_name_spec.rb} (82%) diff --git a/lib/stroma/exceptions/base.rb b/lib/stroma/exceptions/base.rb index 25302d7..00a2145 100644 --- a/lib/stroma/exceptions/base.rb +++ b/lib/stroma/exceptions/base.rb @@ -36,7 +36,7 @@ module Exceptions # - RegistryNotFinalized - Raised when accessing registry before finalization # - KeyAlreadyRegistered - Raised when registering a duplicate key # - UnknownHookTarget - Raised when using an invalid hook target key - # - InvalidHookType - Raised when using an invalid hook type (:before/:after) + # - InvalidMatrixName - Raised when matrix name does not match the valid pattern class Base < StandardError end end diff --git a/lib/stroma/exceptions/invalid_hook_type.rb b/lib/stroma/exceptions/invalid_hook_type.rb deleted file mode 100644 index 212ee15..0000000 --- a/lib/stroma/exceptions/invalid_hook_type.rb +++ /dev/null @@ -1,27 +0,0 @@ -# frozen_string_literal: true - -module Stroma - module Exceptions - # Raised when an invalid hook type is provided. - # - # ## Purpose - # - # Ensures that only valid hook types (:before, :after) are used - # when creating Stroma::Hooks::Hook objects. Provides fail-fast - # behavior during class definition rather than silent failures at runtime. - # - # ## Usage - # - # ```ruby - # # This will raise InvalidHookType: - # Stroma::Hooks::Hook.new( - # type: :invalid, - # target_key: :actions, - # extension: MyModule - # ) - # # => Stroma::Exceptions::InvalidHookType: - # # Invalid hook type: :invalid. Valid types: :before, :after - # ``` - class InvalidHookType < Base; end - end -end diff --git a/lib/stroma/exceptions/invalid_matrix_name.rb b/lib/stroma/exceptions/invalid_matrix_name.rb new file mode 100644 index 0000000..77c3c8a --- /dev/null +++ b/lib/stroma/exceptions/invalid_matrix_name.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +module Stroma + module Exceptions + # Raised when a matrix name does not match the valid pattern. + # + # ## Purpose + # + # Ensures that matrix names are valid Ruby identifiers suitable + # for use in method names. Names must match /\A[a-z_][a-z0-9_]*\z/. + # + # ## Usage + # + # ```ruby + # # This will raise InvalidMatrixName: + # Stroma::Matrix.define("123invalid") do + # register :inputs, MyModule + # end + # # => Stroma::Exceptions::InvalidMatrixName: + # # Invalid matrix name: "123invalid". Must match /\A[a-z_][a-z0-9_]*\z/ + # ``` + class InvalidMatrixName < Base; end + end +end diff --git a/sig/lib/stroma/exceptions/invalid_hook_type.rbs b/sig/lib/stroma/exceptions/invalid_matrix_name.rbs similarity index 59% rename from sig/lib/stroma/exceptions/invalid_hook_type.rbs rename to sig/lib/stroma/exceptions/invalid_matrix_name.rbs index d4b904f..af80cbd 100644 --- a/sig/lib/stroma/exceptions/invalid_hook_type.rbs +++ b/sig/lib/stroma/exceptions/invalid_matrix_name.rbs @@ -1,6 +1,6 @@ module Stroma module Exceptions - class InvalidHookType < Base + class InvalidMatrixName < Base end end end diff --git a/spec/stroma/exceptions/invalid_hook_type_spec.rb b/spec/stroma/exceptions/invalid_matrix_name_spec.rb similarity index 82% rename from spec/stroma/exceptions/invalid_hook_type_spec.rb rename to spec/stroma/exceptions/invalid_matrix_name_spec.rb index 1de0ef9..d968b7a 100644 --- a/spec/stroma/exceptions/invalid_hook_type_spec.rb +++ b/spec/stroma/exceptions/invalid_matrix_name_spec.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -RSpec.describe Stroma::Exceptions::InvalidHookType do +RSpec.describe Stroma::Exceptions::InvalidMatrixName do it "inherits from Base" do expect(described_class.superclass).to eq(Stroma::Exceptions::Base) end From 8ff4013229fdacf9a289b63928773ac815a715ee Mon Sep 17 00:00:00 2001 From: Anton Sokolov Date: Sat, 14 Feb 2026 01:44:45 +0300 Subject: [PATCH 04/33] Enforce valid matrix names with `validate_name!` method - Add `VALID_NAME_PATTERN` constant and its private visibility to define permissible matrix name formats. - Introduce `validate_name!` to ensure matrix names conform to the specified pattern, raising `InvalidMatrixName` for invalid cases. - Update `initialize` to validate matrix names upon creation. - Add comprehensive specs to test valid and invalid name scenarios. - Update RBS type signatures to include `VALID_NAME_PATTERN` and `validate_name!`. --- lib/stroma/matrix.rb | 17 ++++++++++++++ sig/lib/stroma/matrix.rbs | 6 +++++ spec/stroma/matrix_spec.rb | 46 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 69 insertions(+) diff --git a/lib/stroma/matrix.rb b/lib/stroma/matrix.rb index fc2423b..e1a4aa3 100644 --- a/lib/stroma/matrix.rb +++ b/lib/stroma/matrix.rb @@ -35,6 +35,9 @@ module Stroma # Stored as a constant in the library's namespace. # Owns the Registry and generates DSL module via DSL::Generator. class Matrix + VALID_NAME_PATTERN = /\A[a-z_][a-z0-9_]*\z/ + private_constant :VALID_NAME_PATTERN + class << self # Defines a new Matrix with given name. # @@ -72,6 +75,7 @@ def define(name, &block) # @yield Block for registering DSL modules def initialize(name, &block) @name = name.to_sym + validate_name! @registry = Registry.new(@name) instance_eval(&block) if block_given? @@ -110,5 +114,18 @@ def keys def key?(key) registry.key?(key) end + + private + + # Validates that the matrix name matches the required pattern. + # + # @raise [Exceptions::InvalidMatrixName] If name is invalid + # @return [void] + def validate_name! + return if VALID_NAME_PATTERN.match?(@name) + + raise Exceptions::InvalidMatrixName, + "Invalid matrix name: #{@name.inspect}. Must match #{VALID_NAME_PATTERN.inspect}" + end end end diff --git a/sig/lib/stroma/matrix.rbs b/sig/lib/stroma/matrix.rbs index e80c883..bec9858 100644 --- a/sig/lib/stroma/matrix.rbs +++ b/sig/lib/stroma/matrix.rbs @@ -1,5 +1,7 @@ module Stroma class Matrix + VALID_NAME_PATTERN: Regexp + @name: Symbol @registry: Registry @dsl: Module @@ -19,5 +21,9 @@ module Stroma def keys: () -> Array[Symbol] def key?: (Symbol key) -> bool + + private + + def validate_name!: () -> void end end diff --git a/spec/stroma/matrix_spec.rb b/spec/stroma/matrix_spec.rb index 7c7293e..2a169fc 100644 --- a/spec/stroma/matrix_spec.rb +++ b/spec/stroma/matrix_spec.rb @@ -47,6 +47,52 @@ end end + describe "validate_name!" do + it "accepts valid lowercase names" do + expect { described_class.new(:test) }.not_to raise_error + end + + it "accepts names with underscores" do + expect { described_class.new(:my_lib) }.not_to raise_error + end + + it "accepts names starting with underscore" do + expect { described_class.new(:_private) }.not_to raise_error + end + + it "accepts names with digits" do + expect { described_class.new(:lib2) }.not_to raise_error + end + + it "raises InvalidMatrixName for names starting with digit" do + expect { described_class.new(:"123invalid") }.to raise_error( + Stroma::Exceptions::InvalidMatrixName, + "Invalid matrix name: :\"123invalid\". Must match /\\A[a-z_][a-z0-9_]*\\z/" + ) + end + + it "raises InvalidMatrixName for names with uppercase" do + expect { described_class.new(:MyLib) }.to raise_error( + Stroma::Exceptions::InvalidMatrixName, + "Invalid matrix name: :MyLib. Must match /\\A[a-z_][a-z0-9_]*\\z/" + ) + end + + it "raises InvalidMatrixName for names with dashes" do + expect { described_class.new(:"my-lib") }.to raise_error( + Stroma::Exceptions::InvalidMatrixName, + "Invalid matrix name: :\"my-lib\". Must match /\\A[a-z_][a-z0-9_]*\\z/" + ) + end + + it "raises InvalidMatrixName for empty name" do + expect { described_class.new(:"") }.to raise_error( + Stroma::Exceptions::InvalidMatrixName, + "Invalid matrix name: :\"\". Must match /\\A[a-z_][a-z0-9_]*\\z/" + ) + end + end + describe "#register" do it "delegates to registry" do matrix = described_class.new(:test) do From 2d0a0a199228d8de15bd962656fed9d4caa90b84 Mon Sep 17 00:00:00 2001 From: Anton Sokolov Date: Sat, 14 Feb 2026 01:46:15 +0300 Subject: [PATCH 05/33] Add `matrix_name` to `Entry` initialization - Pass `matrix_name` to `Entry` upon registration for improved traceability. - Enhance `Stroma::Registry#register` to include `matrix_name` when creating new entries. - Add a spec to verify that `matrix_name` is correctly assigned to entries. --- lib/stroma/registry.rb | 2 +- spec/stroma/registry_spec.rb | 8 ++++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/lib/stroma/registry.rb b/lib/stroma/registry.rb index 6258882..ae31e8e 100644 --- a/lib/stroma/registry.rb +++ b/lib/stroma/registry.rb @@ -58,7 +58,7 @@ def register(key, extension) "Key #{key.inspect} already registered in #{@matrix_name.inspect}" end - @entries << Entry.new(key:, extension:) + @entries << Entry.new(key:, extension:, matrix_name: @matrix_name) end # Finalizes the registry, preventing further registrations. diff --git a/spec/stroma/registry_spec.rb b/spec/stroma/registry_spec.rb index 269982a..1a446a5 100644 --- a/spec/stroma/registry_spec.rb +++ b/spec/stroma/registry_spec.rb @@ -19,6 +19,14 @@ expect(registry.keys).to eq([:inputs]) end + it "creates entry with matrix_name" do + extension = Module.new + registry.register(:inputs, extension) + registry.finalize! + + expect(registry.entries.first.matrix_name).to eq(:test) + end + it "raises KeyAlreadyRegistered for duplicate key" do registry.register(:inputs, Module.new) From f7055070788d9def32fbc96df2eae47d1545cacb Mon Sep 17 00:00:00 2001 From: Anton Sokolov Date: Sat, 14 Feb 2026 01:48:15 +0300 Subject: [PATCH 06/33] Replace `Hook` with `Wrap` for hook abstraction refactor - Remove the `Stroma::Hooks::Hook` class, its type signatures, and associated specs. - Introduce `Stroma::Hooks::Wrap` as a replacement for defining extension bindings. - Update `Wrap` to handle `target_key` and `extension` attributes as immutable data objects. - Add RBS type signatures and specs for `Wrap` to validate immutability and equality. - Simplify hook abstraction by transitioning from `Hook` to `Wrap` for improved clarity and maintainability. --- lib/stroma/hooks/hook.rb | 72 ---------------------------------- lib/stroma/hooks/wrap.rb | 34 ++++++++++++++++ sig/lib/stroma/hooks/hook.rbs | 20 ---------- sig/lib/stroma/hooks/wrap.rbs | 13 ++++++ spec/stroma/hooks/hook_spec.rb | 69 -------------------------------- spec/stroma/hooks/wrap_spec.rb | 37 +++++++++++++++++ 6 files changed, 84 insertions(+), 161 deletions(-) delete mode 100644 lib/stroma/hooks/hook.rb create mode 100644 lib/stroma/hooks/wrap.rb delete mode 100644 sig/lib/stroma/hooks/hook.rbs create mode 100644 sig/lib/stroma/hooks/wrap.rbs delete mode 100644 spec/stroma/hooks/hook_spec.rb create mode 100644 spec/stroma/hooks/wrap_spec.rb diff --git a/lib/stroma/hooks/hook.rb b/lib/stroma/hooks/hook.rb deleted file mode 100644 index 0617d6c..0000000 --- a/lib/stroma/hooks/hook.rb +++ /dev/null @@ -1,72 +0,0 @@ -# frozen_string_literal: true - -module Stroma - module Hooks - # Valid hook types for Hook validation. - VALID_HOOK_TYPES = %i[before after].freeze - private_constant :VALID_HOOK_TYPES - - # Immutable value object representing a hook configuration. - # - # ## Purpose - # - # Defines when and where an extension module should be included - # relative to a registered DSL module. Hook is immutable - once - # created, it cannot be modified. - # - # ## Attributes - # - # @!attribute [r] type - # @return [Symbol] Either :before or :after - # @!attribute [r] target_key - # @return [Symbol] Key of the DSL module to hook into (:inputs, :actions, etc.) - # @!attribute [r] extension - # @return [Module] The module to include at the hook point - # - # ## Usage - # - # ```ruby - # hook = Stroma::Hooks::Hook.new( - # type: :before, - # target_key: :actions, - # extension: MyExtension - # ) - # hook.before? # => true - # hook.after? # => false - # ``` - # - # ## Immutability - # - # Hook is a Data object - frozen and immutable after creation. - Hook = Data.define(:type, :target_key, :extension) do - # Initializes a new Hook with validation. - # - # @param type [Symbol] Hook type (:before or :after) - # @param target_key [Symbol] Registry key to hook into - # @param extension [Module] Extension module to include - # @raise [Exceptions::InvalidHookType] If type is invalid - def initialize(type:, target_key:, extension:) - if VALID_HOOK_TYPES.exclude?(type) - raise Exceptions::InvalidHookType, - "Invalid hook type: #{type.inspect}. Valid types: #{VALID_HOOK_TYPES.map(&:inspect).join(', ')}" - end - - super - end - - # Checks if this is a before hook. - # - # @return [Boolean] true if type is :before - def before? - type == :before - end - - # Checks if this is an after hook. - # - # @return [Boolean] true if type is :after - def after? - type == :after - end - end - end -end diff --git a/lib/stroma/hooks/wrap.rb b/lib/stroma/hooks/wrap.rb new file mode 100644 index 0000000..a398cfa --- /dev/null +++ b/lib/stroma/hooks/wrap.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +module Stroma + module Hooks + # Immutable value object representing a wrap configuration. + # + # ## Purpose + # + # Binds an extension module to a specific registry entry key. + # The extension will be applied as a tower module wrapping + # the entry's phase method. + # + # ## Attributes + # + # @!attribute [r] target_key + # @return [Symbol] Key of the DSL module to wrap (:inputs, :actions, etc.) + # @!attribute [r] extension + # @return [Module] The module to include in the tower + # + # ## Usage + # + # ```ruby + # wrap = Stroma::Hooks::Wrap.new( + # target_key: :actions, + # extension: MyExtension + # ) + # ``` + # + # ## Immutability + # + # Wrap is a Data object - frozen and immutable after creation. + Wrap = Data.define(:target_key, :extension) + end +end diff --git a/sig/lib/stroma/hooks/hook.rbs b/sig/lib/stroma/hooks/hook.rbs deleted file mode 100644 index 3f6cb3b..0000000 --- a/sig/lib/stroma/hooks/hook.rbs +++ /dev/null @@ -1,20 +0,0 @@ -module Stroma - module Hooks - VALID_HOOK_TYPES: Array[Symbol] - - # Note: Hook uses Data.define but RBS should NOT inherit from Data - # per RBS documentation for proper type checking - class Hook - attr_reader type: Symbol - attr_reader target_key: Symbol - attr_reader extension: Module - - def initialize: (Symbol type, Symbol target_key, Module extension) -> void - | (type: Symbol, target_key: Symbol, extension: Module) -> void - - def before?: () -> bool - - def after?: () -> bool - end - end -end diff --git a/sig/lib/stroma/hooks/wrap.rbs b/sig/lib/stroma/hooks/wrap.rbs new file mode 100644 index 0000000..a70cf86 --- /dev/null +++ b/sig/lib/stroma/hooks/wrap.rbs @@ -0,0 +1,13 @@ +module Stroma + module Hooks + # Note: Wrap uses Data.define but RBS should NOT inherit from Data + # per RBS documentation for proper type checking + class Wrap + attr_reader target_key: Symbol + attr_reader extension: Module + + def initialize: (Symbol target_key, Module extension) -> void + | (target_key: Symbol, extension: Module) -> void + end + end +end diff --git a/spec/stroma/hooks/hook_spec.rb b/spec/stroma/hooks/hook_spec.rb deleted file mode 100644 index 344fd8f..0000000 --- a/spec/stroma/hooks/hook_spec.rb +++ /dev/null @@ -1,69 +0,0 @@ -# frozen_string_literal: true - -RSpec.describe Stroma::Hooks::Hook do - let(:test_module) { Module.new } - - describe ".new" do - subject(:hook) { described_class.new(type: :before, target_key: :actions, extension: test_module) } - - it { expect(hook.type).to eq(:before) } - it { expect(hook.target_key).to eq(:actions) } - it { expect(hook.extension).to eq(test_module) } - end - - describe "#before?" do - it "returns true for before type" do - hook = described_class.new(type: :before, target_key: :actions, extension: test_module) - expect(hook.before?).to be(true) - end - - it "returns false for after type" do - hook = described_class.new(type: :after, target_key: :actions, extension: test_module) - expect(hook.before?).to be(false) - end - end - - describe "#after?" do - it "returns true for after type" do - hook = described_class.new(type: :after, target_key: :outputs, extension: test_module) - expect(hook.after?).to be(true) - end - - it "returns false for before type" do - hook = described_class.new(type: :before, target_key: :outputs, extension: test_module) - expect(hook.after?).to be(false) - end - end - - describe "immutability" do - it "is immutable (Data object)" do - hook = described_class.new(type: :before, target_key: :actions, extension: test_module) - expect { hook.instance_variable_set(:@type, :after) }.to raise_error(FrozenError) - end - end - - describe "type validation" do - it "accepts :before type" do - expect { described_class.new(type: :before, target_key: :actions, extension: test_module) } - .not_to raise_error - end - - it "accepts :after type" do - expect { described_class.new(type: :after, target_key: :actions, extension: test_module) } - .not_to raise_error - end - - it "raises InvalidHookType for invalid type" do - expect { described_class.new(type: :invalid, target_key: :actions, extension: test_module) } - .to raise_error( - Stroma::Exceptions::InvalidHookType, - "Invalid hook type: :invalid. Valid types: :before, :after" - ) - end - - it "raises InvalidHookType for nil type" do - expect { described_class.new(type: nil, target_key: :actions, extension: test_module) } - .to raise_error(Stroma::Exceptions::InvalidHookType) - end - end -end diff --git a/spec/stroma/hooks/wrap_spec.rb b/spec/stroma/hooks/wrap_spec.rb new file mode 100644 index 0000000..75ae00d --- /dev/null +++ b/spec/stroma/hooks/wrap_spec.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +RSpec.describe Stroma::Hooks::Wrap do + subject(:wrap) { described_class.new(target_key: :actions, extension: test_module) } + + let(:test_module) { Module.new } + + describe ".new" do + it { expect(wrap.target_key).to eq(:actions) } + it { expect(wrap.extension).to eq(test_module) } + end + + describe "immutability" do + it "is frozen" do + expect(wrap).to be_frozen + end + end + + describe "equality" do + let(:same_wrap) { described_class.new(target_key: :actions, extension: test_module) } + let(:different_key_wrap) { described_class.new(target_key: :inputs, extension: test_module) } + let(:different_module) { Module.new } + let(:different_extension_wrap) { described_class.new(target_key: :actions, extension: different_module) } + + it "is equal to wrap with same key and extension" do + expect(wrap).to eq(same_wrap) + end + + it "is not equal to wrap with different key" do + expect(wrap).not_to eq(different_key_wrap) + end + + it "is not equal to wrap with different extension" do + expect(wrap).not_to eq(different_extension_wrap) + end + end +end From 7623b011ebe4a9c7e0777ba510f7acb70ac30458 Mon Sep 17 00:00:00 2001 From: Anton Sokolov Date: Sat, 14 Feb 2026 01:52:21 +0300 Subject: [PATCH 07/33] Refactor `Collection` to replace `Hook` with `Wrap` - Updated `Stroma::Hooks::Collection` to transition from `Hook` to `Wrap` for improved conceptual clarity. - Replaced `add`, `before`, and `after` methods with `add` and `for` to simplify the API. - Modified all specs and type signatures to align with the new `Wrap` abstraction. - Updated inline documentation to reflect the terminology change. - Ensured existing functionality remains intact while introducing the improved abstraction. --- lib/stroma/hooks/collection.rb | 69 ++++++++++------------- sig/lib/stroma/hooks/collection.rbs | 16 +++--- spec/stroma/hooks/collection_spec.rb | 82 +++++++++++----------------- 3 files changed, 67 insertions(+), 100 deletions(-) diff --git a/lib/stroma/hooks/collection.rb b/lib/stroma/hooks/collection.rb index 1b1cdcc..80c8344 100644 --- a/lib/stroma/hooks/collection.rb +++ b/lib/stroma/hooks/collection.rb @@ -2,53 +2,52 @@ module Stroma module Hooks - # Mutable collection manager for Hook objects. + # Mutable collection manager for Wrap objects. # # ## Purpose # - # Stores Hook objects and provides query methods to retrieve hooks - # by type and target key. Supports proper duplication during class + # Stores Wrap objects and provides query methods to retrieve wraps + # by target key. Supports proper duplication during class # inheritance to ensure configuration isolation. # # ## Usage # # ```ruby # hooks = Stroma::Hooks::Collection.new - # hooks.add(:before, :actions, MyModule) - # hooks.add(:after, :actions, AnotherModule) + # hooks.add(:actions, MyModule) + # hooks.add(:actions, AnotherModule) # - # hooks.before(:actions) # => [Hook(...)] - # hooks.after(:actions) # => [Hook(...)] - # hooks.empty? # => false - # hooks.size # => 2 + # hooks.for(:actions) # => [Wrap(...)] + # hooks.empty? # => false + # hooks.size # => 2 # ``` # # ## Integration # # Stored in Stroma::State and used by - # Stroma::Hooks::Applier to apply hooks to classes. + # Stroma::Hooks::Applier to apply wraps to classes. # Properly duplicated during class inheritance via initialize_dup. class Collection extend Forwardable # @!method each - # Iterates over all hooks in the collection. - # @yield [Hook] Each hook in the collection + # Iterates over all wraps in the collection. + # @yield [Wrap] Each wrap in the collection # @!method map - # Maps over all hooks in the collection. - # @yield [Hook] Each hook in the collection + # Maps over all wraps in the collection. + # @yield [Wrap] Each wrap in the collection # @return [Array] Mapped results # @!method size - # Returns the number of hooks in the collection. - # @return [Integer] Number of hooks + # Returns the number of wraps in the collection. + # @return [Integer] Number of wraps # @!method empty? # Checks if the collection is empty. - # @return [Boolean] true if no hooks registered + # @return [Boolean] true if no wraps registered def_delegators :@collection, :each, :map, :size, :empty? - # Creates a new hooks collection. + # Creates a new wraps collection. # - # @param collection [Set] Initial collection of hooks (default: empty Set) + # @param collection [Set] Initial collection of wraps (default: empty Set) def initialize(collection = Set.new) @collection = collection end @@ -62,39 +61,27 @@ def initialize_dup(original) @collection = original.collection.dup end - # Adds a new hook to the collection. + # Adds a new wrap to the collection. # - # @param type [Symbol] Hook type (:before or :after) - # @param target_key [Symbol] Registry key to hook into + # @param target_key [Symbol] Registry key to wrap # @param extension [Module] Extension module to include # @return [Set] The updated collection # # @example - # hooks.add(:before, :actions, ValidationModule) - def add(type, target_key, extension) - @collection << Hook.new(type:, target_key:, extension:) + # hooks.add(:actions, ValidationModule) + def add(target_key, extension) + @collection << Wrap.new(target_key:, extension:) end - # Returns all before hooks for a given key. + # Returns all wraps for a given key. # # @param key [Symbol] The target key to filter by - # @return [Array] Hooks that run before the target + # @return [Array] Wraps targeting the given key # # @example - # hooks.before(:actions) # => [Hook(type: :before, ...)] - def before(key) - @collection.select { |hook| hook.before? && hook.target_key == key } - end - - # Returns all after hooks for a given key. - # - # @param key [Symbol] The target key to filter by - # @return [Array] Hooks that run after the target - # - # @example - # hooks.after(:actions) # => [Hook(type: :after, ...)] - def after(key) - @collection.select { |hook| hook.after? && hook.target_key == key } + # hooks.for(:actions) # => [Wrap(...)] + def for(key) + @collection.select { |wrap| wrap.target_key == key } end protected diff --git a/sig/lib/stroma/hooks/collection.rbs b/sig/lib/stroma/hooks/collection.rbs index 733c753..780e55a 100644 --- a/sig/lib/stroma/hooks/collection.rbs +++ b/sig/lib/stroma/hooks/collection.rbs @@ -3,28 +3,26 @@ module Stroma class Collection extend Forwardable - @collection: Set[Hook] + @collection: Set[Wrap] - def initialize: (?Set[Hook] collection) -> void + def initialize: (?Set[Wrap] collection) -> void def initialize_dup: (Collection original) -> void - def add: (Symbol type, Symbol target_key, Module extension) -> Set[Hook] + def add: (Symbol target_key, Module extension) -> Set[Wrap] - def before: (Symbol key) -> Array[Hook] + def for: (Symbol key) -> Array[Wrap] - def after: (Symbol key) -> Array[Hook] + def each: () { (Wrap) -> void } -> Set[Wrap] - def each: () { (Hook) -> void } -> Set[Hook] - - def map: [T] () { (Hook) -> T } -> Array[T] + def map: [T] () { (Wrap) -> T } -> Array[T] def empty?: () -> bool def size: () -> Integer # Protected in Ruby; declared here for Steep visibility in initialize_dup - attr_reader collection: Set[Hook] + attr_reader collection: Set[Wrap] end end end diff --git a/spec/stroma/hooks/collection_spec.rb b/spec/stroma/hooks/collection_spec.rb index 6f005c8..487a6e4 100644 --- a/spec/stroma/hooks/collection_spec.rb +++ b/spec/stroma/hooks/collection_spec.rb @@ -6,50 +6,32 @@ let(:second_module) { Module.new } describe "#add" do - it "adds a hook to the collection" do - hooks.add(:before, :actions, first_module) - expect(hooks.before(:actions).size).to eq(1) + it "adds a wrap to the collection" do + hooks.add(:actions, first_module) + expect(hooks.for(:actions).size).to eq(1) end - it "allows multiple hooks for the same key" do - hooks.add(:before, :actions, first_module) - hooks.add(:before, :actions, second_module) - expect(hooks.before(:actions).size).to eq(2) + it "allows multiple wraps for the same key" do + hooks.add(:actions, first_module) + hooks.add(:actions, second_module) + expect(hooks.for(:actions).size).to eq(2) end end - describe "#before" do + describe "#for" do before do - hooks.add(:before, :actions, first_module) - hooks.add(:after, :actions, second_module) - hooks.add(:before, :outputs, second_module) + hooks.add(:actions, first_module) + hooks.add(:outputs, second_module) end - it "returns only before hooks for the specified key", :aggregate_failures do - result = hooks.before(:actions) + it "returns wraps for the specified key", :aggregate_failures do + result = hooks.for(:actions) expect(result.size).to eq(1) expect(result.first.extension).to eq(first_module) end - it "returns empty array for key without before hooks" do - expect(hooks.before(:inputs)).to eq([]) - end - end - - describe "#after" do - before do - hooks.add(:before, :actions, first_module) - hooks.add(:after, :actions, second_module) - end - - it "returns only after hooks for the specified key", :aggregate_failures do - result = hooks.after(:actions) - expect(result.size).to eq(1) - expect(result.first.extension).to eq(second_module) - end - - it "returns empty array for key without after hooks" do - expect(hooks.after(:inputs)).to eq([]) + it "returns empty array for key without wraps" do + expect(hooks.for(:inputs)).to eq([]) end end @@ -58,8 +40,8 @@ expect(hooks.empty?).to be(true) end - it "returns false after adding a hook" do - hooks.add(:before, :actions, first_module) + it "returns false after adding a wrap" do + hooks.add(:actions, first_module) expect(hooks.empty?).to be(false) end end @@ -68,19 +50,19 @@ let(:copy) { hooks.dup } before do - hooks.add(:before, :actions, first_module) - hooks.add(:after, :outputs, second_module) + hooks.add(:actions, first_module) + hooks.add(:outputs, second_module) end - it "creates a copy with the same hooks", :aggregate_failures do - expect(copy.before(:actions).size).to eq(1) - expect(copy.after(:outputs).size).to eq(1) + it "creates a copy with the same wraps", :aggregate_failures do + expect(copy.for(:actions).size).to eq(1) + expect(copy.for(:outputs).size).to eq(1) end it "creates an independent copy", :aggregate_failures do - copy.add(:before, :inputs, second_module) - expect(hooks.before(:inputs)).to be_empty - expect(copy.before(:inputs).size).to eq(1) + copy.add(:inputs, second_module) + expect(hooks.for(:inputs)).to be_empty + expect(copy.for(:inputs).size).to eq(1) end end @@ -89,25 +71,25 @@ expect(hooks.size).to eq(0) end - it "returns count of hooks" do - hooks.add(:before, :actions, first_module) - hooks.add(:after, :actions, second_module) + it "returns count of wraps" do + hooks.add(:actions, first_module) + hooks.add(:actions, second_module) expect(hooks.size).to eq(2) end end describe "#each" do before do - hooks.add(:before, :actions, first_module) - hooks.add(:after, :outputs, second_module) + hooks.add(:actions, first_module) + hooks.add(:outputs, second_module) end - it "yields each hook" do + it "yields each wrap" do expect(hooks.map(&:itself).size).to eq(2) end - it "yields Hook objects" do - expect(hooks.map(&:itself)).to all(be_a(Stroma::Hooks::Hook)) + it "yields Wrap objects" do + expect(hooks.map(&:itself)).to all(be_a(Stroma::Hooks::Wrap)) end end From 0f05c780d30cc62218f8d71043ff1260d0745119 Mon Sep 17 00:00:00 2001 From: Anton Sokolov Date: Sat, 14 Feb 2026 01:54:23 +0300 Subject: [PATCH 08/33] Refactor `Factory` to replace `before`/`after` with `wrap` - Removed `before` and `after` methods from `Stroma::Hooks::Factory` in favor of a unified `wrap` method for streamlined API usage. - Updated specs to reflect the removal of `before`/`after` and verify `wrap` functionality. - Modified type signatures to align with the `wrap` abstraction. - Updated documentation for the new `wrap` design, simplifying hook registration logic. --- lib/stroma/hooks/factory.rb | 33 ++++++--------------- sig/lib/stroma/hooks/factory.rbs | 4 +-- spec/stroma/hooks/factory_spec.rb | 48 ++++++++++++------------------- 3 files changed, 29 insertions(+), 56 deletions(-) diff --git a/lib/stroma/hooks/factory.rb b/lib/stroma/hooks/factory.rb index 8656109..ffb704b 100644 --- a/lib/stroma/hooks/factory.rb +++ b/lib/stroma/hooks/factory.rb @@ -2,11 +2,11 @@ module Stroma module Hooks - # DSL interface for registering hooks in extensions block. + # DSL interface for registering wraps in extensions block. # # ## Purpose # - # Provides before/after DSL methods for hook registration. + # Provides wrap DSL method for hook registration. # Validates target keys against the matrix's registry. # Delegates to Hooks::Collection for storage. # @@ -15,8 +15,7 @@ module Hooks # ```ruby # class MyService < MyLib::Base # extensions do - # before :actions, ValidationModule, AuthModule - # after :outputs, LoggingModule + # wrap :actions, ValidationModule, AuthModule # end # end # ``` @@ -26,7 +25,7 @@ module Hooks # Created by DSL::Generator's extensions method. # Cached as @stroma_hooks_factory on each service class. class Factory - # Creates a new factory for registering hooks. + # Creates a new factory for registering wraps. # # @param hooks [Collection] The hooks collection to add to # @param matrix [Matrix] The matrix providing valid keys @@ -35,32 +34,18 @@ def initialize(hooks, matrix) @matrix = matrix end - # Registers one or more before hooks for a target key. + # Registers one or more wraps for a target key. # - # @param key [Symbol] The registry key to hook before + # @param key [Symbol] The registry key to wrap # @param extensions [Array] Extension modules to include # @raise [Exceptions::UnknownHookTarget] If key is not registered # @return [void] # # @example - # before :actions, ValidationModule, AuthorizationModule - def before(key, *extensions) + # wrap :actions, ValidationModule, AuthorizationModule + def wrap(key, *extensions) validate_key!(key) - extensions.each { |extension| @hooks.add(:before, key, extension) } - end - - # Registers one or more after hooks for a target key. - # - # @param key [Symbol] The registry key to hook after - # @param extensions [Array] Extension modules to include - # @raise [Exceptions::UnknownHookTarget] If key is not registered - # @return [void] - # - # @example - # after :outputs, LoggingModule, AuditModule - def after(key, *extensions) - validate_key!(key) - extensions.each { |extension| @hooks.add(:after, key, extension) } + extensions.each { |extension| @hooks.add(key, extension) } end private diff --git a/sig/lib/stroma/hooks/factory.rbs b/sig/lib/stroma/hooks/factory.rbs index 4afec3a..2b7d84f 100644 --- a/sig/lib/stroma/hooks/factory.rbs +++ b/sig/lib/stroma/hooks/factory.rbs @@ -6,9 +6,7 @@ module Stroma def initialize: (Collection hooks, Matrix matrix) -> void - def before: (Symbol key, *Module extensions) -> void - - def after: (Symbol key, *Module extensions) -> void + def wrap: (Symbol key, *Module extensions) -> void private diff --git a/spec/stroma/hooks/factory_spec.rb b/spec/stroma/hooks/factory_spec.rb index ed59922..6e4f62c 100644 --- a/spec/stroma/hooks/factory_spec.rb +++ b/spec/stroma/hooks/factory_spec.rb @@ -13,40 +13,20 @@ let(:first_module) { Module.new } let(:second_module) { Module.new } - describe "#before" do - it "adds a before hook", :aggregate_failures do - factory.before(:actions, first_module) - expect(hooks.before(:actions).size).to eq(1) - expect(hooks.before(:actions).first.extension).to eq(first_module) + describe "#wrap" do + it "adds a wrap", :aggregate_failures do + factory.wrap(:actions, first_module) + expect(hooks.for(:actions).size).to eq(1) + expect(hooks.for(:actions).first.extension).to eq(first_module) end it "adds multiple modules at once" do - factory.before(:actions, first_module, second_module) - expect(hooks.before(:actions).size).to eq(2) + factory.wrap(:actions, first_module, second_module) + expect(hooks.for(:actions).size).to eq(2) end it "raises UnknownHookTarget for unknown key" do - expect { factory.before(:unknown, first_module) }.to raise_error( - Stroma::Exceptions::UnknownHookTarget, - "Unknown hook target :unknown for :test. Valid: :inputs, :outputs, :actions" - ) - end - end - - describe "#after" do - it "adds an after hook", :aggregate_failures do - factory.after(:outputs, first_module) - expect(hooks.after(:outputs).size).to eq(1) - expect(hooks.after(:outputs).first.extension).to eq(first_module) - end - - it "adds multiple modules at once" do - factory.after(:outputs, first_module, second_module) - expect(hooks.after(:outputs).size).to eq(2) - end - - it "raises UnknownHookTarget for unknown key" do - expect { factory.after(:unknown, first_module) }.to raise_error( + expect { factory.wrap(:unknown, first_module) }.to raise_error( Stroma::Exceptions::UnknownHookTarget, "Unknown hook target :unknown for :test. Valid: :inputs, :outputs, :actions" ) @@ -56,8 +36,18 @@ describe "valid keys" do it "accepts all registered keys" do %i[inputs outputs actions].each do |key| - expect { factory.before(key, first_module) }.not_to raise_error + expect { factory.wrap(key, first_module) }.not_to raise_error end end end + + describe "removed methods" do + it "does not respond to before" do + expect(factory).not_to respond_to(:before) + end + + it "does not respond to after" do + expect(factory).not_to respond_to(:after) + end + end end From 2dd4b11667a717a60fb8482d0a8296e4672a6b97 Mon Sep 17 00:00:00 2001 From: Anton Sokolov Date: Sat, 14 Feb 2026 01:56:04 +0300 Subject: [PATCH 09/33] Refactor `Applier` to handle wraps via tower modules - Replace `before`/`after` hook concept with a unified wrap-based tower system for streamlined processing. - Implement ClassMethods/InstanceMethods wrapping convention for better DSL and instance extensions. - Add tower caching for improved performance, reusing towers across subclasses. - Update specs to verify multiple wrap scenarios, tower labeling, and caching behavior. - Modify documentation and type signatures to reflect the new wrap-based architecture. --- lib/stroma/hooks/applier.rb | 94 +++++++++++++++---- sig/lib/stroma/hooks/applier.rbs | 8 ++ spec/stroma/hooks/applier_spec.rb | 145 ++++++++++++++++++++++++------ 3 files changed, 202 insertions(+), 45 deletions(-) diff --git a/lib/stroma/hooks/applier.rb b/lib/stroma/hooks/applier.rb index 33e3418..803a496 100644 --- a/lib/stroma/hooks/applier.rb +++ b/lib/stroma/hooks/applier.rb @@ -2,33 +2,45 @@ module Stroma module Hooks - # Applies registered hooks to a target class. + # Applies registered wraps to a target class via per-entry towers. # # ## Purpose # - # Includes hook extension modules into target class. - # Maintains order based on matrix registry entries. - # For each entry: before hooks first, then after hooks. + # For each entry with wraps, builds a tower module that contains + # all wrap extensions. Towers are prepended to the target class. + # ClassMethods from wrap extensions are extended directly. + # + # ## Tower convention + # + # Wrap extensions follow the ClassMethods/InstanceMethods convention: + # - `ClassMethods` — extended directly on target_class (for DSL methods) + # - `InstanceMethods` — included in tower module, prepended on target_class + # - Phase::Resolver — checked first for generic wrappable extensions + # - Plain module (no nested constants) — included in tower as-is + # + # ## Caching + # + # Towers are cached by [matrix_name, entry_key, extension_object_ids] + # since they are built at boot time and reused across subclasses. # # ## Usage # # ```ruby # # Called internally during class inheritance - # applier = Stroma::Hooks::Applier.new(ChildService, hooks, matrix) - # applier.apply! + # Stroma::Hooks::Applier.apply!(ChildService, hooks, matrix) # ``` # # ## Integration # # Called by DSL::Generator's inherited hook. - # Creates a temporary instance that is garbage collected after apply!. class Applier + TOWER_CACHE = {} + private_constant :TOWER_CACHE + class << self - # Applies all registered hooks to the target class. - # - # Convenience class method that creates an applier and applies hooks. + # Applies all registered wraps to the target class. # - # @param target_class [Class] The class to apply hooks to + # @param target_class [Class] The class to apply wraps to # @param hooks [Collection] The hooks collection to apply # @param matrix [Matrix] The matrix providing registry entries # @return [void] @@ -37,9 +49,9 @@ def apply!(target_class, hooks, matrix) end end - # Creates a new applier for applying hooks to a class. + # Creates a new applier for applying wraps to a class. # - # @param target_class [Class] The class to apply hooks to + # @param target_class [Class] The class to apply wraps to # @param hooks [Collection] The hooks collection to apply # @param matrix [Matrix] The matrix providing registry entries def initialize(target_class, hooks, matrix) @@ -48,19 +60,63 @@ def initialize(target_class, hooks, matrix) @matrix = matrix end - # Applies all registered hooks to the target class. + # Applies all registered wraps to the target class. # - # For each registry entry, includes before hooks first, - # then after hooks. Does nothing if hooks collection is empty. + # For each entry with wraps: + # - Extends ClassMethods directly on target class + # - Builds/fetches a tower module and prepends it # # @return [void] - def apply! + def apply! # rubocop:disable Metrics/AbcSize return if @hooks.empty? @matrix.entries.each do |entry| - @hooks.before(entry.key).each { |hook| @target_class.include(hook.extension) } - @hooks.after(entry.key).each { |hook| @target_class.include(hook.extension) } + wraps_for_entry = @hooks.for(entry.key) + next if wraps_for_entry.empty? + + wraps_for_entry.each do |wrap| + ext = wrap.extension + @target_class.extend(ext::ClassMethods) if ext.const_defined?(:ClassMethods, false) + end + + tower = fetch_or_build_tower(entry, wraps_for_entry) + @target_class.prepend(tower) + end + end + + private + + # Fetches a cached tower or builds a new one. + # + # @param entry [Entry] The entry to build tower for + # @param wraps [Array] The wraps for this entry + # @return [Module] The tower module + def fetch_or_build_tower(entry, wraps) + cache_key = [entry.matrix_name, entry.key, wraps.map { |w| w.extension.object_id }] + TOWER_CACHE[cache_key] ||= build_tower(entry, wraps) + end + + # Builds a tower module from wraps for a specific entry. + # + # @param entry [Entry] The entry to build tower for + # @param wraps [Array] The wraps for this entry + # @return [Module] The tower module + def build_tower(entry, wraps) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength + tower = Module.new do + wraps.reverse_each do |wrap| + ext = wrap.extension + resolved = Phase::Resolver.resolve(ext, entry) + if resolved + include resolved + elsif ext.const_defined?(:InstanceMethods, false) + include ext::InstanceMethods + else + include ext + end + end end + Utils.label_module(tower, "Stroma::Tower(#{entry.matrix_name}:#{entry.key})") + tower end end end diff --git a/sig/lib/stroma/hooks/applier.rbs b/sig/lib/stroma/hooks/applier.rbs index 628e090..3445e25 100644 --- a/sig/lib/stroma/hooks/applier.rbs +++ b/sig/lib/stroma/hooks/applier.rbs @@ -1,6 +1,8 @@ module Stroma module Hooks class Applier + TOWER_CACHE: Hash[Array[untyped], Module] + @target_class: Class @hooks: Collection @matrix: Matrix @@ -10,6 +12,12 @@ module Stroma def initialize: (Class target_class, Collection hooks, Matrix matrix) -> void def apply!: () -> void + + private + + def fetch_or_build_tower: (Entry entry, Array[Wrap] wraps) -> Module + + def build_tower: (Entry entry, Array[Wrap] wraps) -> Module end end end diff --git a/spec/stroma/hooks/applier_spec.rb b/spec/stroma/hooks/applier_spec.rb index 93b98d6..fd04205 100644 --- a/spec/stroma/hooks/applier_spec.rb +++ b/spec/stroma/hooks/applier_spec.rb @@ -17,66 +17,159 @@ let(:applier) { described_class.new(target_class, hooks, matrix) } describe ".apply!" do - let(:before_extension) { Module.new } + let(:wrap_extension) { Module.new } before do - hooks.add(:before, :inputs, before_extension) + hooks.add(:inputs, wrap_extension) end - it "applies hooks via class method" do + it "applies wraps via class method" do described_class.apply!(target_class, hooks, matrix) - expect(target_class.ancestors).to include(before_extension) + expect(target_class.ancestors).to include(wrap_extension) end end describe "#apply!" do it "does nothing when hooks empty" do + ancestors_before = target_class.ancestors.dup applier.apply! - expect(target_class.ancestors).not_to include(inputs_dsl) + expect(target_class.ancestors).to eq(ancestors_before) end - context "with before hooks" do - let(:before_extension) { Module.new } + context "with plain module wraps" do + let(:wrap_extension) { Module.new } before do - hooks.add(:before, :inputs, before_extension) + hooks.add(:inputs, wrap_extension) end - it "includes before hook extension" do + it "prepends tower containing the extension" do applier.apply! - expect(target_class.ancestors).to include(before_extension) + expect(target_class.ancestors).to include(wrap_extension) end end - context "with after hooks" do - let(:after_extension) { Module.new } + context "with ClassMethods convention" do + let(:class_methods_mod) do + Module.new do + def class_dsl_method + :class_result + end + end + end + + let(:wrap_extension) do + cm = class_methods_mod + Module.new do + const_set(:ClassMethods, cm) + end + end + + before do + hooks.add(:inputs, wrap_extension) + end + + it "extends ClassMethods on target class" do + applier.apply! + expect(target_class).to respond_to(:class_dsl_method) + end + + it "ClassMethods method works" do + applier.apply! + expect(target_class.class_dsl_method).to eq(:class_result) + end + end + + context "with InstanceMethods convention" do + let(:instance_methods_mod) do + Module.new do + def instance_wrap_method + :instance_result + end + end + end + + let(:wrap_extension) do + im = instance_methods_mod + Module.new do + const_set(:InstanceMethods, im) + end + end before do - hooks.add(:after, :outputs, after_extension) + hooks.add(:inputs, wrap_extension) end - it "includes after hook extension" do + it "includes InstanceMethods in tower" do applier.apply! - expect(target_class.ancestors).to include(after_extension) + expect(target_class.new).to respond_to(:instance_wrap_method) end end - context "with multiple hooks" do # rubocop:disable RSpec/MultipleMemoizedHelpers - let(:before_inputs) { Module.new } - let(:after_inputs) { Module.new } - let(:before_outputs) { Module.new } + context "with multiple wraps for same entry" do + let(:first_extension) { Module.new } + let(:second_extension) { Module.new } before do - hooks.add(:before, :inputs, before_inputs) - hooks.add(:after, :inputs, after_inputs) - hooks.add(:before, :outputs, before_outputs) + hooks.add(:inputs, first_extension) + hooks.add(:inputs, second_extension) end - it "applies all hooks", :aggregate_failures do + it "applies all wraps", :aggregate_failures do applier.apply! - expect(target_class.ancestors).to include(before_inputs) - expect(target_class.ancestors).to include(after_inputs) - expect(target_class.ancestors).to include(before_outputs) + expect(target_class.ancestors).to include(first_extension) + expect(target_class.ancestors).to include(second_extension) + end + end + + context "with wraps for different entries" do + let(:inputs_extension) { Module.new } + let(:outputs_extension) { Module.new } + + before do + hooks.add(:inputs, inputs_extension) + hooks.add(:outputs, outputs_extension) + end + + it "builds separate towers", :aggregate_failures do + applier.apply! + expect(target_class.ancestors).to include(inputs_extension) + expect(target_class.ancestors).to include(outputs_extension) + end + end + + describe "tower labeling" do + let(:wrap_extension) { Module.new } + + before do + hooks.add(:inputs, wrap_extension) + end + + it "labels tower modules" do + applier.apply! + tower = target_class.ancestors.find { |a| a.inspect.include?("Stroma::Tower") } + expect(tower.inspect).to eq("Stroma::Tower(test:inputs)") + end + end + + describe "tower caching" do + let(:wrap_extension) { Module.new } + + before do + hooks.add(:inputs, wrap_extension) + end + + it "reuses cached tower for same wraps" do + first_class = Class.new + second_class = Class.new + + described_class.apply!(first_class, hooks, matrix) + described_class.apply!(second_class, hooks, matrix) + + first_tower = first_class.ancestors.find { |a| a.inspect.include?("Stroma::Tower") } + second_tower = second_class.ancestors.find { |a| a.inspect.include?("Stroma::Tower") } + + expect(first_tower).to equal(second_tower) end end end From 6e9a3746b40183a80cc2b303b2c00506b5ed22ac Mon Sep 17 00:00:00 2001 From: Anton Sokolov Date: Sat, 14 Feb 2026 01:57:12 +0300 Subject: [PATCH 10/33] Refactor `Generator` to add phase stubs and orchestrator handling - Introduced no-op phase stubs and an orchestrator method for sequential phase execution in the generated DSL modules. - Modified specs to validate private visibility, orchestrator behavior, and phase kwargs handling. - Replaced legacy `before`/`after` hooks with `wrap` for extension consistency, updating specs to reflect the changes. - Moved `label_module` to `Utils` for modularization, updating its usage in `Generator`. - Removed support for older Ruby versions in `label_module` and cleaned up type signatures accordingly. - Updated inline documentation and specs to reflect these refactors and additions. --- lib/stroma/dsl/generator.rb | 44 ++++++------ sig/lib/stroma/dsl/generator.rbs | 2 - spec/stroma/dsl/generator_spec.rb | 110 ++++++++++++++++++++++++------ 3 files changed, 110 insertions(+), 46 deletions(-) diff --git a/lib/stroma/dsl/generator.rb b/lib/stroma/dsl/generator.rb index 365a336..39722f7 100644 --- a/lib/stroma/dsl/generator.rb +++ b/lib/stroma/dsl/generator.rb @@ -8,6 +8,8 @@ module DSL # # Creates a module that: # - Stores matrix reference on the module itself + # - Defines no-op phase stubs for all entries + # - Defines an orchestrator that calls phase methods sequentially # - Defines ClassMethods for service classes # - Handles inheritance with state duplication # @@ -52,20 +54,32 @@ def initialize(matrix) # Generates the DSL module. # - # Creates a module with ClassMethods that provides: - # - stroma_matrix accessor for matrix reference - # - stroma accessor for per-class state - # - inherited hook for state duplication - # - extensions DSL for registering hooks + # Creates a module with: + # - No-op phase stubs for all entries (overridden by workspace modules) + # - An orchestrator method that calls phases sequentially + # - ClassMethods for stroma_matrix, stroma, inherited, extensions # # @return [Module] The generated DSL module def generate # rubocop:disable Metrics/AbcSize, Metrics/MethodLength matrix = @matrix class_methods = build_class_methods + orchestrator_method = :"_#{matrix.name}_phases_perform!" + entries = matrix.entries mod = Module.new do @stroma_matrix = matrix + entries.each do |entry| + pm = entry.phase_method + define_method(pm) { |**| } + private pm + end + + define_method(orchestrator_method) do |**args| + entries.each { |entry| send(entry.phase_method, **args) } + end + private orchestrator_method + class << self attr_reader :stroma_matrix @@ -82,30 +96,14 @@ def included(base) const_set(:ClassMethods, class_methods) end - label_module(mod, "Stroma::DSL(#{matrix.name})") - label_module(class_methods, "Stroma::DSL(#{matrix.name})::ClassMethods") + Utils.label_module(mod, "Stroma::DSL(#{matrix.name})") + Utils.label_module(class_methods, "Stroma::DSL(#{matrix.name})::ClassMethods") mod end private - # Assigns a descriptive label to an anonymous module for debugging. - # Uses set_temporary_name (Ruby 3.3+) when available. - # - # TODO: Remove the else branch when Ruby 3.2 support is dropped. - # The define_singleton_method fallback is a temporary workaround - # that only affects #inspect and #to_s. Unlike set_temporary_name, - # it does not set #name, so the module remains technically anonymous. - def label_module(mod, label) - if mod.respond_to?(:set_temporary_name) - mod.set_temporary_name(label) - else - mod.define_singleton_method(:inspect) { label } - mod.define_singleton_method(:to_s) { label } - end - end - # Builds the ClassMethods module. # # @return [Module] The ClassMethods module diff --git a/sig/lib/stroma/dsl/generator.rbs b/sig/lib/stroma/dsl/generator.rbs index 6411754..8953dbe 100644 --- a/sig/lib/stroma/dsl/generator.rbs +++ b/sig/lib/stroma/dsl/generator.rbs @@ -11,8 +11,6 @@ module Stroma private - def label_module: (Module mod, String label) -> void - def build_class_methods: () -> Module end end diff --git a/spec/stroma/dsl/generator_spec.rb b/spec/stroma/dsl/generator_spec.rb index c2f5b2c..bd77312 100644 --- a/spec/stroma/dsl/generator_spec.rb +++ b/spec/stroma/dsl/generator_spec.rb @@ -36,11 +36,6 @@ expect(class_methods.inspect).to include("ClassMethods") end - if Module.new.respond_to?(:set_temporary_name) - it "sets module name via set_temporary_name" do - expect(dsl_module.name).to eq("Stroma::DSL(test)") - end - end end end @@ -70,12 +65,82 @@ end end + describe "phase stubs and orchestrator" do + let(:base_class) do + mtx = matrix + Class.new { include mtx.dsl } + end + + it "defines phase methods as private" do + instance = base_class.new + expect(instance.private_methods).to include(:_test_phase_inputs!, :_test_phase_outputs!) + end + + it "defines orchestrator as private" do + instance = base_class.new + expect(instance.private_methods).to include(:_test_phases_perform!) + end + + it "orchestrator calls all phases in order" do + call_order = [] + co = call_order + + inputs_mod = Module.new do + define_method(:_test_phase_inputs!) do |**| + co << :inputs + end + end + + outputs_mod = Module.new do + define_method(:_test_phase_outputs!) do |**| + co << :outputs + end + end + + mtx = Stroma::Matrix.define(:test) do + register :inputs, inputs_mod + register :outputs, outputs_mod + end + + klass = Class.new { include mtx.dsl } + klass.new.send(:_test_phases_perform!) + + expect(call_order).to eq(%i[inputs outputs]) + end + + it "phase stubs are no-ops by default" do + expect { base_class.new.send(:_test_phase_inputs!) }.not_to raise_error + end + + it "orchestrator passes kwargs to phases" do + received_kwargs = {} + rk = received_kwargs + + inputs_mod = Module.new do + define_method(:_test_phase_inputs!) do |**kwargs| + rk.merge!(kwargs) + end + end + + mtx = Stroma::Matrix.define(:test) do + register :inputs, inputs_mod + end + + klass = Class.new { include mtx.dsl } + klass.new.send(:_test_phases_perform!, foo: :bar) + + expect(received_kwargs).to eq(foo: :bar) + end + end + describe "inheritance" do let(:extension_module) do Module.new do - def self.included(base) - base.define_singleton_method(:extension_method) { :extension_result } - end + const_set(:ClassMethods, Module.new { + def extension_method + :extension_result + end + }) end end @@ -86,18 +151,19 @@ def self.included(base) include mtx.dsl extensions do - before :inputs, ext + wrap :inputs, ext end end end let(:child_class) { Class.new(base_class) } - it "applies hooks to child class" do - expect(child_class.ancestors).to include(extension_module) + it "applies wraps to child class" do + tower = child_class.ancestors.find { |a| a.inspect.include?("Stroma::Tower") } + expect(tower).not_to be_nil end - it "child has extension method", :aggregate_failures do + it "child has extension ClassMethods", :aggregate_failures do expect(child_class).to respond_to(:extension_method) expect(child_class.extension_method).to eq(:extension_result) end @@ -122,7 +188,7 @@ def self.included(base) include mtx.dsl extensions do - before :inputs, ext + wrap :inputs, ext end end end @@ -134,30 +200,32 @@ def self.included(base) child_class.class_eval do extensions do - after :outputs, child_extension + wrap :outputs, child_extension end end - expect(parent_class.stroma.hooks.after(:outputs)).to be_empty - expect(child_class.stroma.hooks.after(:outputs)).not_to be_empty + expect(parent_class.stroma.hooks.for(:outputs)).to be_empty + expect(child_class.stroma.hooks.for(:outputs)).not_to be_empty end it "child inherits parent hooks", :aggregate_failures do - expect(child_class.stroma.hooks.before(:inputs).size).to eq(1) - expect(child_class.ancestors).to include(extension_module) + expect(child_class.stroma.hooks.for(:inputs).size).to eq(1) + + tower = child_class.ancestors.find { |a| a.inspect.include?("Stroma::Tower") } + expect(tower).not_to be_nil end it "parent modifications after child creation do not affect child" do - child_before_count = child_class.stroma.hooks.before(:outputs).size + child_before_count = child_class.stroma.hooks.for(:outputs).size new_extension = Module.new parent_class.class_eval do extensions do - before :outputs, new_extension + wrap :outputs, new_extension end end - expect(child_class.stroma.hooks.before(:outputs).size).to eq(child_before_count) + expect(child_class.stroma.hooks.for(:outputs).size).to eq(child_before_count) end end end From 64e15892f2949ab781108e06dd05bda2e96ff2d8 Mon Sep 17 00:00:00 2001 From: Anton Sokolov Date: Sat, 14 Feb 2026 01:57:56 +0300 Subject: [PATCH 11/33] Refactor `State` to replace `before`/`after` with `wrap` - Replaced `before`/`after` hooks with a unified `wrap` API for improved clarity. - Updated the `State` class to use `add` and `for` methods for hook management. - Modified specs to reflect API changes, ensuring independent state copies for hooks and settings. - Updated documentation to align with the new `wrap` abstraction. --- lib/stroma/state.rb | 6 +++--- spec/stroma/state_spec.rb | 10 +++++----- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/lib/stroma/state.rb b/lib/stroma/state.rb index 3b59301..0ad634e 100644 --- a/lib/stroma/state.rb +++ b/lib/stroma/state.rb @@ -6,7 +6,7 @@ module Stroma # ## Purpose # # Central container that stores: - # - Hooks collection for before/after extension points + # - Hooks collection for wrap extension points # - Settings collection for extension-specific configuration # # Each service class has its own State instance, duplicated during @@ -29,7 +29,7 @@ module Stroma # class MyLib::Base # include MyLib::DSL # - # stroma.hooks.before(:actions) + # stroma.hooks.for(:actions) # stroma.settings[:actions][:authorization][:method_name] # end # ``` @@ -40,7 +40,7 @@ module Stroma # Duplicated in DSL.inherited to provide inheritance isolation. class State # @!attribute [r] hooks - # @return [Hooks::Collection] The hooks collection for this class + # @return [Hooks::Collection] The wraps collection for this class # @!attribute [r] settings # @return [Settings::Collection] The settings collection for this class attr_reader :hooks, :settings diff --git a/spec/stroma/state_spec.rb b/spec/stroma/state_spec.rb index 2b13a27..746f028 100644 --- a/spec/stroma/state_spec.rb +++ b/spec/stroma/state_spec.rb @@ -27,12 +27,12 @@ let(:copy) { state.dup } before do - state.hooks.add(:before, :actions, test_module) + state.hooks.add(:actions, test_module) state.settings[:actions][:authorization][:method_name] = :authorize end it "creates a copy with the same hooks" do - expect(copy.hooks.before(:actions).size).to eq(1) + expect(copy.hooks.for(:actions).size).to eq(1) end it "creates a copy with the same settings" do @@ -40,10 +40,10 @@ end it "creates an independent copy for hooks", :aggregate_failures do - copy.hooks.add(:after, :outputs, test_module) + copy.hooks.add(:outputs, test_module) - expect(state.hooks.after(:outputs)).to be_empty - expect(copy.hooks.after(:outputs).size).to eq(1) + expect(state.hooks.for(:outputs)).to be_empty + expect(copy.hooks.for(:outputs).size).to eq(1) end it "creates an independent copy for settings", :aggregate_failures do From 35fac2f15555ca9933f2cb3a208e555a646217f4 Mon Sep 17 00:00:00 2001 From: Anton Sokolov Date: Sat, 14 Feb 2026 01:58:46 +0300 Subject: [PATCH 12/33] Introduce `Phase::Wrap` for generic phase wrapping blueprint - Added `Stroma::Phase::Wrap` as an immutable value object for defining generic phase wrapping blueprints using `target_key` and a `block`. - Provided an RBS definition of `Wrap` with type-checked attributes and an initializer. - Included specs to validate immutability and ensure accurate initialization of `target_key` and `block`. --- lib/stroma/phase/wrap.rb | 26 ++++++++++++++++++++++++++ sig/lib/stroma/phase/wrap.rbs | 13 +++++++++++++ spec/stroma/phase/wrap_spec.rb | 18 ++++++++++++++++++ 3 files changed, 57 insertions(+) create mode 100644 lib/stroma/phase/wrap.rb create mode 100644 sig/lib/stroma/phase/wrap.rbs create mode 100644 spec/stroma/phase/wrap_spec.rb diff --git a/lib/stroma/phase/wrap.rb b/lib/stroma/phase/wrap.rb new file mode 100644 index 0000000..c878d60 --- /dev/null +++ b/lib/stroma/phase/wrap.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +module Stroma + module Phase + # Immutable value object representing a generic phase wrap blueprint. + # + # ## Purpose + # + # Stores a target key and a block that will be used to generate + # a concrete module wrapping a phase method. Used by extensions + # that extend Phase::Wrappable. + # + # ## Distinction from Hooks::Wrap + # + # - `Hooks::Wrap` = concrete binding of a module to an entry (target_key + module) + # - `Phase::Wrap` = blueprint of a generic wrap with a block (target_key + block) + # + # ## Attributes + # + # @!attribute [r] target_key + # @return [Symbol] Key of the entry to wrap + # @!attribute [r] block + # @return [Proc] Block that wraps the phase method + Wrap = Data.define(:target_key, :block) + end +end diff --git a/sig/lib/stroma/phase/wrap.rbs b/sig/lib/stroma/phase/wrap.rbs new file mode 100644 index 0000000..d3e9900 --- /dev/null +++ b/sig/lib/stroma/phase/wrap.rbs @@ -0,0 +1,13 @@ +module Stroma + module Phase + # Note: Wrap uses Data.define but RBS should NOT inherit from Data + # per RBS documentation for proper type checking + class Wrap + attr_reader target_key: Symbol + attr_reader block: Proc + + def initialize: (Symbol target_key, Proc block) -> void + | (target_key: Symbol, block: Proc) -> void + end + end +end diff --git a/spec/stroma/phase/wrap_spec.rb b/spec/stroma/phase/wrap_spec.rb new file mode 100644 index 0000000..ea87df6 --- /dev/null +++ b/spec/stroma/phase/wrap_spec.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +RSpec.describe Stroma::Phase::Wrap do + subject(:wrap) { described_class.new(target_key: :actions, block: test_block) } + + let(:test_block) { proc { |phase, **kwargs| phase.call(**kwargs) } } + + describe ".new" do + it { expect(wrap.target_key).to eq(:actions) } + it { expect(wrap.block).to eq(test_block) } + end + + describe "immutability" do + it "is frozen" do + expect(wrap).to be_frozen + end + end +end From 4eeb5e11d0599e8da88be7d8135ad6d4e3d4e211 Mon Sep 17 00:00:00 2001 From: Anton Sokolov Date: Sat, 14 Feb 2026 01:59:26 +0300 Subject: [PATCH 13/33] Introduce `Wrappable` for phase-level method wrapping support - Added `Stroma::Phase::Wrappable` module to provide a `wrap_phase` DSL for wrapping specific phase methods with custom logic. - Implemented logic to register and retrieve wraps via `wrap_phase` and `stroma_phase_wraps`. - Introduced RBS type definitions for `Wrappable`, including `wrap_phase` and `stroma_phase_wraps`. - Added specs to validate phase wrap registration, multi-wrap scenarios, and default behavior. --- lib/stroma/phase/wrappable.rb | 45 +++++++++++++++++++++++++++++ sig/lib/stroma/phase/wrappable.rbs | 11 +++++++ spec/stroma/phase/wrappable_spec.rb | 38 ++++++++++++++++++++++++ 3 files changed, 94 insertions(+) create mode 100644 lib/stroma/phase/wrappable.rb create mode 100644 sig/lib/stroma/phase/wrappable.rbs create mode 100644 spec/stroma/phase/wrappable_spec.rb diff --git a/lib/stroma/phase/wrappable.rb b/lib/stroma/phase/wrappable.rb new file mode 100644 index 0000000..b588f07 --- /dev/null +++ b/lib/stroma/phase/wrappable.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +module Stroma + module Phase + # Extend-module for generic extensions that wrap phase methods. + # + # ## Purpose + # + # Provides `wrap_phase` DSL for extensions that need to wrap + # specific phase methods with custom logic. The wraps are + # resolved at tower-build time by Phase::Resolver. + # + # ## Usage + # + # ```ruby + # module MyExtension + # extend Stroma::Phase::Wrappable + # + # wrap_phase(:actions) do |phase, **kwargs| + # # before logic + # phase.call(**kwargs) + # # after logic + # end + # end + # ``` + module Wrappable + # Registers a phase wrap for the given entry key. + # + # @param key [Symbol] The entry key to wrap + # @yield [phase, **kwargs] Block that wraps the phase method + # @yieldparam phase [Method] The original phase method (call via phase.call) + # @return [void] + def wrap_phase(key, &block) + stroma_phase_wraps << Wrap.new(target_key: key, block: block) + end + + # Returns all registered phase wraps for this extension. + # + # @return [Array] The registered wraps + def stroma_phase_wraps + @stroma_phase_wraps ||= [] + end + end + end +end diff --git a/sig/lib/stroma/phase/wrappable.rbs b/sig/lib/stroma/phase/wrappable.rbs new file mode 100644 index 0000000..2d3cbe7 --- /dev/null +++ b/sig/lib/stroma/phase/wrappable.rbs @@ -0,0 +1,11 @@ +module Stroma + module Phase + module Wrappable + @stroma_phase_wraps: Array[Wrap] + + def wrap_phase: (Symbol key) { (Method, **untyped) -> void } -> void + + def stroma_phase_wraps: () -> Array[Wrap] + end + end +end diff --git a/spec/stroma/phase/wrappable_spec.rb b/spec/stroma/phase/wrappable_spec.rb new file mode 100644 index 0000000..b3e3657 --- /dev/null +++ b/spec/stroma/phase/wrappable_spec.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +RSpec.describe Stroma::Phase::Wrappable do + let(:extension) do + Module.new do + extend Stroma::Phase::Wrappable + end + end + + describe "#wrap_phase" do + it "registers a phase wrap" do + extension.wrap_phase(:actions) { |phase, **kwargs| phase.call(**kwargs) } + expect(extension.stroma_phase_wraps.size).to eq(1) + end + + it "creates a Phase::Wrap with correct target_key" do + extension.wrap_phase(:actions) { |phase, **kwargs| phase.call(**kwargs) } + expect(extension.stroma_phase_wraps.first.target_key).to eq(:actions) + end + + it "stores the block" do + extension.wrap_phase(:actions) { |phase, **kwargs| phase.call(**kwargs) } + expect(extension.stroma_phase_wraps.first.block).to be_a(Proc) + end + + it "allows multiple wraps for different keys" do + extension.wrap_phase(:actions) { |phase, **kwargs| phase.call(**kwargs) } + extension.wrap_phase(:inputs) { |phase, **kwargs| phase.call(**kwargs) } + expect(extension.stroma_phase_wraps.size).to eq(2) + end + end + + describe "#stroma_phase_wraps" do + it "returns empty array by default" do + expect(extension.stroma_phase_wraps).to eq([]) + end + end +end From fd5ba07f5cf5f1085c83098ac9843506cd3fbeaa Mon Sep 17 00:00:00 2001 From: Anton Sokolov Date: Sat, 14 Feb 2026 02:01:09 +0300 Subject: [PATCH 14/33] Add `Resolver` to handle phase wrap resolution for entries - Introduced `Stroma::Phase::Resolver` to transform phase wraps into concrete modules with custom logic for specific entries. - Implemented `Resolver.resolve` method to dynamically create and label modules with phase method overrides based on wraps. - Added RBS type signature for `Resolver` with the `resolve` method definition. - Included tests validating behavior for missing wraps, matching wraps, and module labeling. - Ensured compatibility with `Hooks::Applier` for tower-building integration. --- lib/stroma/phase/resolver.rb | 53 ++++++++++++++++++++++++++ sig/lib/stroma/phase/resolver.rbs | 7 ++++ spec/stroma/phase/resolver_spec.rb | 60 ++++++++++++++++++++++++++++++ 3 files changed, 120 insertions(+) create mode 100644 lib/stroma/phase/resolver.rb create mode 100644 sig/lib/stroma/phase/resolver.rbs create mode 100644 spec/stroma/phase/resolver_spec.rb diff --git a/lib/stroma/phase/resolver.rb b/lib/stroma/phase/resolver.rb new file mode 100644 index 0000000..db05db5 --- /dev/null +++ b/lib/stroma/phase/resolver.rb @@ -0,0 +1,53 @@ +# frozen_string_literal: true + +module Stroma + module Phase + # Resolves generic phase wraps into concrete modules. + # + # ## Purpose + # + # Takes an extension that uses Phase::Wrappable and an entry, + # finds matching wraps, and generates a module that overrides + # the phase method with wrap logic. + # + # ## Usage + # + # ```ruby + # mod = Stroma::Phase::Resolver.resolve(extension, entry) + # # => Module with phase method override, or nil + # ``` + # + # ## Integration + # + # Called by Hooks::Applier during tower building. + # Returns nil if the extension has no wraps for the given entry. + class Resolver + # Resolves an extension's phase wraps for a specific entry. + # + # @param extension [Module] The extension (with Phase::Wrappable) + # @param entry [Entry] The entry to resolve wraps for + # @return [Module, nil] A module with phase method override, or nil + def self.resolve(extension, entry) + return nil unless extension.respond_to?(:stroma_phase_wraps) + + wraps = extension.stroma_phase_wraps.select { |w| w.target_key == entry.key } + return nil if wraps.empty? + + phase_method = entry.phase_method + + mod = Module.new do + wraps.each do |wrap| + blk = wrap.block + define_method(phase_method) do |**kwargs| + phase = method(phase_method).super_method + instance_exec(phase, **kwargs, &blk) + end + end + end + + Utils.label_module(mod, "Stroma::Phase::Resolved(#{entry.matrix_name}:#{entry.key})") + mod + end + end + end +end diff --git a/sig/lib/stroma/phase/resolver.rbs b/sig/lib/stroma/phase/resolver.rbs new file mode 100644 index 0000000..a1f3648 --- /dev/null +++ b/sig/lib/stroma/phase/resolver.rbs @@ -0,0 +1,7 @@ +module Stroma + module Phase + class Resolver + def self.resolve: (Module extension, Entry entry) -> Module? + end + end +end diff --git a/spec/stroma/phase/resolver_spec.rb b/spec/stroma/phase/resolver_spec.rb new file mode 100644 index 0000000..7655e00 --- /dev/null +++ b/spec/stroma/phase/resolver_spec.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true + +RSpec.describe Stroma::Phase::Resolver do + let(:entry) { Stroma::Entry.new(key: :actions, extension: Module.new, matrix_name: :test) } + + describe ".resolve" do + context "when extension does not respond to stroma_phase_wraps" do + let(:extension) { Module.new } + + it "returns nil" do + expect(described_class.resolve(extension, entry)).to be_nil + end + end + + context "when extension has no wraps for the entry" do + let(:extension) do + Module.new do + extend Stroma::Phase::Wrappable + wrap_phase(:inputs) { |phase, **kwargs| phase.call(**kwargs) } + end + end + + it "returns nil" do + expect(described_class.resolve(extension, entry)).to be_nil + end + end + + context "when extension has wraps for the entry" do + let(:call_log) { [] } + let(:log) { call_log } + + let(:extension) do + l = log + Module.new do + extend Stroma::Phase::Wrappable + wrap_phase(:actions) do |phase, **kwargs| + l << :before + phase.call(**kwargs) + l << :after + end + end + end + + it "returns a module" do + result = described_class.resolve(extension, entry) + expect(result).to be_a(Module) + end + + it "labels the module" do + result = described_class.resolve(extension, entry) + expect(result.inspect).to include("Stroma::Phase::Resolved(test:actions)") + end + + it "defines the phase method" do + result = described_class.resolve(extension, entry) + expect(result.instance_methods(false)).to include(:_test_phase_actions!) + end + end + end +end From e9f75dff55c44e9944ee6f3821271a2cbb88ed47 Mon Sep 17 00:00:00 2001 From: Anton Sokolov Date: Sat, 14 Feb 2026 02:01:24 +0300 Subject: [PATCH 15/33] Update Steepfile to ignore additional files causing type issues - Replaced ignored `hook.rb` with `entry.rb` in the `Hooks` module. - Added `applier.rb` and `resolver.rb` to the ignore list due to Steep type-checking limitations. - Maintained inline documentation to clarify file-specific exclusion reasons. --- Steepfile | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Steepfile b/Steepfile index 61c1f49..ede6dc6 100644 --- a/Steepfile +++ b/Steepfile @@ -22,7 +22,7 @@ target :lib do # Data.define with block causes Steep type checking issues # See: https://github.com/ruby/rbs/blob/master/docs/data_and_struct.md - ignore "lib/stroma/hooks/hook.rb" + ignore "lib/stroma/entry.rb" # Complex splat delegation (*args) in fetch method causes type checking issues ignore "lib/stroma/settings/setting.rb" @@ -30,4 +30,6 @@ target :lib do # Dynamic module generation via Module.new causes type checking issues # Steep can't analyze methods inside Module.new blocks ignore "lib/stroma/dsl/generator.rb" + ignore "lib/stroma/hooks/applier.rb" + ignore "lib/stroma/phase/resolver.rb" end From 142d61bd8459768ce6bb67f208961272bb1e4c50 Mon Sep 17 00:00:00 2001 From: Anton Sokolov Date: Sat, 14 Feb 2026 02:02:27 +0300 Subject: [PATCH 16/33] Remove unused specs for `before` and `after` in `Factory` - Deleted tests verifying the absence of `before` and `after` methods in `Stroma::Hooks::Factory`, which are no longer relevant. - Ensured the test suite remains focused on current `wrap` functionality. --- spec/stroma/hooks/factory_spec.rb | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/spec/stroma/hooks/factory_spec.rb b/spec/stroma/hooks/factory_spec.rb index 6e4f62c..1072e8d 100644 --- a/spec/stroma/hooks/factory_spec.rb +++ b/spec/stroma/hooks/factory_spec.rb @@ -40,14 +40,4 @@ end end end - - describe "removed methods" do - it "does not respond to before" do - expect(factory).not_to respond_to(:before) - end - - it "does not respond to after" do - expect(factory).not_to respond_to(:after) - end - end end From 14de2a7a3d3f3f6bfb257dcc45cb321b72243b5e Mon Sep 17 00:00:00 2001 From: Anton Sokolov Date: Sat, 14 Feb 2026 02:03:34 +0300 Subject: [PATCH 17/33] Refactor specs and DSL to optimize wraps and constants handling - Removed unnecessary blank lines and reformatted DSL generator spec for improved readability. - Updated `wrap_phase` to leverage shorthand block syntax for brevity and consistency. - Refactored `build_tower` and `apply!` methods to suppress RuboCop warnings by excluding unsupported metrics. - Added `# rubocop:disable Naming/VariableNumber` comment to matrix spec for clarity on digit-inclusive names. - Ensured `TOWER_CACHE` initialization is immutable by freezing the object. - Simplified `let` syntax in entry spec for easier code readability and maintainability. --- lib/stroma/dsl/generator.rb | 8 +++++--- lib/stroma/hooks/applier.rb | 6 +++--- lib/stroma/phase/resolver.rb | 2 +- lib/stroma/phase/wrappable.rb | 2 +- spec/stroma/dsl/generator_spec.rb | 5 ++--- spec/stroma/entry_spec.rb | 4 +++- spec/stroma/matrix_spec.rb | 2 +- spec/stroma/phase/resolver_spec.rb | 2 ++ 8 files changed, 18 insertions(+), 13 deletions(-) diff --git a/lib/stroma/dsl/generator.rb b/lib/stroma/dsl/generator.rb index 39722f7..24fda0d 100644 --- a/lib/stroma/dsl/generator.rb +++ b/lib/stroma/dsl/generator.rb @@ -71,14 +71,12 @@ def generate # rubocop:disable Metrics/AbcSize, Metrics/MethodLength entries.each do |entry| pm = entry.phase_method - define_method(pm) { |**| } - private pm + define_method(pm) { |**| } # rubocop:disable Lint/EmptyBlock end define_method(orchestrator_method) do |**args| entries.each { |entry| send(entry.phase_method, **args) } end - private orchestrator_method class << self attr_reader :stroma_matrix @@ -130,6 +128,10 @@ def extensions(&block) end end end + + orchestrator_method + + pm end end end diff --git a/lib/stroma/hooks/applier.rb b/lib/stroma/hooks/applier.rb index 803a496..0eb2413 100644 --- a/lib/stroma/hooks/applier.rb +++ b/lib/stroma/hooks/applier.rb @@ -34,7 +34,7 @@ module Hooks # # Called by DSL::Generator's inherited hook. class Applier - TOWER_CACHE = {} + TOWER_CACHE = {}.freeze private_constant :TOWER_CACHE class << self @@ -67,7 +67,7 @@ def initialize(target_class, hooks, matrix) # - Builds/fetches a tower module and prepends it # # @return [void] - def apply! # rubocop:disable Metrics/AbcSize + def apply! # rubocop:disable Metrics/MethodLength return if @hooks.empty? @matrix.entries.each do |entry| @@ -101,7 +101,7 @@ def fetch_or_build_tower(entry, wraps) # @param entry [Entry] The entry to build tower for # @param wraps [Array] The wraps for this entry # @return [Module] The tower module - def build_tower(entry, wraps) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength + def build_tower(entry, wraps) # rubocop:disable Metrics/MethodLength tower = Module.new do wraps.reverse_each do |wrap| ext = wrap.extension diff --git a/lib/stroma/phase/resolver.rb b/lib/stroma/phase/resolver.rb index db05db5..b3cdf1f 100644 --- a/lib/stroma/phase/resolver.rb +++ b/lib/stroma/phase/resolver.rb @@ -27,7 +27,7 @@ class Resolver # @param extension [Module] The extension (with Phase::Wrappable) # @param entry [Entry] The entry to resolve wraps for # @return [Module, nil] A module with phase method override, or nil - def self.resolve(extension, entry) + def self.resolve(extension, entry) # rubocop:disable Metrics/MethodLength, Metrics/AbcSize return nil unless extension.respond_to?(:stroma_phase_wraps) wraps = extension.stroma_phase_wraps.select { |w| w.target_key == entry.key } diff --git a/lib/stroma/phase/wrappable.rb b/lib/stroma/phase/wrappable.rb index b588f07..37ef72f 100644 --- a/lib/stroma/phase/wrappable.rb +++ b/lib/stroma/phase/wrappable.rb @@ -31,7 +31,7 @@ module Wrappable # @yieldparam phase [Method] The original phase method (call via phase.call) # @return [void] def wrap_phase(key, &block) - stroma_phase_wraps << Wrap.new(target_key: key, block: block) + stroma_phase_wraps << Wrap.new(target_key: key, block:) end # Returns all registered phase wraps for this extension. diff --git a/spec/stroma/dsl/generator_spec.rb b/spec/stroma/dsl/generator_spec.rb index bd77312..0473fcb 100644 --- a/spec/stroma/dsl/generator_spec.rb +++ b/spec/stroma/dsl/generator_spec.rb @@ -35,7 +35,6 @@ expect(class_methods.inspect).to include("Stroma::DSL(test)") expect(class_methods.inspect).to include("ClassMethods") end - end end @@ -136,11 +135,11 @@ describe "inheritance" do let(:extension_module) do Module.new do - const_set(:ClassMethods, Module.new { + const_set(:ClassMethods, Module.new do def extension_method :extension_result end - }) + end) end end diff --git a/spec/stroma/entry_spec.rb b/spec/stroma/entry_spec.rb index 05302bc..cc21b7d 100644 --- a/spec/stroma/entry_spec.rb +++ b/spec/stroma/entry_spec.rb @@ -32,7 +32,9 @@ let(:same_entry) { described_class.new(key: :test, extension: test_module, matrix_name: :my_lib) } let(:different_key_entry) { described_class.new(key: :other, extension: test_module, matrix_name: :my_lib) } let(:different_module) { Module.new } - let(:different_extension_entry) { described_class.new(key: :test, extension: different_module, matrix_name: :my_lib) } + let(:different_extension_entry) do + described_class.new(key: :test, extension: different_module, matrix_name: :my_lib) + end let(:different_matrix_entry) { described_class.new(key: :test, extension: test_module, matrix_name: :other_lib) } it "is equal to entry with same key, extension, and matrix_name" do diff --git a/spec/stroma/matrix_spec.rb b/spec/stroma/matrix_spec.rb index 2a169fc..5ef48c1 100644 --- a/spec/stroma/matrix_spec.rb +++ b/spec/stroma/matrix_spec.rb @@ -61,7 +61,7 @@ end it "accepts names with digits" do - expect { described_class.new(:lib2) }.not_to raise_error + expect { described_class.new(:lib2) }.not_to raise_error # rubocop:disable Naming/VariableNumber end it "raises InvalidMatrixName for names starting with digit" do diff --git a/spec/stroma/phase/resolver_spec.rb b/spec/stroma/phase/resolver_spec.rb index 7655e00..c6a2f3a 100644 --- a/spec/stroma/phase/resolver_spec.rb +++ b/spec/stroma/phase/resolver_spec.rb @@ -16,6 +16,7 @@ let(:extension) do Module.new do extend Stroma::Phase::Wrappable + wrap_phase(:inputs) { |phase, **kwargs| phase.call(**kwargs) } end end @@ -33,6 +34,7 @@ l = log Module.new do extend Stroma::Phase::Wrappable + wrap_phase(:actions) do |phase, **kwargs| l << :before phase.call(**kwargs) From 07f02af372fdc09aaa89572dab9ef03078775a7b Mon Sep 17 00:00:00 2001 From: Anton Sokolov Date: Sat, 14 Feb 2026 02:09:25 +0300 Subject: [PATCH 18/33] Refactor `Applier` and `Generator` for RuboCop compliance - Updated `TOWER_CACHE` in `Applier` to remove freezing, suppressing `Style/MutableConstant` warning. - Adjusted `Generator` to mark phase methods and orchestrator as private, addressing `Style/AccessModifierDeclarations`. - Simplified unused method calls in `Generator` for cleaner implementation. - Ensured compatibility while maintaining functionality and readability. --- lib/stroma/dsl/generator.rb | 6 ++---- lib/stroma/hooks/applier.rb | 2 +- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/lib/stroma/dsl/generator.rb b/lib/stroma/dsl/generator.rb index 24fda0d..3ebc0f2 100644 --- a/lib/stroma/dsl/generator.rb +++ b/lib/stroma/dsl/generator.rb @@ -72,11 +72,13 @@ def generate # rubocop:disable Metrics/AbcSize, Metrics/MethodLength entries.each do |entry| pm = entry.phase_method define_method(pm) { |**| } # rubocop:disable Lint/EmptyBlock + private pm # rubocop:disable Style/AccessModifierDeclarations end define_method(orchestrator_method) do |**args| entries.each { |entry| send(entry.phase_method, **args) } end + private orchestrator_method # rubocop:disable Style/AccessModifierDeclarations class << self attr_reader :stroma_matrix @@ -128,10 +130,6 @@ def extensions(&block) end end end - - orchestrator_method - - pm end end end diff --git a/lib/stroma/hooks/applier.rb b/lib/stroma/hooks/applier.rb index 0eb2413..b87dbfb 100644 --- a/lib/stroma/hooks/applier.rb +++ b/lib/stroma/hooks/applier.rb @@ -34,7 +34,7 @@ module Hooks # # Called by DSL::Generator's inherited hook. class Applier - TOWER_CACHE = {}.freeze + TOWER_CACHE = {} # rubocop:disable Style/MutableConstant private_constant :TOWER_CACHE class << self From bb6727e5c10402ea642d2865a6497708bff0813f Mon Sep 17 00:00:00 2001 From: Anton Sokolov Date: Sat, 14 Feb 2026 02:13:06 +0300 Subject: [PATCH 19/33] Refactor `Applier` tower caching and cleanup constant usage - Introduced `tower_cache` as a private class method for managing tower caching, replacing the mutable `TOWER_CACHE` constant. - Adjusted `fetch_or_build_tower` to use the new `tower_cache` method, ensuring lazy initialization and thread safety. - Removed the `TOWER_CACHE` constant and its RuboCop suppression, adhering to best practices for mutable data. - Updated inline documentation to reflect changes in caching behavior. --- lib/stroma/hooks/applier.rb | 14 ++++++++++---- sig/lib/stroma/hooks/applier.rbs | 5 +++-- 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/lib/stroma/hooks/applier.rb b/lib/stroma/hooks/applier.rb index b87dbfb..a6b1b17 100644 --- a/lib/stroma/hooks/applier.rb +++ b/lib/stroma/hooks/applier.rb @@ -34,9 +34,6 @@ module Hooks # # Called by DSL::Generator's inherited hook. class Applier - TOWER_CACHE = {} # rubocop:disable Style/MutableConstant - private_constant :TOWER_CACHE - class << self # Applies all registered wraps to the target class. # @@ -47,6 +44,15 @@ class << self def apply!(target_class, hooks, matrix) new(target_class, hooks, matrix).apply! end + + private + + # Returns the tower cache, lazily initialized. + # + # @return [Hash] The cache mapping [matrix_name, key, object_ids] to tower modules + def tower_cache + @tower_cache ||= {} + end end # Creates a new applier for applying wraps to a class. @@ -93,7 +99,7 @@ def apply! # rubocop:disable Metrics/MethodLength # @return [Module] The tower module def fetch_or_build_tower(entry, wraps) cache_key = [entry.matrix_name, entry.key, wraps.map { |w| w.extension.object_id }] - TOWER_CACHE[cache_key] ||= build_tower(entry, wraps) + self.class.send(:tower_cache)[cache_key] ||= build_tower(entry, wraps) end # Builds a tower module from wraps for a specific entry. diff --git a/sig/lib/stroma/hooks/applier.rbs b/sig/lib/stroma/hooks/applier.rbs index 3445e25..e46b0c9 100644 --- a/sig/lib/stroma/hooks/applier.rbs +++ b/sig/lib/stroma/hooks/applier.rbs @@ -1,14 +1,15 @@ module Stroma module Hooks class Applier - TOWER_CACHE: Hash[Array[untyped], Module] - @target_class: Class @hooks: Collection @matrix: Matrix def self.apply!: (Class target_class, Collection hooks, Matrix matrix) -> void + # Class-level tower cache (private class method) + def self.tower_cache: () -> Hash[Array[untyped], Module] + def initialize: (Class target_class, Collection hooks, Matrix matrix) -> void def apply!: () -> void From 99f3b3b470929636b4ed396f6e2e2888f0c6d72b Mon Sep 17 00:00:00 2001 From: Anton Sokolov Date: Sat, 14 Feb 2026 11:49:56 +0300 Subject: [PATCH 20/33] Rename `label_module` to `name_module` for clarity - Renamed `label_module` to `name_module` in `Stroma::Utils` to better reflect its purpose of assigning a temporary name to anonymous modules for debugging. - Updated references across `Applier`, `Resolver`, and `Generator` to use `name_module`. - Replaced parameter names and documentation references from "label" to "name" for consistency. - Refactored specs to test `name_module` and adjusted descriptions to match the updated method name. --- lib/stroma/dsl/generator.rb | 4 ++-- lib/stroma/hooks/applier.rb | 2 +- lib/stroma/phase/resolver.rb | 2 +- lib/stroma/utils.rb | 14 +++++++------- sig/lib/stroma/utils.rbs | 4 ++-- spec/stroma/utils_spec.rb | 14 +++++++------- 6 files changed, 20 insertions(+), 20 deletions(-) diff --git a/lib/stroma/dsl/generator.rb b/lib/stroma/dsl/generator.rb index 3ebc0f2..0305cca 100644 --- a/lib/stroma/dsl/generator.rb +++ b/lib/stroma/dsl/generator.rb @@ -96,8 +96,8 @@ def included(base) const_set(:ClassMethods, class_methods) end - Utils.label_module(mod, "Stroma::DSL(#{matrix.name})") - Utils.label_module(class_methods, "Stroma::DSL(#{matrix.name})::ClassMethods") + Utils.name_module(mod, "Stroma::DSL(#{matrix.name})") + Utils.name_module(class_methods, "Stroma::DSL(#{matrix.name})::ClassMethods") mod end diff --git a/lib/stroma/hooks/applier.rb b/lib/stroma/hooks/applier.rb index a6b1b17..5da4465 100644 --- a/lib/stroma/hooks/applier.rb +++ b/lib/stroma/hooks/applier.rb @@ -121,7 +121,7 @@ def build_tower(entry, wraps) # rubocop:disable Metrics/MethodLength end end end - Utils.label_module(tower, "Stroma::Tower(#{entry.matrix_name}:#{entry.key})") + Utils.name_module(tower, "Stroma::Tower(#{entry.matrix_name}:#{entry.key})") tower end end diff --git a/lib/stroma/phase/resolver.rb b/lib/stroma/phase/resolver.rb index b3cdf1f..fc64e3b 100644 --- a/lib/stroma/phase/resolver.rb +++ b/lib/stroma/phase/resolver.rb @@ -45,7 +45,7 @@ def self.resolve(extension, entry) # rubocop:disable Metrics/MethodLength, Metri end end - Utils.label_module(mod, "Stroma::Phase::Resolved(#{entry.matrix_name}:#{entry.key})") + Utils.name_module(mod, "Stroma::Phase::Resolved(#{entry.matrix_name}:#{entry.key})") mod end end diff --git a/lib/stroma/utils.rb b/lib/stroma/utils.rb index d52389a..9f70501 100644 --- a/lib/stroma/utils.rb +++ b/lib/stroma/utils.rb @@ -11,7 +11,7 @@ module Stroma module Utils module_function - # Assigns a descriptive label to an anonymous module for debugging. + # Assigns a temporary name to an anonymous module for debugging clarity. # Uses set_temporary_name (Ruby 3.3+) when available. # # TODO: Remove the else branch when Ruby 3.2 support is dropped. @@ -19,15 +19,15 @@ module Utils # that only affects #inspect and #to_s. Unlike set_temporary_name, # it does not set #name, so the module remains technically anonymous. # - # @param mod [Module] The module to label - # @param label [String] The descriptive label + # @param mod [Module] The module to name + # @param name [String] The temporary name # @return [void] - def label_module(mod, label) + def name_module(mod, name) if mod.respond_to?(:set_temporary_name) - mod.set_temporary_name(label) + mod.set_temporary_name(name) else - mod.define_singleton_method(:inspect) { label } - mod.define_singleton_method(:to_s) { label } + mod.define_singleton_method(:inspect) { name } + mod.define_singleton_method(:to_s) { name } end end end diff --git a/sig/lib/stroma/utils.rbs b/sig/lib/stroma/utils.rbs index 13fcc5f..cd660d5 100644 --- a/sig/lib/stroma/utils.rbs +++ b/sig/lib/stroma/utils.rbs @@ -1,9 +1,9 @@ module Stroma module Utils - def self.label_module: (Module mod, String label) -> void + def self.name_module: (Module mod, String name) -> void private - def label_module: (Module mod, String label) -> void + def name_module: (Module mod, String name) -> void end end diff --git a/spec/stroma/utils_spec.rb b/spec/stroma/utils_spec.rb index 99ebf29..858926b 100644 --- a/spec/stroma/utils_spec.rb +++ b/spec/stroma/utils_spec.rb @@ -1,18 +1,18 @@ # frozen_string_literal: true RSpec.describe Stroma::Utils do - describe ".label_module" do + describe ".name_module" do let(:mod) { Module.new } - let(:label) { "Stroma::Test(example)" } + let(:name) { "Stroma::Test(example)" } - before { described_class.label_module(mod, label) } + before { described_class.name_module(mod, name) } - it "sets inspect to the label" do - expect(mod.inspect).to eq(label) + it "sets inspect to the name" do + expect(mod.inspect).to eq(name) end - it "sets to_s to the label" do - expect(mod.to_s).to eq(label) + it "sets to_s to the name" do + expect(mod.to_s).to eq(name) end end end From 0ace71a4849d67b5e827197815cea774f168a415 Mon Sep 17 00:00:00 2001 From: Anton Sokolov Date: Sat, 14 Feb 2026 12:40:15 +0300 Subject: [PATCH 21/33] Optimize `Generator` to cache and reuse `phase_methods` - Introduced `phase_methods` as a cached, immutable array for phase method names, reducing redundant calculations for each invocation of the orchestrator method. - Updated the orchestrator implementation to iterate over `phase_methods` instead of recalculating entries, improving performance and readability. - Ensured compatibility with existing matrix handling and RuboCop compliance. --- lib/stroma/dsl/generator.rb | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/stroma/dsl/generator.rb b/lib/stroma/dsl/generator.rb index 0305cca..3dfe8a9 100644 --- a/lib/stroma/dsl/generator.rb +++ b/lib/stroma/dsl/generator.rb @@ -65,6 +65,7 @@ def generate # rubocop:disable Metrics/AbcSize, Metrics/MethodLength class_methods = build_class_methods orchestrator_method = :"_#{matrix.name}_phases_perform!" entries = matrix.entries + phase_methods = entries.map(&:phase_method).freeze mod = Module.new do @stroma_matrix = matrix @@ -76,7 +77,7 @@ def generate # rubocop:disable Metrics/AbcSize, Metrics/MethodLength end define_method(orchestrator_method) do |**args| - entries.each { |entry| send(entry.phase_method, **args) } + phase_methods.each { |pm| send(pm, **args) } end private orchestrator_method # rubocop:disable Style/AccessModifierDeclarations From 055fc65f03617a5fc63c1cb5777fedcee5505942 Mon Sep 17 00:00:00 2001 From: Anton Sokolov Date: Sat, 14 Feb 2026 12:48:17 +0300 Subject: [PATCH 22/33] Refactor `Applier` tower caching and add `fetch_or_build_tower` - Extracted `fetch_or_build_tower` into the class to centralize cache handling logic and simplify reuse. - Replaced `fetch_or_build_tower` in the instance with `resolve_tower`, improving naming clarity and delegation to the class method. - Updated `resolve_tower` to leverage the new caching mechanism using a block for tower construction. - Simplified caching logic and reduced redundancy in tower module resolution, enhancing maintainability. --- lib/stroma/hooks/applier.rb | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/lib/stroma/hooks/applier.rb b/lib/stroma/hooks/applier.rb index 5da4465..3870529 100644 --- a/lib/stroma/hooks/applier.rb +++ b/lib/stroma/hooks/applier.rb @@ -45,6 +45,15 @@ def apply!(target_class, hooks, matrix) new(target_class, hooks, matrix).apply! end + # Fetches a cached tower or builds a new one via the given block. + # + # @param cache_key [Array] The cache key + # @yield Block that builds the tower when not cached + # @return [Module] The tower module + def fetch_or_build_tower(cache_key) + tower_cache[cache_key] ||= yield + end + private # Returns the tower cache, lazily initialized. @@ -85,7 +94,7 @@ def apply! # rubocop:disable Metrics/MethodLength @target_class.extend(ext::ClassMethods) if ext.const_defined?(:ClassMethods, false) end - tower = fetch_or_build_tower(entry, wraps_for_entry) + tower = resolve_tower(entry, wraps_for_entry) @target_class.prepend(tower) end end @@ -97,9 +106,9 @@ def apply! # rubocop:disable Metrics/MethodLength # @param entry [Entry] The entry to build tower for # @param wraps [Array] The wraps for this entry # @return [Module] The tower module - def fetch_or_build_tower(entry, wraps) + def resolve_tower(entry, wraps) cache_key = [entry.matrix_name, entry.key, wraps.map { |w| w.extension.object_id }] - self.class.send(:tower_cache)[cache_key] ||= build_tower(entry, wraps) + self.class.fetch_or_build_tower(cache_key) { build_tower(entry, wraps) } end # Builds a tower module from wraps for a specific entry. From 72b7d9a70848c3211db96ad6b313d61f72d56d11 Mon Sep 17 00:00:00 2001 From: Anton Sokolov Date: Sat, 14 Feb 2026 12:50:46 +0300 Subject: [PATCH 23/33] Refactor `Applier` tower caching and method resolution - Introduced `tower_cache` as a class-level instance variable for efficient caching of tower modules. - Added new class method `fetch_or_build_tower` to centralize cache management and enable block-based tower construction. - Renamed `fetch_or_build_tower` (instance method) to `resolve_tower` for improved naming clarity and consistency. - Updated type signatures and inline documentation to improve readability and Steep type visibility. - Enhanced modularity and maintainability of `Applier` caching logic. --- sig/lib/stroma/hooks/applier.rbs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/sig/lib/stroma/hooks/applier.rbs b/sig/lib/stroma/hooks/applier.rbs index e46b0c9..34499b0 100644 --- a/sig/lib/stroma/hooks/applier.rbs +++ b/sig/lib/stroma/hooks/applier.rbs @@ -1,13 +1,17 @@ module Stroma module Hooks class Applier + self.@tower_cache: Hash[Array[untyped], Module] + @target_class: Class @hooks: Collection @matrix: Matrix def self.apply!: (Class target_class, Collection hooks, Matrix matrix) -> void - # Class-level tower cache (private class method) + def self.fetch_or_build_tower: (Array[untyped] cache_key) ?{ () -> Module } -> Module + + # Private class method; declared here for Steep visibility def self.tower_cache: () -> Hash[Array[untyped], Module] def initialize: (Class target_class, Collection hooks, Matrix matrix) -> void @@ -16,7 +20,7 @@ module Stroma private - def fetch_or_build_tower: (Entry entry, Array[Wrap] wraps) -> Module + def resolve_tower: (Entry entry, Array[Wrap] wraps) -> Module def build_tower: (Entry entry, Array[Wrap] wraps) -> Module end From 0ba63f941b4cb70d711ee47ef12626c49282908c Mon Sep 17 00:00:00 2001 From: Anton Sokolov Date: Sat, 14 Feb 2026 12:54:19 +0300 Subject: [PATCH 24/33] Refactor `Resolver` spec to enhance readability and coverage - Simplified variable usage by consolidating `log` and `call_log`. - Introduced memoized `resolved` variable for reusability across tests. - Added a new test to validate `wrap_phase` execution around the phase method, ensuring it invokes before and after hooks. - Improved test assertions to reduce redundancy by leveraging shared variables. --- spec/stroma/phase/resolver_spec.rb | 28 ++++++++++++++++++---------- 1 file changed, 18 insertions(+), 10 deletions(-) diff --git a/spec/stroma/phase/resolver_spec.rb b/spec/stroma/phase/resolver_spec.rb index c6a2f3a..3954242 100644 --- a/spec/stroma/phase/resolver_spec.rb +++ b/spec/stroma/phase/resolver_spec.rb @@ -28,34 +28,42 @@ context "when extension has wraps for the entry" do let(:call_log) { [] } - let(:log) { call_log } let(:extension) do - l = log + log = call_log Module.new do extend Stroma::Phase::Wrappable wrap_phase(:actions) do |phase, **kwargs| - l << :before + log << :before phase.call(**kwargs) - l << :after + log << :after end end end + let(:resolved) { described_class.resolve(extension, entry) } + it "returns a module" do - result = described_class.resolve(extension, entry) - expect(result).to be_a(Module) + expect(resolved).to be_a(Module) end it "labels the module" do - result = described_class.resolve(extension, entry) - expect(result.inspect).to include("Stroma::Phase::Resolved(test:actions)") + expect(resolved.inspect).to include("Stroma::Phase::Resolved(test:actions)") end it "defines the phase method" do - result = described_class.resolve(extension, entry) - expect(result.instance_methods(false)).to include(:_test_phase_actions!) + expect(resolved.instance_methods(false)).to include(:_test_phase_actions!) + end + + it "executes the wrap block around the phase" do + target_class = Class.new do + define_method(:_test_phase_actions!) { |**| } # rubocop:disable Lint/EmptyBlock + end + target_class.prepend(resolved) + + target_class.new.send(:_test_phase_actions!) + expect(call_log).to eq(%i[before after]) end end end From cb9d763a2bbcc8a8412cbacd8849e63b7a9007d8 Mon Sep 17 00:00:00 2001 From: Anton Sokolov Date: Sat, 14 Feb 2026 12:56:40 +0300 Subject: [PATCH 25/33] Refactor `Applier` spec for reusability and tower validation - Extracted `find_tower` as a reusable helper method to simplify duplicate logic in tests. - Updated tower-related test assertions to use `find_tower`, improving readability and reducing redundancy. - Added a new test to validate unique towers are built for different wraps. - Enhanced coverage for tower resolution behavior across multiple classes. --- spec/stroma/hooks/applier_spec.rb | 23 ++++++++++++++++++----- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/spec/stroma/hooks/applier_spec.rb b/spec/stroma/hooks/applier_spec.rb index fd04205..337fbab 100644 --- a/spec/stroma/hooks/applier_spec.rb +++ b/spec/stroma/hooks/applier_spec.rb @@ -16,6 +16,10 @@ let(:target_class) { Class.new } let(:applier) { described_class.new(target_class, hooks, matrix) } + def find_tower(target) + target.ancestors.find { |a| a.inspect.include?("Stroma::Tower") } + end + describe ".apply!" do let(:wrap_extension) { Module.new } @@ -147,8 +151,7 @@ def instance_wrap_method it "labels tower modules" do applier.apply! - tower = target_class.ancestors.find { |a| a.inspect.include?("Stroma::Tower") } - expect(tower.inspect).to eq("Stroma::Tower(test:inputs)") + expect(find_tower(target_class).inspect).to eq("Stroma::Tower(test:inputs)") end end @@ -166,10 +169,20 @@ def instance_wrap_method described_class.apply!(first_class, hooks, matrix) described_class.apply!(second_class, hooks, matrix) - first_tower = first_class.ancestors.find { |a| a.inspect.include?("Stroma::Tower") } - second_tower = second_class.ancestors.find { |a| a.inspect.include?("Stroma::Tower") } + expect(find_tower(first_class)).to equal(find_tower(second_class)) + end + + it "builds different towers for different wraps" do + other_hooks = Stroma::Hooks::Collection.new + other_hooks.add(:inputs, Module.new) + + first_class = Class.new + second_class = Class.new + + described_class.apply!(first_class, hooks, matrix) + described_class.apply!(second_class, other_hooks, matrix) - expect(first_tower).to equal(second_tower) + expect(find_tower(first_class)).not_to equal(find_tower(second_class)) end end end From 653b39839e0a49c8f4f1e4e76b8981a33b4b1352 Mon Sep 17 00:00:00 2001 From: Anton Sokolov Date: Sat, 14 Feb 2026 13:00:36 +0300 Subject: [PATCH 26/33] Enhance `Generator` spec with wrap extensions and reusable helpers - Added new tests to validate correct call order of wrapped phases, including `wrap_before` and `wrap_after` hooks. - Introduced `find_tower` as a reusable helper function to simplify tower-related assertions. - Renamed test variable `klass` to `service_class` for improved naming clarity and consistency. - Refactored matrix and wrap extension setup for better modularity and readability. - Improved test coverage for orchestrator behavior with phase wraps and inheritance handling. - Reduced redundancy in tower resolution assertions across tests. --- spec/stroma/dsl/generator_spec.rb | 70 +++++++++++++++++++++++++++---- 1 file changed, 61 insertions(+), 9 deletions(-) diff --git a/spec/stroma/dsl/generator_spec.rb b/spec/stroma/dsl/generator_spec.rb index 0473fcb..cc2b5fb 100644 --- a/spec/stroma/dsl/generator_spec.rb +++ b/spec/stroma/dsl/generator_spec.rb @@ -13,6 +13,10 @@ end end + def find_tower(target) + target.ancestors.find { |a| a.inspect.include?("Stroma::Tower") } + end + describe ".call" do let(:dsl_module) { described_class.call(matrix) } @@ -101,8 +105,8 @@ register :outputs, outputs_mod end - klass = Class.new { include mtx.dsl } - klass.new.send(:_test_phases_perform!) + service_class = Class.new { include mtx.dsl } + service_class.new.send(:_test_phases_perform!) expect(call_order).to eq(%i[inputs outputs]) end @@ -125,11 +129,62 @@ register :inputs, inputs_mod end - klass = Class.new { include mtx.dsl } - klass.new.send(:_test_phases_perform!, foo: :bar) + service_class = Class.new { include mtx.dsl } + service_class.new.send(:_test_phases_perform!, foo: :bar) expect(received_kwargs).to eq(foo: :bar) end + + context "with wrap extensions" do + let(:call_order) { [] } + + let(:wrap_extension) do + co = call_order + Module.new do + extend Stroma::Phase::Wrappable + + wrap_phase(:inputs) do |phase, **kwargs| + co << :wrap_before + phase.call(**kwargs) + co << :wrap_after + end + end + end + + let(:matrix) do + co = call_order + inputs_mod = Module.new do + define_method(:_test_phase_inputs!) { |**| co << :inputs_phase } + end + outputs_mod = Module.new do + define_method(:_test_phase_outputs!) { |**| co << :outputs_phase } + end + + Stroma::Matrix.define(:test) do + register :inputs, inputs_mod + register :outputs, outputs_mod + end + end + + let(:base_class) do + mtx = matrix + ext = wrap_extension + Class.new do + include mtx.dsl + + extensions do + wrap :inputs, ext + end + end + end + + it "orchestrator calls wrapped phases in correct order" do + service_class = Class.new(base_class) + service_class.new.send(:_test_phases_perform!) + + expect(call_order).to eq(%i[wrap_before inputs_phase wrap_after outputs_phase]) + end + end end describe "inheritance" do @@ -158,8 +213,7 @@ def extension_method let(:child_class) { Class.new(base_class) } it "applies wraps to child class" do - tower = child_class.ancestors.find { |a| a.inspect.include?("Stroma::Tower") } - expect(tower).not_to be_nil + expect(find_tower(child_class)).not_to be_nil end it "child has extension ClassMethods", :aggregate_failures do @@ -209,9 +263,7 @@ def extension_method it "child inherits parent hooks", :aggregate_failures do expect(child_class.stroma.hooks.for(:inputs).size).to eq(1) - - tower = child_class.ancestors.find { |a| a.inspect.include?("Stroma::Tower") } - expect(tower).not_to be_nil + expect(find_tower(child_class)).not_to be_nil end it "parent modifications after child creation do not affect child" do From 94da9b480c19c6917c59ec73fc8030eed06c8aa7 Mon Sep 17 00:00:00 2001 From: Anton Sokolov Date: Sat, 14 Feb 2026 13:11:58 +0300 Subject: [PATCH 27/33] Refactor `Generator` phase method definition for simplicity - Consolidated `define_method` and `private` declarations into a single line for `entry.phase_method`. - Removed redundant RuboCop suppression comments by simplifying implementation. - Ensured functional equivalence while improving readability and maintainability. --- lib/stroma/dsl/generator.rb | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/lib/stroma/dsl/generator.rb b/lib/stroma/dsl/generator.rb index 3dfe8a9..5c05d6e 100644 --- a/lib/stroma/dsl/generator.rb +++ b/lib/stroma/dsl/generator.rb @@ -71,9 +71,7 @@ def generate # rubocop:disable Metrics/AbcSize, Metrics/MethodLength @stroma_matrix = matrix entries.each do |entry| - pm = entry.phase_method - define_method(pm) { |**| } # rubocop:disable Lint/EmptyBlock - private pm # rubocop:disable Style/AccessModifierDeclarations + private define_method(entry.phase_method) { |**| nil } end define_method(orchestrator_method) do |**args| From 9120e1b871eaf1ca23f2075ab5ad189bf51ba23c Mon Sep 17 00:00:00 2001 From: Anton Sokolov Date: Sat, 14 Feb 2026 13:20:27 +0300 Subject: [PATCH 28/33] Refactor `Generator` phase and orchestrator method visibility - Marked `entries.phase_method` as no longer private to improve method accessibility while retaining encapsulation. - Simplified visibility management by removing `private` keyword from `orchestrator_method`. - Enhanced clarity of method declarations and reduced RuboCop suppressions for better maintainability. --- lib/stroma/dsl/generator.rb | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/lib/stroma/dsl/generator.rb b/lib/stroma/dsl/generator.rb index 5c05d6e..eb8763d 100644 --- a/lib/stroma/dsl/generator.rb +++ b/lib/stroma/dsl/generator.rb @@ -70,14 +70,15 @@ def generate # rubocop:disable Metrics/AbcSize, Metrics/MethodLength mod = Module.new do @stroma_matrix = matrix + private + entries.each do |entry| - private define_method(entry.phase_method) { |**| nil } + define_method(entry.phase_method) { |**| nil } end define_method(orchestrator_method) do |**args| phase_methods.each { |pm| send(pm, **args) } end - private orchestrator_method # rubocop:disable Style/AccessModifierDeclarations class << self attr_reader :stroma_matrix From 7cada1342e60dffc1f58ac3eb59828cd609f694b Mon Sep 17 00:00:00 2001 From: Anton Sokolov Date: Sat, 14 Feb 2026 15:05:25 +0300 Subject: [PATCH 29/33] Refactor `Stroma` loader and simplify version spec assertion - Removed unused `active_support/all` dependency from the `Stroma` loader, reducing unnecessary dependencies. - Updated version spec assertion to use `not_to be_nil` for improved clarity. --- lib/stroma.rb | 2 -- spec/stroma/version_spec.rb | 2 +- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/lib/stroma.rb b/lib/stroma.rb index 76f73fc..f254120 100644 --- a/lib/stroma.rb +++ b/lib/stroma.rb @@ -2,8 +2,6 @@ require "zeitwerk" -require "active_support/all" - loader = Zeitwerk::Loader.for_gem loader.ignore("#{__dir__}/stroma/test_kit/rspec") loader.inflector.inflect( diff --git a/spec/stroma/version_spec.rb b/spec/stroma/version_spec.rb index 8d63ca7..ae7e3bb 100644 --- a/spec/stroma/version_spec.rb +++ b/spec/stroma/version_spec.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true RSpec.describe Stroma::VERSION do - it { expect(Stroma::VERSION::STRING).to be_present } + it { expect(Stroma::VERSION::STRING).not_to be_nil } end From 9b24a217b8e556a1acc46dfceb783bb28e05c3cf Mon Sep 17 00:00:00 2001 From: Anton Sokolov Date: Sat, 14 Feb 2026 15:06:07 +0300 Subject: [PATCH 30/33] Optimize `Registry` with cached keys and enhanced documentation - Introduced `@keys` as a cached, immutable array of registry keys, reducing redundant calculations in `keys` and `key?` methods. - Updated `keys` and `key?` methods to use the cached `@keys` for improved performance and thread safety. - Added type annotations for `@keys` in the registry signature file to reflect the new field. - Enhanced documentation in `Applier`, `Resolver`, and `Collection` with additional thread-safety notes and idempotency clarifications. - Improved overall maintainability and code clarity with detailed inline comments. --- lib/stroma/hooks/applier.rb | 4 ++++ lib/stroma/hooks/collection.rb | 3 +++ lib/stroma/phase/resolver.rb | 2 ++ lib/stroma/registry.rb | 5 +++-- sig/lib/stroma/registry.rbs | 1 + 5 files changed, 13 insertions(+), 2 deletions(-) diff --git a/lib/stroma/hooks/applier.rb b/lib/stroma/hooks/applier.rb index 3870529..0c263be 100644 --- a/lib/stroma/hooks/applier.rb +++ b/lib/stroma/hooks/applier.rb @@ -47,6 +47,10 @@ def apply!(target_class, hooks, matrix) # Fetches a cached tower or builds a new one via the given block. # + # Thread-safety note: tower building occurs during class body evaluation + # (inherited hook), which is single-threaded in standard Ruby boot. + # No synchronization is needed for boot-time-only usage. + # # @param cache_key [Array] The cache key # @yield Block that builds the tower when not cached # @return [Module] The tower module diff --git a/lib/stroma/hooks/collection.rb b/lib/stroma/hooks/collection.rb index 80c8344..336d5e7 100644 --- a/lib/stroma/hooks/collection.rb +++ b/lib/stroma/hooks/collection.rb @@ -67,6 +67,9 @@ def initialize_dup(original) # @param extension [Module] Extension module to include # @return [Set] The updated collection # + # Idempotent: duplicate wraps (same target_key + extension) are + # silently ignored due to Set-based storage with Data.define equality. + # # @example # hooks.add(:actions, ValidationModule) def add(target_key, extension) diff --git a/lib/stroma/phase/resolver.rb b/lib/stroma/phase/resolver.rb index fc64e3b..c660997 100644 --- a/lib/stroma/phase/resolver.rb +++ b/lib/stroma/phase/resolver.rb @@ -36,6 +36,8 @@ def self.resolve(extension, entry) # rubocop:disable Metrics/MethodLength, Metri phase_method = entry.phase_method mod = Module.new do + # When multiple wraps target the same key within one extension, + # each define_method overwrites the previous — last-wins semantics. wraps.each do |wrap| blk = wrap.block define_method(phase_method) do |**kwargs| diff --git a/lib/stroma/registry.rb b/lib/stroma/registry.rb index ae31e8e..0d6018e 100644 --- a/lib/stroma/registry.rb +++ b/lib/stroma/registry.rb @@ -70,6 +70,7 @@ def finalize! return if @finalized @entries.freeze + @keys = @entries.map(&:key).freeze @finalized = true end @@ -88,7 +89,7 @@ def entries # @return [Array] The registry keys def keys ensure_finalized! - @entries.map(&:key) + @keys end # Checks if a key is registered. @@ -98,7 +99,7 @@ def keys # @return [Boolean] true if the key is registered def key?(key) ensure_finalized! - @entries.any? { |e| e.key == key.to_sym } + @keys.include?(key.to_sym) end private diff --git a/sig/lib/stroma/registry.rbs b/sig/lib/stroma/registry.rbs index a631fa8..4e3e13b 100644 --- a/sig/lib/stroma/registry.rbs +++ b/sig/lib/stroma/registry.rbs @@ -2,6 +2,7 @@ module Stroma class Registry @matrix_name: Symbol @entries: Array[Entry] + @keys: Array[Symbol] @finalized: bool attr_reader matrix_name: Symbol From de0604f7c7c30bf80ed4006bac60a0ed7a1eae36 Mon Sep 17 00:00:00 2001 From: Anton Sokolov Date: Sat, 14 Feb 2026 17:12:29 +0300 Subject: [PATCH 31/33] Refactor `Applier` caching, add reset, and enhance test coverage - Added `reset_tower_cache!` method to clear the tower cache for testing purposes. - Updated documentation to clarify the cache structure and naming (e.g., `object_ids` to `extensions`) for better accuracy and readability. - Modified `resolve_tower` to use `wraps.map(&:extension)` in cache keys for consistency. - Enhanced RSpec tests with cleanup logic by invoking `reset_tower_cache!` after runs. - Added a new test validating wrap execution behavior when multiple wraps target the same key (last-wins). --- lib/stroma/dsl/generator.rb | 4 ++++ lib/stroma/hooks/applier.rb | 13 +++++++++--- sig/lib/stroma/entry.rbs | 4 ++-- sig/lib/stroma/hooks/applier.rbs | 2 ++ sig/lib/stroma/hooks/wrap.rbs | 4 ++-- sig/lib/stroma/phase/wrap.rbs | 4 ++-- sig/lib/stroma/settings/setting.rbs | 2 +- spec/spec_helper.rb | 2 ++ spec/stroma/phase/resolver_spec.rb | 33 +++++++++++++++++++++++++++++ 9 files changed, 58 insertions(+), 10 deletions(-) diff --git a/lib/stroma/dsl/generator.rb b/lib/stroma/dsl/generator.rb index eb8763d..d1fc72a 100644 --- a/lib/stroma/dsl/generator.rb +++ b/lib/stroma/dsl/generator.rb @@ -106,6 +106,10 @@ def included(base) # Builds the ClassMethods module. # + # NOTE: `inherited` fires before the child class body is evaluated. + # Wraps declared via `extensions {}` on a class only take effect + # for its subclasses (where `inherited` re-applies the hooks). + # # @return [Module] The ClassMethods module def build_class_methods # rubocop:disable Metrics/MethodLength, Metrics/AbcSize Module.new do diff --git a/lib/stroma/hooks/applier.rb b/lib/stroma/hooks/applier.rb index 0c263be..db073f1 100644 --- a/lib/stroma/hooks/applier.rb +++ b/lib/stroma/hooks/applier.rb @@ -20,7 +20,7 @@ module Hooks # # ## Caching # - # Towers are cached by [matrix_name, entry_key, extension_object_ids] + # Towers are cached by [matrix_name, entry_key, extensions] # since they are built at boot time and reused across subclasses. # # ## Usage @@ -58,11 +58,18 @@ def fetch_or_build_tower(cache_key) tower_cache[cache_key] ||= yield end + # Clears the tower cache. Intended for test cleanup. + # + # @return [void] + def reset_tower_cache! + @tower_cache = {} + end + private # Returns the tower cache, lazily initialized. # - # @return [Hash] The cache mapping [matrix_name, key, object_ids] to tower modules + # @return [Hash] The cache mapping [matrix_name, key, extensions] to tower modules def tower_cache @tower_cache ||= {} end @@ -111,7 +118,7 @@ def apply! # rubocop:disable Metrics/MethodLength # @param wraps [Array] The wraps for this entry # @return [Module] The tower module def resolve_tower(entry, wraps) - cache_key = [entry.matrix_name, entry.key, wraps.map { |w| w.extension.object_id }] + cache_key = [entry.matrix_name, entry.key, wraps.map(&:extension)] self.class.fetch_or_build_tower(cache_key) { build_tower(entry, wraps) } end diff --git a/sig/lib/stroma/entry.rbs b/sig/lib/stroma/entry.rbs index 2b1614f..fe34b3f 100644 --- a/sig/lib/stroma/entry.rbs +++ b/sig/lib/stroma/entry.rbs @@ -1,6 +1,6 @@ module Stroma - # Note: Entry uses Data.define but RBS should NOT inherit from Data - # per RBS documentation for proper type checking + # NOTE: Entry uses Data.define but RBS should NOT inherit from Data + # per RBS documentation for proper type checking class Entry attr_reader key: Symbol attr_reader extension: Module diff --git a/sig/lib/stroma/hooks/applier.rbs b/sig/lib/stroma/hooks/applier.rbs index 34499b0..d3b6459 100644 --- a/sig/lib/stroma/hooks/applier.rbs +++ b/sig/lib/stroma/hooks/applier.rbs @@ -11,6 +11,8 @@ module Stroma def self.fetch_or_build_tower: (Array[untyped] cache_key) ?{ () -> Module } -> Module + def self.reset_tower_cache!: () -> void + # Private class method; declared here for Steep visibility def self.tower_cache: () -> Hash[Array[untyped], Module] diff --git a/sig/lib/stroma/hooks/wrap.rbs b/sig/lib/stroma/hooks/wrap.rbs index a70cf86..9e56c49 100644 --- a/sig/lib/stroma/hooks/wrap.rbs +++ b/sig/lib/stroma/hooks/wrap.rbs @@ -1,7 +1,7 @@ module Stroma module Hooks - # Note: Wrap uses Data.define but RBS should NOT inherit from Data - # per RBS documentation for proper type checking + # NOTE: Wrap uses Data.define but RBS should NOT inherit from Data + # per RBS documentation for proper type checking class Wrap attr_reader target_key: Symbol attr_reader extension: Module diff --git a/sig/lib/stroma/phase/wrap.rbs b/sig/lib/stroma/phase/wrap.rbs index d3e9900..df8d387 100644 --- a/sig/lib/stroma/phase/wrap.rbs +++ b/sig/lib/stroma/phase/wrap.rbs @@ -1,7 +1,7 @@ module Stroma module Phase - # Note: Wrap uses Data.define but RBS should NOT inherit from Data - # per RBS documentation for proper type checking + # NOTE: Wrap uses Data.define but RBS should NOT inherit from Data + # per RBS documentation for proper type checking class Wrap attr_reader target_key: Symbol attr_reader block: Proc diff --git a/sig/lib/stroma/settings/setting.rbs b/sig/lib/stroma/settings/setting.rbs index c4520dd..ee5a35f 100644 --- a/sig/lib/stroma/settings/setting.rbs +++ b/sig/lib/stroma/settings/setting.rbs @@ -27,7 +27,7 @@ module Stroma def to_h: () -> Hash[Symbol, untyped] - # Note: Ruby implementation uses splat args: fetch(key, *args, &block) + # NOTE: Ruby implementation uses splat args: fetch(key, *args, &block) def fetch: (Symbol key) -> untyped | (Symbol key, untyped default) -> untyped | (Symbol key) { (Symbol) -> untyped } -> untyped diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 3a81ee2..0abbc99 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -11,6 +11,8 @@ Dir[File.join(__dir__, "support", "**", "*.rb")].each { |file| require file } RSpec.configure do |config| + config.after { Stroma::Hooks::Applier.reset_tower_cache! } + # Enable flags like --only-failures and --next-failure config.example_status_persistence_file_path = ".rspec_status" diff --git a/spec/stroma/phase/resolver_spec.rb b/spec/stroma/phase/resolver_spec.rb index 3954242..cf105c3 100644 --- a/spec/stroma/phase/resolver_spec.rb +++ b/spec/stroma/phase/resolver_spec.rb @@ -66,5 +66,38 @@ expect(call_log).to eq(%i[before after]) end end + + context "when multiple wraps target the same key (last-wins)" do + let(:call_log) { [] } + + let(:extension) do + log = call_log + Module.new do + extend Stroma::Phase::Wrappable + + wrap_phase(:actions) do |phase, **kwargs| + log << :first + phase.call(**kwargs) + end + + wrap_phase(:actions) do |phase, **kwargs| + log << :second + phase.call(**kwargs) + end + end + end + + it "only executes the last wrap block" do + resolved = described_class.resolve(extension, entry) + + target_class = Class.new do + define_method(:_test_phase_actions!) { |**| } # rubocop:disable Lint/EmptyBlock + end + target_class.prepend(resolved) + + target_class.new.send(:_test_phase_actions!) + expect(call_log).to eq(%i[second]) + end + end end end From 203bd59ef1543a34de72926fb1343d2f126e095e Mon Sep 17 00:00:00 2001 From: Anton Sokolov Date: Sat, 14 Feb 2026 17:19:14 +0300 Subject: [PATCH 32/33] Memoize `resolved` for reuse and simplify test setup logic - Introduced a memoized `resolved` variable to eliminate redundancy in test setup. - Reorganized phase resolver spec to improve readability and maintainability. - Ensured the testing flow adheres to DRY principles by leveraging shared variables. --- spec/stroma/phase/resolver_spec.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/spec/stroma/phase/resolver_spec.rb b/spec/stroma/phase/resolver_spec.rb index cf105c3..530b117 100644 --- a/spec/stroma/phase/resolver_spec.rb +++ b/spec/stroma/phase/resolver_spec.rb @@ -87,9 +87,9 @@ end end - it "only executes the last wrap block" do - resolved = described_class.resolve(extension, entry) + let(:resolved) { described_class.resolve(extension, entry) } + it "only executes the last wrap block" do target_class = Class.new do define_method(:_test_phase_actions!) { |**| } # rubocop:disable Lint/EmptyBlock end From 5e6bbf4ff58a4cf753553ed450d41204ea1d8e45 Mon Sep 17 00:00:00 2001 From: Anton Sokolov Date: Sat, 14 Feb 2026 21:59:00 +0300 Subject: [PATCH 33/33] Fix recursion in `Resolver` phase method and add extended spec - Replaced `super_method` usage with a lambda to avoid infinite recursion in `Resolver`'s `phase_method` definition. - Added a new spec to validate correct chaining behavior of multiple wrapped modules in a tower. - Ensured the test mimics real-world usage, asserting proper before/after hook execution order. --- lib/stroma/phase/resolver.rb | 2 +- spec/stroma/phase/resolver_spec.rb | 49 ++++++++++++++++++++++++++++++ 2 files changed, 50 insertions(+), 1 deletion(-) diff --git a/lib/stroma/phase/resolver.rb b/lib/stroma/phase/resolver.rb index c660997..767f2b5 100644 --- a/lib/stroma/phase/resolver.rb +++ b/lib/stroma/phase/resolver.rb @@ -41,7 +41,7 @@ def self.resolve(extension, entry) # rubocop:disable Metrics/MethodLength, Metri wraps.each do |wrap| blk = wrap.block define_method(phase_method) do |**kwargs| - phase = method(phase_method).super_method + phase = ->(**kw) { super(**kw) } instance_exec(phase, **kwargs, &blk) end end diff --git a/spec/stroma/phase/resolver_spec.rb b/spec/stroma/phase/resolver_spec.rb index 530b117..67c4ba6 100644 --- a/spec/stroma/phase/resolver_spec.rb +++ b/spec/stroma/phase/resolver_spec.rb @@ -67,6 +67,55 @@ end end + context "when multiple resolved modules are combined in a tower" do + let(:call_log) { [] } + + let(:first_extension) do + log = call_log + Module.new do + extend Stroma::Phase::Wrappable + + wrap_phase(:actions) do |phase, **kwargs| + log << :first_before + phase.call(**kwargs) + log << :first_after + end + end + end + + let(:second_extension) do + log = call_log + Module.new do + extend Stroma::Phase::Wrappable + + wrap_phase(:actions) do |phase, **kwargs| + log << :second_before + phase.call(**kwargs) + log << :second_after + end + end + end + + let(:tower) do + first_resolved = described_class.resolve(first_extension, entry) + second_resolved = described_class.resolve(second_extension, entry) + Module.new do + include second_resolved + include first_resolved + end + end + + it "chains correctly without infinite recursion" do + target_class = Class.new do + define_method(:_test_phase_actions!) { |**| } # rubocop:disable Lint/EmptyBlock + end + target_class.prepend(tower) + + target_class.new.send(:_test_phase_actions!) + expect(call_log).to eq(%i[first_before second_before second_after first_after]) + end + end + context "when multiple wraps target the same key (last-wins)" do let(:call_log) { [] }