Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
c8f24c6
Add `Utils` module with `label_module` functionality
afuno Feb 13, 2026
149b4e2
Add `matrix_name` to `Entry` for phase method handling
afuno Feb 13, 2026
9d82737
Replace `InvalidHookType` with `InvalidMatrixName`
afuno Feb 13, 2026
8ff4013
Enforce valid matrix names with `validate_name!` method
afuno Feb 13, 2026
2d0a0a1
Add `matrix_name` to `Entry` initialization
afuno Feb 13, 2026
f705507
Replace `Hook` with `Wrap` for hook abstraction refactor
afuno Feb 13, 2026
7623b01
Refactor `Collection` to replace `Hook` with `Wrap`
afuno Feb 13, 2026
0f05c78
Refactor `Factory` to replace `before`/`after` with `wrap`
afuno Feb 13, 2026
2dd4b11
Refactor `Applier` to handle wraps via tower modules
afuno Feb 13, 2026
6e9a374
Refactor `Generator` to add phase stubs and orchestrator handling
afuno Feb 13, 2026
64e1589
Refactor `State` to replace `before`/`after` with `wrap`
afuno Feb 13, 2026
35fac2f
Introduce `Phase::Wrap` for generic phase wrapping blueprint
afuno Feb 13, 2026
4eeb5e1
Introduce `Wrappable` for phase-level method wrapping support
afuno Feb 13, 2026
fd5ba07
Add `Resolver` to handle phase wrap resolution for entries
afuno Feb 13, 2026
e9f75df
Update Steepfile to ignore additional files causing type issues
afuno Feb 13, 2026
142d61b
Remove unused specs for `before` and `after` in `Factory`
afuno Feb 13, 2026
14de2a7
Refactor specs and DSL to optimize wraps and constants handling
afuno Feb 13, 2026
07f02af
Refactor `Applier` and `Generator` for RuboCop compliance
afuno Feb 13, 2026
bb6727e
Refactor `Applier` tower caching and cleanup constant usage
afuno Feb 13, 2026
99f3b3b
Rename `label_module` to `name_module` for clarity
afuno Feb 14, 2026
0ace71a
Optimize `Generator` to cache and reuse `phase_methods`
afuno Feb 14, 2026
055fc65
Refactor `Applier` tower caching and add `fetch_or_build_tower`
afuno Feb 14, 2026
72b7d9a
Refactor `Applier` tower caching and method resolution
afuno Feb 14, 2026
0ba63f9
Refactor `Resolver` spec to enhance readability and coverage
afuno Feb 14, 2026
cb9d763
Refactor `Applier` spec for reusability and tower validation
afuno Feb 14, 2026
653b398
Enhance `Generator` spec with wrap extensions and reusable helpers
afuno Feb 14, 2026
94da9b4
Refactor `Generator` phase method definition for simplicity
afuno Feb 14, 2026
9120e1b
Refactor `Generator` phase and orchestrator method visibility
afuno Feb 14, 2026
7cada13
Refactor `Stroma` loader and simplify version spec assertion
afuno Feb 14, 2026
9b24a21
Optimize `Registry` with cached keys and enhanced documentation
afuno Feb 14, 2026
de0604f
Refactor `Applier` caching, add reset, and enhance test coverage
afuno Feb 14, 2026
203bd59
Memoize `resolved` for reuse and simplify test setup logic
afuno Feb 14, 2026
5e6bbf4
Fix recursion in `Resolver` phase method and add extended spec
afuno Feb 14, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion Steepfile
Original file line number Diff line number Diff line change
Expand Up @@ -22,12 +22,14 @@ 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"

# 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
2 changes: 0 additions & 2 deletions lib/stroma.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
48 changes: 25 additions & 23 deletions lib/stroma/dsl/generator.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
#
Expand Down Expand Up @@ -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
phase_methods = entries.map(&:phase_method).freeze

mod = Module.new do
@stroma_matrix = matrix

private

entries.each do |entry|
define_method(entry.phase_method) { |**| nil }
end

define_method(orchestrator_method) do |**args|
phase_methods.each { |pm| send(pm, **args) }
end

class << self
attr_reader :stroma_matrix

Expand All @@ -82,32 +96,20 @@ 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.name_module(mod, "Stroma::DSL(#{matrix.name})")
Utils.name_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.
#
# 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
Expand Down
23 changes: 18 additions & 5 deletions lib/stroma/entry.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
2 changes: 1 addition & 1 deletion lib/stroma/exceptions/base.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
27 changes: 0 additions & 27 deletions lib/stroma/exceptions/invalid_hook_type.rb

This file was deleted.

24 changes: 24 additions & 0 deletions lib/stroma/exceptions/invalid_matrix_name.rb
Original file line number Diff line number Diff line change
@@ -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
120 changes: 101 additions & 19 deletions lib/stroma/hooks/applier.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,44 +2,82 @@

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, extensions]
# 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
class << self
# Applies all registered hooks to the target class.
# Applies all registered wraps to the target class.
#
# Convenience class method that creates an applier and applies hooks.
#
# @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]
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.
#
# 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
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, extensions] to tower modules
def tower_cache
@tower_cache ||= {}
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)
Expand All @@ -48,19 +86,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/MethodLength
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 = resolve_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<Wrap>] The wraps for this entry
# @return [Module] The tower module
def resolve_tower(entry, wraps)
cache_key = [entry.matrix_name, entry.key, wraps.map(&:extension)]
self.class.fetch_or_build_tower(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<Wrap>] The wraps for this entry
# @return [Module] The tower module
def build_tower(entry, wraps) # rubocop:disable 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.name_module(tower, "Stroma::Tower(#{entry.matrix_name}:#{entry.key})")
tower
end
end
end
Expand Down
Loading