diff --git a/lib/dry/system/container.rb b/lib/dry/system/container.rb index 89be5b21..7db0baa0 100644 --- a/lib/dry/system/container.rb +++ b/lib/dry/system/container.rb @@ -5,6 +5,7 @@ require "dry/configurable" require "dry/auto_inject" require "dry/inflector" +require "dry/system/cyclic_dependency_detector" module Dry module System @@ -493,11 +494,22 @@ def register(key, *) self end + # Resolves a component by its key, loading it if necessary. + # + # In case of a cyclic dependency, it will detect the cycle + # and replace the error with CyclicDependencyError providing + # information on the dependency loading cycle. + # # @api public def resolve(key) load_component(key) unless finalized? super + rescue SystemStackError => e + cycle = CyclicDependencyDetector.detect_from_backtrace(e.backtrace) + e = CyclicDependencyError.new(cycle) if cycle.any? + + raise e end alias_method :registered?, :key? diff --git a/lib/dry/system/cycle_visualization.rb b/lib/dry/system/cycle_visualization.rb new file mode 100644 index 00000000..42fbb277 --- /dev/null +++ b/lib/dry/system/cycle_visualization.rb @@ -0,0 +1,69 @@ +# frozen_string_literal: true + +module Dry + module System + # Generates ASCII art visualizations for dependency cycles + # + # @api private + class CycleVisualization + # Generates ASCII art for a dependency cycle + # + # @param cycle [Array] Array of component names forming the cycle + # @return [String] ASCII art representation of the cycle + # + # @api private + def self.generate(cycle) = new(cycle).generate + + # @api private + def initialize(cycle) = @cycle = cycle + + # @api private + def generate + return "" if cycle.empty? + + case cycle.length + when 2 + generate_bidirectional_arrow + when 3, 4 + generate_small_cycle + else + generate_large_cycle + end + end + + private + + attr_reader :cycle + + def generate_bidirectional_arrow + "#{cycle[0]} ◄──► #{cycle[1]}" + end + + def generate_small_cycle + components = cycle + [cycle[0]] # Complete the cycle + cycle_lines = components.each_cons(2).map { |a, b| "#{a} ───► #{b}" } + + cycle_text = cycle_lines.join("\n") + visual_return_arrow = build_visual_return_arrow(components[-2].length) + + "#{cycle_text}\n#{visual_return_arrow}" + end + + def generate_large_cycle + cycle_text = cycle.join(" ───► ") + cycle_text += " ───► #{cycle[0]}" + + visual_return_arrow = build_visual_return_arrow(cycle_text.length - cycle[0].length - 8) + + "#{cycle_text}\n#{visual_return_arrow}" + end + + def build_visual_return_arrow(width) + arrow_up = "▲#{" " * (width + 6)}│" + arrow_line = "└#{"─" * (width + 6)}┘" + + "#{arrow_up}\n#{arrow_line}" + end + end + end +end diff --git a/lib/dry/system/cyclic_dependency_detector.rb b/lib/dry/system/cyclic_dependency_detector.rb new file mode 100644 index 00000000..47a74075 --- /dev/null +++ b/lib/dry/system/cyclic_dependency_detector.rb @@ -0,0 +1,110 @@ +# frozen_string_literal: true + +module Dry + module System + # Detects cyclic dependencies from SystemStackError backtraces + # + # @api private + class CyclicDependencyDetector + # Detects cyclic dependencies from SystemStackError backtrace + # + # @param backtrace [Array] The backtrace from SystemStackError + # @return [Array] Array of component names forming the cycle + # + # @api private + def self.detect_from_backtrace(backtrace) + new(backtrace).detect_cycle + end + + # @api private + def initialize(backtrace) + @backtrace = backtrace + end + + # @api private + def detect_cycle + component_files = extract_component_files + unique_components = component_files.uniq + + # If we have repeated component names, we likely have a cycle + if repeated_components?(component_files, unique_components) + cycle = find_component_cycle(component_files) + return cycle if cycle.any? + end + + # Fallback: if we have multiple unique components in the stack, assume + # they form a cycle + return unique_components.first(4) if unique_components.length >= 2 + + [] + end + + private + + attr_reader :backtrace + + def extract_component_files + component_files = [] + + backtrace.each do |frame| + # Extract component information: file name and method name + _, file_name, method_name = frame.match(%r{/([^/]+)\.rb:\d+:in\s+`([^']+)'}).to_a + next unless file_name && method_name + + # Skip system/framework files + next if system_file?(file_name, frame) + + # Focus on initialize methods which are likely where dependency cycles occur + component_files << file_name if component_creation_method?(method_name) + end + + component_files + end + + def system_file?(file_name, frame) + file_name.start_with?("dry-", "loader", "component", "container") || + frame.include?("/lib/dry/") || + frame.include?("/gems/") + end + + def component_creation_method?(method_name) + method_name == "initialize" || method_name == "new" + end + + def repeated_components?(component_files, unique_components) + component_files.length > unique_components.length && unique_components.length >= 2 + end + + def find_component_cycle(component_files) + return [] if component_files.length < 4 + + # Look for patterns where the same component sequence repeats + (2..component_files.length / 2).each do |pattern_length| + pattern = component_files[-pattern_length..] + repeat_count = count_pattern_repetitions(component_files, pattern, pattern_length) + + # If we found at least 2 repetitions, this is likely a cycle + return pattern.uniq if repeat_count >= 1 + end + + [] + end + + def count_pattern_repetitions(component_files, pattern, pattern_length) + repeat_count = 0 + start_pos = component_files.length - pattern_length + + while start_pos >= pattern_length + if component_files[start_pos - pattern_length, pattern_length] == pattern + repeat_count += 1 + start_pos -= pattern_length + else + break + end + end + + repeat_count + end + end + end +end diff --git a/lib/dry/system/errors.rb b/lib/dry/system/errors.rb index 62cecafb..443e3cff 100644 --- a/lib/dry/system/errors.rb +++ b/lib/dry/system/errors.rb @@ -1,5 +1,7 @@ # frozen_string_literal: true +require "dry/system/cycle_visualization" + module Dry module System # Error raised when import is called on an already finalized container @@ -128,5 +130,23 @@ def initialize(component, error, super(message.join("\n")) end end + + # Error raised when components have cyclic dependencies + # + # @api public + CyclicDependencyError = Class.new(StandardError) do + # @api private + def initialize(cycle) + cycle_visualization = CycleVisualization.generate(cycle) + + super(<<~ERROR_MESSAGE) + These dependencies form a cycle: + + #{cycle_visualization} + + You must break this cycle in order to use any of them. + ERROR_MESSAGE + end + end end end diff --git a/spec/fixtures/cyclic_components/lib/cycle_bar.rb b/spec/fixtures/cyclic_components/lib/cycle_bar.rb new file mode 100644 index 00000000..5a0d7754 --- /dev/null +++ b/spec/fixtures/cyclic_components/lib/cycle_bar.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +require_relative "cycle_foo" + +class CycleBar + def initialize + # This creates the cycle: CycleBar -> CycleFoo -> CycleBar + CycleFoo.new + end +end diff --git a/spec/fixtures/cyclic_components/lib/cycle_foo.rb b/spec/fixtures/cyclic_components/lib/cycle_foo.rb new file mode 100644 index 00000000..231e30ae --- /dev/null +++ b/spec/fixtures/cyclic_components/lib/cycle_foo.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +require_relative "cycle_bar" + +class CycleFoo + def initialize + # This creates the cycle: CycleFoo -> CycleBar -> CycleFoo + CycleBar.new + end +end diff --git a/spec/fixtures/cyclic_components/lib/safe_component.rb b/spec/fixtures/cyclic_components/lib/safe_component.rb new file mode 100644 index 00000000..99ab098c --- /dev/null +++ b/spec/fixtures/cyclic_components/lib/safe_component.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class SafeComponent + def initialize + # No dependencies, no cycles + end +end diff --git a/spec/integration/container/cyclic_dependencies_spec.rb b/spec/integration/container/cyclic_dependencies_spec.rb new file mode 100644 index 00000000..4c56764e --- /dev/null +++ b/spec/integration/container/cyclic_dependencies_spec.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +require "dry/system/container" + +RSpec.describe "Cyclic dependency detection" do + let(:container) { Test::Container } + + before do + class Test::Container < Dry::System::Container + configure do |config| + config.root = SPEC_ROOT.join("fixtures/cyclic_components").realpath + config.component_dirs.add "lib" + end + end + end + + context "with existing cyclic fixtures" do + it "detects the cycle and raises CyclicDependencyError" do + expect { container["cycle_foo"] }.to raise_error(Dry::System::CyclicDependencyError) do |error| + expect(error.message).to include("These dependencies form a cycle:") + expect(error.message).to include("You must break this cycle") + end + end + end + + context "when there are no cycles" do + it "loads components normally without error" do + expect { container["safe_component"] }.not_to raise_error + expect(container["safe_component"]).to be_a(SafeComponent) + end + end +end diff --git a/spec/unit/cycle_visualization_spec.rb b/spec/unit/cycle_visualization_spec.rb new file mode 100644 index 00000000..2e200eb6 --- /dev/null +++ b/spec/unit/cycle_visualization_spec.rb @@ -0,0 +1,217 @@ +# frozen_string_literal: true + +require "dry/system/cycle_visualization" + +RSpec.describe Dry::System::CycleVisualization do + subject(:visualizer) { described_class.new(cycle) } + let(:cycle) { [] } + + describe ".generate" do + context "with empty cycle" do + it "returns empty string" do + expect(described_class.generate([])).to eq("") + end + end + + context "with single component" do + it "generates small cycle visualization" do + result = described_class.generate(["single"]) + expect(result).to include("single ───► single") + expect(result).to include("▲") + expect(result).to include("└") + end + end + + context "with two components" do + it "generates bidirectional arrow" do + result = described_class.generate(%w[foo bar]) + expect(result).to eq("foo ◄──► bar") + end + + it "handles components with different lengths" do + result = described_class.generate(%w[short very_long_component_name]) + expect(result).to eq("short ◄──► very_long_component_name") + end + end + + context "with three components" do + it "generates small cycle visualization" do + result = described_class.generate(%w[alpha beta gamma]) + + expected = <<~CYCLE.strip + alpha ───► beta + beta ───► gamma + gamma ───► alpha + ▲ │ + └───────────┘ + CYCLE + + expect(result).to eq(expected) + end + + it "adjusts arrow width based on component names" do + result = described_class.generate(%w[a b c]) + + expect(result).to include("a ───► b") + expect(result).to include("b ───► c") + expect(result).to include("c ───► a") + expect(result).to include("▲") + expect(result).to include("└") + end + end + + context "with four components" do + it "generates small cycle visualization" do + result = described_class.generate(%w[widget xenon yacht zorro]) + + expected_lines = [ + "widget ───► xenon", + "xenon ───► yacht", + "yacht ───► zorro", + "zorro ───► widget" + ] + + expected_lines.each do |line| + expect(result).to include(line) + end + + expect(result).to include("▲") + expect(result).to include("└") + end + end + + context "with five or more components" do + it "generates large cycle visualization" do + cycle = %w[apple banana cherry date elderberry] + result = described_class.generate(cycle) + + expected_cycle_text = "apple ───► banana ───► cherry ───► date ───► elderberry ───► apple" + expect(result).to include(expected_cycle_text) + expect(result).to include("▲") + expect(result).to include("└") + end + + it "handles very long cycles" do + cycle = %w[service_a service_b service_c service_d service_e service_f service_g] + result = described_class.generate(cycle) + + expect(result).to include("service_a ───►") + expect(result).to include("───► service_g ───► service_a") + expect(result).to include("▲") + expect(result).to include("└") + end + end + + context "with special characters in component names" do + it "handles underscores and numbers" do + result = described_class.generate(%w[component_1 component_2]) + expect(result).to eq("component_1 ◄──► component_2") + end + + it "handles mixed case" do + result = described_class.generate(%w[MyComponent YourComponent]) + expect(result).to eq("MyComponent ◄──► YourComponent") + end + end + end + + describe "#initialize" do + it "stores the cycle" do + cycle = %w[foo bar] + visualizer = described_class.new(cycle) + + expect(visualizer.instance_variable_get(:@cycle)).to eq(cycle) + end + end + + describe "#generate" do + let(:cycle) { %w[test example] } + + it "delegates to class method behavior" do + expect(visualizer.generate).to eq("test ◄──► example") + end + end + + describe "private methods" do + let(:cycle) { %w[alpha beta gamma] } + + describe "#generate_bidirectional_arrow" do + let(:cycle) { %w[left right] } + + it "creates proper bidirectional arrow" do + result = visualizer.send(:generate_bidirectional_arrow) + expect(result).to eq("left ◄──► right") + end + end + + describe "#generate_small_cycle" do + it "creates cycle with return arrow" do + result = visualizer.send(:generate_small_cycle) + + expect(result).to include("alpha ───► beta") + expect(result).to include("beta ───► gamma") + expect(result).to include("gamma ───► alpha") + expect(result).to include("▲") + expect(result).to include("└") + end + end + + describe "#generate_large_cycle" do + let(:cycle) { %w[a b c d e f] } + + it "creates compact cycle representation" do + result = visualizer.send(:generate_large_cycle) + + expect(result).to include("a ───► b ───► c ───► d ───► e ───► f ───► a") + expect(result).to include("▲") + expect(result).to include("└") + end + end + + describe "#build_visual_return_arrow" do + it "creates return arrow with correct width" do + result = visualizer.send(:build_visual_return_arrow, 10) + + lines = result.split("\n") + expect(lines.length).to eq(2) + expect(lines[0]).to start_with("▲") + expect(lines[0]).to end_with("│") + expect(lines[1]).to start_with("└") + expect(lines[1]).to end_with("┘") + + # Check that both lines have the expected width + expected_width = 10 + 6 + 2 # width + padding + arrow chars + expect(lines[0].length).to eq(expected_width) + expect(lines[1].length).to eq(expected_width) + end + + it "handles zero width" do + result = visualizer.send(:build_visual_return_arrow, 0) + + lines = result.split("\n") + expect(lines[0]).to eq("▲ │") + expect(lines[1]).to eq("└──────┘") + end + end + end + + describe "edge cases" do + context "with nil in cycle array" do + it "handles nil values gracefully" do + # This shouldn't happen in practice, but let's be defensive + expect { described_class.generate([nil, "component"]) }.not_to raise_error + end + end + + context "with very long component names" do + it "handles long names" do + long_name = "a" * 100 + result = described_class.generate([long_name, "short"]) + + expect(result).to include(long_name) + expect(result).to include("short") + expect(result).to include("◄──►") + end + end + end +end diff --git a/spec/unit/cyclic_dependency_detector_spec.rb b/spec/unit/cyclic_dependency_detector_spec.rb new file mode 100644 index 00000000..bc106dbe --- /dev/null +++ b/spec/unit/cyclic_dependency_detector_spec.rb @@ -0,0 +1,336 @@ +# frozen_string_literal: true + +require "dry/system/cyclic_dependency_detector" + +RSpec.describe Dry::System::CyclicDependencyDetector do + subject(:detector) { described_class.new(backtrace) } + let(:backtrace) { [] } + + describe ".detect_from_backtrace" do + let(:backtrace) { ["/path/to/foo.rb:10:in `initialize'"] } + + it "delegates to instance method" do + expect(described_class.detect_from_backtrace(backtrace)).to eq([]) + end + end + + describe "#detect_cycle" do + context "with no component files in backtrace" do + let(:backtrace) do + [ + "/usr/lib/ruby/gems/dry-core/lib/dry/core.rb:10:in `resolve'", + "/usr/lib/ruby/gems/zeitwerk/lib/zeitwerk.rb:20:in `load'" + ] + end + + it "returns empty array" do + expect(detector.detect_cycle).to eq([]) + end + end + + context "with single component in backtrace" do + let(:backtrace) do + [ + "/app/lib/components/user_service.rb:5:in `initialize'", + "/usr/lib/ruby/gems/dry-system/lib/dry/system/loader.rb:33:in `require!'" + ] + end + + it "returns empty array" do + expect(detector.detect_cycle).to eq([]) + end + end + + context "with two unique components" do + let(:backtrace) do + [ + "/app/lib/components/user_service.rb:5:in `initialize'", + "/app/lib/components/auth_service.rb:8:in `new'", + "/usr/lib/ruby/gems/dry-system/lib/dry/system/loader.rb:33:in `require!'" + ] + end + + it "returns both components as fallback cycle" do + expect(detector.detect_cycle).to eq(%w[user_service auth_service]) + end + end + + context "with repeated components indicating a cycle" do + let(:backtrace) do + [ + "/app/lib/components/foo.rb:5:in `initialize'", + "/app/lib/components/bar.rb:8:in `new'", + "/app/lib/components/foo.rb:5:in `initialize'", + "/app/lib/components/bar.rb:8:in `new'", + "/app/lib/components/foo.rb:5:in `initialize'", + "/app/lib/components/bar.rb:8:in `new'", + "/usr/lib/ruby/gems/dry-system/lib/dry/system/loader.rb:33:in `require!'" + ] + end + + it "detects the repeating cycle pattern" do + expect(detector.detect_cycle).to eq(%w[foo bar]) + end + end + + context "with more than 4 unique components" do + let(:backtrace) do + [ + "/app/lib/components/service_a.rb:5:in `initialize'", + "/app/lib/components/service_b.rb:8:in `new'", + "/app/lib/components/service_c.rb:3:in `initialize'", + "/app/lib/components/service_d.rb:12:in `new'", + "/app/lib/components/service_e.rb:7:in `initialize'", + "/usr/lib/ruby/gems/dry-system/lib/dry/system/loader.rb:33:in `require!'" + ] + end + + it "returns first 4 components as fallback" do + expect(detector.detect_cycle).to eq(%w[service_a service_b service_c service_d]) + end + end + end + + describe "#extract_component_files" do + context "with valid component backtrace lines" do + let(:backtrace) do + [ + "/app/lib/components/user_service.rb:10:in `initialize'", + "/app/lib/components/auth_service.rb:5:in `new'", + "/app/other/helper.rb:15:in `initialize'" + ] + end + + it "extracts component file names from initialize and new methods" do + result = subject.send(:extract_component_files) + expect(result).to eq(%w[user_service auth_service helper]) + end + end + + context "with system/framework files" do + let(:backtrace) do + [ + "/app/lib/components/user_service.rb:10:in `initialize'", + "/usr/lib/ruby/gems/dry-system/lib/dry/system/loader.rb:33:in `require!'", + "/usr/lib/ruby/gems/dry-core/lib/dry/core/container.rb:50:in `resolve'", + "/app/lib/dry-custom.rb:5:in `initialize'" + ] + end + + it "filters out system files" do + result = subject.send(:extract_component_files) + expect(result).to eq(%w[user_service]) + end + end + + context "with non-component methods" do + let(:backtrace) do + [ + "/app/lib/components/user_service.rb:10:in `initialize'", + "/app/lib/components/auth_service.rb:5:in `call'", + "/app/lib/components/data_service.rb:8:in `process'" + ] + end + + it "only includes initialize and new methods" do + detector.instance_variable_set(:@backtrace, backtrace) + result = detector.send(:extract_component_files) + expect(result).to eq(%w[user_service]) + end + end + + context "with malformed backtrace lines" do + let(:backtrace) do + [ + "/app/lib/components/user_service.rb:10:in `initialize'", + "invalid backtrace line", + "/app/lib/components/auth_service.rb:5:in `new'", + "/no/method/info.rb:10" + ] + end + + it "handles malformed lines gracefully" do + detector.instance_variable_set(:@backtrace, backtrace) + result = detector.send(:extract_component_files) + expect(result).to eq(%w[user_service auth_service]) + end + end + end + + describe "#system_file?" do + it "identifies dry- prefixed files as system files" do + expect(detector.send(:system_file?, "dry-core", "/path/dry-core.rb")).to be true + expect(detector.send(:system_file?, "dry-system", "/path/dry-system.rb")).to be true + end + + it "identifies loader files as system files" do + expect(detector.send(:system_file?, "loader", "/path/loader.rb")).to be true + end + + it "identifies component files as system files" do + expect(detector.send(:system_file?, "component", "/path/component.rb")).to be true + end + + it "identifies container files as system files" do + expect(detector.send(:system_file?, "container", "/path/container.rb")).to be true + end + + it "identifies paths with /lib/dry/ as system files" do + expect(detector.send(:system_file?, "anything", "/app/lib/dry/system.rb")).to be true + end + + it "identifies paths with /gems/ as system files" do + expect(detector.send(:system_file?, "anything", "/usr/lib/ruby/gems/dry-core.rb")).to be true + end + + it "does not identify regular user files as system files" do + expect(detector.send(:system_file?, "user_service", "/app/lib/user_service.rb")).to be false + expect(detector.send(:system_file?, "my_component", "/app/components/my_component.rb")).to be false + end + end + + describe "#component_creation_method?" do + it "identifies initialize as component creation method" do + expect(detector.send(:component_creation_method?, "initialize")).to be true + end + + it "identifies new as component creation method" do + expect(detector.send(:component_creation_method?, "new")).to be true + end + + it "does not identify other methods as component creation methods" do + expect(detector.send(:component_creation_method?, "call")).to be false + expect(detector.send(:component_creation_method?, "process")).to be false + expect(detector.send(:component_creation_method?, "execute")).to be false + end + end + + describe "#repeated_components?" do + it "returns true when components repeat and there are at least 2 unique" do + component_files = %w[foo bar foo bar] + unique_components = %w[foo bar] + + result = detector.send(:repeated_components?, component_files, unique_components) + expect(result).to be true + end + + it "returns false when no repetition" do + component_files = %w[foo bar baz] + unique_components = %w[foo bar baz] + + result = detector.send(:repeated_components?, component_files, unique_components) + expect(result).to be false + end + + it "returns false when less than 2 unique components" do + component_files = %w[foo foo foo] + unique_components = %w[foo] + + result = detector.send(:repeated_components?, component_files, unique_components) + expect(result).to be false + end + end + + describe "#find_component_cycle" do + context "with insufficient component files" do + it "returns empty array for less than 4 files" do + expect(detector.send(:find_component_cycle, %w[foo bar])).to eq([]) + end + end + + context "with repeating patterns" do + it "detects 2-component repeating pattern" do + component_files = %w[foo bar foo bar foo bar] + result = detector.send(:find_component_cycle, component_files) + expect(result).to eq(%w[foo bar]) + end + + it "detects 3-component repeating pattern" do + component_files = %w[alpha beta gamma alpha beta gamma] + result = detector.send(:find_component_cycle, component_files) + expect(result).to eq(%w[alpha beta gamma]) + end + + it "returns unique components from pattern" do + component_files = %w[foo bar bar foo bar bar] # bar appears twice in pattern + result = detector.send(:find_component_cycle, component_files) + expect(result).to eq(%w[foo bar]) # Deduplicated + end + end + + context "without clear repeating patterns" do + it "returns empty array when no pattern found" do + component_files = %w[foo bar baz qux] + expect(detector.send(:find_component_cycle, component_files)).to eq([]) + end + end + end + + describe "#count_pattern_repetitions" do + it "counts exact pattern repetitions" do + component_files = %w[foo bar foo bar foo bar] + pattern = %w[foo bar] + pattern_length = 2 + + result = detector.send(:count_pattern_repetitions, component_files, pattern, pattern_length) + expect(result).to eq(2) # Pattern repeats 2 times before the final occurrence + end + + it "returns 0 when pattern doesn't repeat" do + component_files = %w[foo bar baz qux] + pattern = %w[baz qux] + pattern_length = 2 + + result = detector.send(:count_pattern_repetitions, component_files, pattern, pattern_length) + expect(result).to eq(0) + end + + it "handles single element patterns" do + component_files = %w[foo foo foo foo] + pattern = %w[foo] + pattern_length = 1 + + result = detector.send(:count_pattern_repetitions, component_files, pattern, pattern_length) + expect(result).to eq(3) + end + end + + describe "integration scenarios" do + context "real-world backtrace simulation" do + let(:backtrace) do + [ + "/app/lib/services/user_service.rb:15:in `initialize'", + "/app/lib/services/auth_service.rb:8:in `new'", + "/usr/lib/ruby/3.3.0/gems/dry-system-1.2.3/lib/dry/system/loader.rb:47:in `call'", + "/usr/lib/ruby/3.3.0/gems/dry-system-1.2.3/lib/dry/system/component.rb:64:in `instance'", + "/app/lib/services/user_service.rb:15:in `initialize'", + "/app/lib/services/auth_service.rb:8:in `new'", + "/usr/lib/ruby/3.3.0/gems/dry-core-1.1.0/lib/dry/core/container/resolver.rb:36:in `call'", + "/app/lib/services/user_service.rb:15:in `initialize'", + "/app/lib/services/auth_service.rb:8:in `new'" + ] + end + + it "correctly identifies the cyclic dependencies" do + expect(detector.detect_cycle).to eq(%w[user_service auth_service]) + end + end + + context "complex cycle with multiple components" do + let(:backtrace) do + [ + "/app/components/service_a.rb:10:in `initialize'", + "/app/components/service_b.rb:5:in `new'", + "/app/components/service_c.rb:12:in `initialize'", + "/app/components/service_a.rb:10:in `initialize'", + "/app/components/service_b.rb:5:in `new'", + "/app/components/service_c.rb:12:in `initialize'" + ] + end + + it "detects the three-component cycle" do + expect(detector.detect_cycle).to eq(%w[service_a service_b service_c]) + end + end + end +end diff --git a/spec/unit/errors_spec.rb b/spec/unit/errors_spec.rb index 9e5b85f0..cdd6f17b 100644 --- a/spec/unit/errors_spec.rb +++ b/spec/unit/errors_spec.rb @@ -2,86 +2,128 @@ require "dry/system/errors" -module Dry - module System - RSpec.describe "Errors" do - describe ComponentNotLoadableError do - let(:component) { instance_double(Dry::System::Component, key: key) } - let(:error) { instance_double(NameError, name: "Foo", receiver: "Test") } - subject { described_class.new(component, error, corrections: corrections) } - - describe "without corrections" do - let(:corrections) { [] } +RSpec.describe "Dry::System::Errors" do + describe Dry::System::ComponentNotLoadableError do + let(:component) { instance_double(Dry::System::Component, key: key) } + let(:error) { instance_double(NameError, name: "Foo", receiver: "Test") } + subject { described_class.new(component, error, corrections: corrections) } + + describe "without corrections" do + let(:corrections) { [] } + let(:key) { "test.foo" } + + it do + expect(subject.message).to eq( + "Component 'test.foo' is not loadable.\n" \ + "Looking for Test::Foo." + ) + end + end + + describe "with corrections" do + describe "acronym" do + describe "single class name correction" do + let(:corrections) { ["Test::FOO"] } let(:key) { "test.foo" } it do expect(subject.message).to eq( - "Component 'test.foo' is not loadable.\n" \ - "Looking for Test::Foo." + <<~ERROR_MESSAGE + Component 'test.foo' is not loadable. + Looking for Test::Foo. + + You likely need to add: + + acronym('FOO') + + to your container's inflector, since we found a Test::FOO class. + ERROR_MESSAGE ) end end - describe "with corrections" do - describe "acronym" do - describe "single class name correction" do - let(:corrections) { ["Test::FOO"] } - let(:key) { "test.foo" } + describe "module and class name correction" do + let(:error) { instance_double(NameError, name: "Foo", receiver: "Test::Api") } + let(:corrections) { ["Test::API::FOO"] } + let(:key) { "test.api.foo" } - it do - expect(subject.message).to eq( - <<~ERROR_MESSAGE - Component 'test.foo' is not loadable. - Looking for Test::Foo. + it do + expect(subject.message).to eq( + <<~ERROR_MESSAGE + Component 'test.api.foo' is not loadable. + Looking for Test::Api::Foo. - You likely need to add: + You likely need to add: - acronym('FOO') + acronym('API', 'FOO') - to your container's inflector, since we found a Test::FOO class. - ERROR_MESSAGE - ) - end - end + to your container's inflector, since we found a Test::API::FOO class. + ERROR_MESSAGE + ) + end + end + end - describe "module and class name correction" do - let(:error) { instance_double(NameError, name: "Foo", receiver: "Test::Api") } - let(:corrections) { ["Test::API::FOO"] } - let(:key) { "test.api.foo" } + describe "typo" do + let(:corrections) { ["Test::Fon", "Test::Flo"] } + let(:key) { "test.foo" } - it do - expect(subject.message).to eq( - <<~ERROR_MESSAGE - Component 'test.api.foo' is not loadable. - Looking for Test::Api::Foo. + it do + expect(subject.message).to eq( + <<~ERROR_MESSAGE.chomp + Component 'test.foo' is not loadable. + Looking for Test::Foo. - You likely need to add: + Did you mean? Test::Fon + Test::Flo + ERROR_MESSAGE + ) + end + end + end + end - acronym('API', 'FOO') + describe Dry::System::CyclicDependencyError do + describe "ASCII art generation" do + context "with two components" do + it "generates simple bidirectional arrow" do + error = described_class.new(%w[foo bar]) - to your container's inflector, since we found a Test::API::FOO class. - ERROR_MESSAGE - ) - end - end - end + expect(error.message).to include("These dependencies form a cycle:") + expect(error.message).to include("foo ◄──► bar") + expect(error.message).to include("You must break this cycle") + end + end - describe "typo" do - let(:corrections) { ["Test::Fon", "Test::Flo"] } - let(:key) { "test.foo" } - - it do - expect(subject.message).to eq( - <<~ERROR_MESSAGE.chomp - Component 'test.foo' is not loadable. - Looking for Test::Foo. - - Did you mean? Test::Fon - Test::Flo - ERROR_MESSAGE - ) - end - end + context "with three components" do + it "generates cycle visualization" do + error = described_class.new(%w[alpha beta gamma]) + + expect(error.message).to include("These dependencies form a cycle:") + expect(error.message).to include("alpha ───► beta") + expect(error.message).to include("beta ───► gamma") + expect(error.message).to include("gamma ───► alpha") + expect(error.message).to include("You must break this cycle") + end + end + + context "with longer cycle" do + it "generates compact visualization" do + cycle = %w[service_a service_b service_c service_d service_e] + error = described_class.new(cycle) + + expect(error.message).to include("These dependencies form a cycle:") + expect(error.message).to include("service_a ───► service_b ───► service_c ───► service_d ───► service_e ───► service_a") + expect(error.message).to include("You must break this cycle") + end + end + + context "with empty cycle" do + it "handles empty cycle gracefully" do + error = described_class.new([]) + + expect(error.message).to include("These dependencies form a cycle:") + expect(error.message).to include("You must break this cycle") end end end