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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 20 additions & 1 deletion lib/stroma/dsl/generator.rb
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,19 @@ module DSL
# - ServiceClass gets @stroma_matrix (same reference)
# - ServiceClass gets @stroma (unique State per class)
#
# ## Deferred Entry Inclusion
#
# Entry extensions are NOT included via `Module#include` at the base class level.
# Instead, only the `self.included` callback is fired (via `send(:included, base)`)
# to set up ClassMethods, constants, etc. The actual module insertion into the
# ancestor chain (`append_features`) is deferred until {Hooks::Applier} interleaves
# entries with hooks in child classes.
#
# **Contract:** Entry extensions MUST implement `self.included` as idempotent.
# The callback fires twice per entry per class hierarchy:
# 1. At base class creation (deferred, via `send(:included, base)`)
# 2. At child class creation (real, via `include` in {Hooks::Applier})
#
# ## Usage
#
# ```ruby
Expand Down Expand Up @@ -75,7 +88,13 @@ def included(base)
base.instance_variable_set(:@stroma_matrix, mtx)
base.instance_variable_set(:@stroma, State.new)

mtx.entries.each { |entry| base.include(entry.extension) }
# Deferred inclusion: triggers `included` callback without `append_features`.
# The callback runs ClassMethods/Workspace setup on base.
# `append_features` (actual module insertion into ancestors) is deferred
# until Applier interleaves entries with hooks in child classes.
# NOTE: `included` will fire again when Applier calls `include` on child,
# so entry extensions must design `self.included` as idempotent.
mtx.entries.each { |entry| entry.extension.send(:included, base) }
end
end

Expand Down
61 changes: 53 additions & 8 deletions lib/stroma/hooks/applier.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,15 @@

module Stroma
module Hooks
# Applies registered hooks to a target class.
# Applies registered hooks to a target class with deferred entry inclusion.
#
# ## Purpose
#
# Includes hook extension modules into target class.
# Maintains order based on matrix registry entries.
# For each entry: before hooks first, then after hooks.
# Manages hook and entry module inclusion into the target class.
# Operates in three modes depending on current state:
# - No hooks: returns immediately (entries stay deferred)
# - Entries already in ancestors: includes only new hooks
# - Entries not in ancestors: interleaves entries with hooks for correct MRO
#
# ## Usage
#
Expand Down Expand Up @@ -50,16 +52,59 @@ def initialize(target_class, hooks, matrix)

# Applies all registered hooks to the target class.
#
# For each registry entry, includes before hooks first,
# then after hooks. Does nothing if hooks collection is empty.
# Three modes based on current state:
# - No hooks: return immediately (defer entry inclusion)
# - Entries already in ancestors: include only hooks
# - Entries not in ancestors: interleave entries with hooks
#
# @return [void]
def apply!
return if @hooks.empty?

if entries_in_ancestors?
include_hooks_only
else
include_entries_with_hooks
end
end

private

# Checks whether all entry extensions are already in the target class ancestors.
#
# Uses all? so that partial inclusion (some entries present, some not)
# falls through to include_entries_with_hooks where Ruby skips
# already-included modules (idempotent) and interleaves the rest.
#
# @return [Boolean]
def entries_in_ancestors?
ancestors = @target_class.ancestors
@matrix.entries.all? { |e| ancestors.include?(e.extension) }
end

# Includes entries interleaved with their hooks.
#
# For each entry: after hooks first (reversed), then entry, then before hooks (reversed).
# reverse_each ensures first registered = outermost in MRO.
#
# @return [void]
def include_entries_with_hooks
@matrix.entries.each do |entry|
@hooks.after(entry.key).reverse_each { |hook| @target_class.include(hook.extension) }
@target_class.include(entry.extension)
@hooks.before(entry.key).reverse_each { |hook| @target_class.include(hook.extension) }
end
end

# Includes only hook extensions without entries.
#
# Used when entries are already in ancestors (multi-level inheritance).
#
# @return [void]
def include_hooks_only
@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) }
@hooks.before(entry.key).reverse_each { |hook| @target_class.include(hook.extension) }
@hooks.after(entry.key).reverse_each { |hook| @target_class.include(hook.extension) }
end
end
end
Expand Down
8 changes: 8 additions & 0 deletions sig/lib/stroma/hooks/applier.rbs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,14 @@ module Stroma
def initialize: (Class target_class, Collection hooks, Matrix matrix) -> void

def apply!: () -> void

private

def entries_in_ancestors?: () -> bool

def include_entries_with_hooks: () -> void

def include_hooks_only: () -> void
end
end
end
149 changes: 138 additions & 11 deletions spec/stroma/dsl/generator_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -56,9 +56,9 @@
expect(base_class).to respond_to(:inherited)
end

it "includes all registered DSL modules", :aggregate_failures do
expect(base_class.ancestors).to include(inputs_dsl)
expect(base_class.ancestors).to include(outputs_dsl)
it "does not include entry modules in base ancestors", :aggregate_failures do
expect(base_class.ancestors).not_to include(inputs_dsl)
expect(base_class.ancestors).not_to include(outputs_dsl)
end

it "creates stroma state" do
Expand Down Expand Up @@ -93,11 +93,21 @@ def self.included(base)

let(:child_class) { Class.new(base_class) }

it "includes entry modules in child via interleaving", :aggregate_failures do
expect(child_class.ancestors).to include(inputs_dsl)
expect(child_class.ancestors).to include(outputs_dsl)
end

it "applies hooks to child class" do
expect(child_class.ancestors).to include(extension_module)
end

it "child has extension method", :aggregate_failures do
it "positions hook adjacent to its target entry in child" do
ancestors = child_class.ancestors
expect(ancestors.index(extension_module)).to be < ancestors.index(inputs_dsl)
end

it "propagates extension callback to child", :aggregate_failures do
expect(child_class).to respond_to(:extension_method)
expect(child_class.extension_method).to eq(:extension_result)
end
Expand All @@ -112,10 +122,127 @@ def self.included(base)
end
end

describe "multi-level inheritance" do
let(:base_class) do
mtx = matrix
Class.new { include mtx.dsl }
end

context "with hooks at one level" do
let(:auth_module) do
Module.new do
def self.included(base)
base.define_singleton_method(:auth_configured) { true }
end
end
end

let(:mid_class) do
base = base_class
auth = auth_module
Class.new(base) do
extensions do
before :inputs, auth
end
end
end

let(:leaf_class) { Class.new(mid_class) }

it "does not include entries in base", :aggregate_failures do
expect(base_class.ancestors).not_to include(inputs_dsl)
expect(base_class.ancestors).not_to include(outputs_dsl)
end

it "does not include entries in mid class", :aggregate_failures do
expect(mid_class.ancestors).not_to include(inputs_dsl)
expect(mid_class.ancestors).not_to include(outputs_dsl)
end

it "interleaves entries with hooks in leaf class", :aggregate_failures do
ancestors = leaf_class.ancestors

expect(ancestors).to include(inputs_dsl, outputs_dsl, auth_module)
expect(ancestors.index(auth_module)).to be < ancestors.index(inputs_dsl)
end

it "propagates hook callbacks to leaf" do
expect(leaf_class).to respond_to(:auth_configured)
end
end

context "with hooks at every level" do
let(:auth_module) { Module.new }
let(:logging_module) { Module.new }

let(:mid_class) do
base = base_class
auth = auth_module
Class.new(base) do
extensions do
before :inputs, auth
end
end
end

let(:leaf_class) do
mid = mid_class
logging = logging_module
Class.new(mid) do
extensions do
after :inputs, logging
end
end
end

it "includes hooks and entries from all levels", :aggregate_failures do
ancestors = Class.new(leaf_class).ancestors
expect(ancestors).to include(auth_module, logging_module, inputs_dsl, outputs_dsl)
end

it "positions before hook above entry in MRO" do
ancestors = Class.new(leaf_class).ancestors
expect(ancestors.index(auth_module)).to be < ancestors.index(inputs_dsl)
end

# Ruby's Module#include cannot position a new module below an inherited module.
# After hooks registered at parent level are placed above inherited entries in
# grandchild MRO. This does not affect phase execution order (controlled by
# the orchestrator), only the super call chain.
it "positions cross-level after hook above entry in MRO" do
ancestors = Class.new(leaf_class).ancestors
expect(ancestors.index(logging_module)).to be < ancestors.index(inputs_dsl)
end
end

context "when grandchild has no own hooks" do
let(:auth_module) { Module.new }

let(:mid_class) do
base = base_class
auth = auth_module
Class.new(base) do
extensions do
before :inputs, auth
end
end
end

let(:child_class) { Class.new(mid_class) }
let(:grandchild) { Class.new(child_class) }

it "propagates entries and hooks to grandchild", :aggregate_failures do
expect(grandchild.ancestors).to include(inputs_dsl)
expect(grandchild.ancestors).to include(outputs_dsl)
expect(grandchild.ancestors).to include(auth_module)
end
end
end

describe "inheritance isolation" do
let(:extension_module) { Module.new }

let(:parent_class) do
let(:base_class) do
mtx = matrix
ext = extension_module
Class.new do
Expand All @@ -127,9 +254,9 @@ def self.included(base)
end
end

let(:child_class) { Class.new(parent_class) }
let(:child_class) { Class.new(base_class) }

it "child modifications do not affect parent", :aggregate_failures do
it "child modifications do not affect base", :aggregate_failures do
child_extension = Module.new

child_class.class_eval do
Expand All @@ -138,20 +265,20 @@ def self.included(base)
end
end

expect(parent_class.stroma.hooks.after(:outputs)).to be_empty
expect(base_class.stroma.hooks.after(:outputs)).to be_empty
expect(child_class.stroma.hooks.after(:outputs)).not_to be_empty
end

it "child inherits parent hooks", :aggregate_failures do
it "child inherits base hooks", :aggregate_failures do
expect(child_class.stroma.hooks.before(:inputs).size).to eq(1)
expect(child_class.ancestors).to include(extension_module)
end

it "parent modifications after child creation do not affect child" do
it "base modifications after child creation do not affect child" do
child_before_count = child_class.stroma.hooks.before(:outputs).size
new_extension = Module.new

parent_class.class_eval do
base_class.class_eval do
extensions do
before :outputs, new_extension
end
Expand Down
Loading