archunit 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (149) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +13 -0
  3. data/LICENSE +7 -0
  4. data/README.md +375 -0
  5. data/lib/archunit/common/assertion/empty_test_violation.rb +50 -0
  6. data/lib/archunit/common/assertion/violation.rb +14 -0
  7. data/lib/archunit/common/extraction/edge.rb +46 -0
  8. data/lib/archunit/common/extraction/graph.rb +73 -0
  9. data/lib/archunit/common/extraction/import_kind.rb +21 -0
  10. data/lib/archunit/common/filter.rb +46 -0
  11. data/lib/archunit/common/fluentapi/check_options.rb +48 -0
  12. data/lib/archunit/common/fluentapi/checkable.rb +70 -0
  13. data/lib/archunit/common/logging/check_logger.rb +124 -0
  14. data/lib/archunit/common/logging/logging_options.rb +57 -0
  15. data/lib/archunit/common/pattern.rb +99 -0
  16. data/lib/archunit/common/pattern_matching.rb +76 -0
  17. data/lib/archunit/common/projection/cycles/johnson_cycles.rb +103 -0
  18. data/lib/archunit/common/projection/cycles/tarjan_scc.rb +82 -0
  19. data/lib/archunit/common/projection/edge_projections.rb +50 -0
  20. data/lib/archunit/common/projection/mapped_edge.rb +26 -0
  21. data/lib/archunit/common/projection/project_cycles.rb +92 -0
  22. data/lib/archunit/common/projection/project_edges.rb +69 -0
  23. data/lib/archunit/common/projection/project_to_nodes.rb +59 -0
  24. data/lib/archunit/common/projection/projected_edge.rb +35 -0
  25. data/lib/archunit/common/projection/projected_node.rb +36 -0
  26. data/lib/archunit/common/regex_factory.rb +96 -0
  27. data/lib/archunit/error/technical_error.rb +6 -0
  28. data/lib/archunit/error/user_error.rb +6 -0
  29. data/lib/archunit/extraction/enumerate_source_files.rb +131 -0
  30. data/lib/archunit/extraction/extract_dependencies.rb +61 -0
  31. data/lib/archunit/extraction/extract_graph.rb +89 -0
  32. data/lib/archunit/extraction/extract_imports.rb +197 -0
  33. data/lib/archunit/extraction/locate_project.rb +80 -0
  34. data/lib/archunit/extraction/resolve_import.rb +76 -0
  35. data/lib/archunit/extraction/resolved_import.rb +46 -0
  36. data/lib/archunit/files/assertion/custom_file_condition.rb +89 -0
  37. data/lib/archunit/files/assertion/cycle_free.rb +58 -0
  38. data/lib/archunit/files/assertion/depend_on_external_modules.rb +116 -0
  39. data/lib/archunit/files/assertion/depend_on_files.rb +110 -0
  40. data/lib/archunit/files/assertion/matching_files.rb +87 -0
  41. data/lib/archunit/files/extraction/extract_file_info.rb +84 -0
  42. data/lib/archunit/files/extraction/file_info.rb +46 -0
  43. data/lib/archunit/files/fluentapi/custom_file_condition.rb +73 -0
  44. data/lib/archunit/files/fluentapi/cycle_free_file_condition.rb +55 -0
  45. data/lib/archunit/files/fluentapi/depend_on_external_module_condition.rb +69 -0
  46. data/lib/archunit/files/fluentapi/depend_on_external_module_condition_builder.rb +36 -0
  47. data/lib/archunit/files/fluentapi/depend_on_file_condition.rb +80 -0
  48. data/lib/archunit/files/fluentapi/depend_on_file_condition_builder.rb +49 -0
  49. data/lib/archunit/files/fluentapi/file_condition_builder.rb +71 -0
  50. data/lib/archunit/files/fluentapi/file_rule_support.rb +24 -0
  51. data/lib/archunit/files/fluentapi/files.rb +31 -0
  52. data/lib/archunit/files/fluentapi/match_pattern_file_condition.rb +59 -0
  53. data/lib/archunit/files/fluentapi/match_pattern_file_condition_builder.rb +66 -0
  54. data/lib/archunit/files/fluentapi/negated_match_pattern_file_condition_builder.rb +16 -0
  55. data/lib/archunit/files/fluentapi/positive_match_pattern_file_condition_builder.rb +21 -0
  56. data/lib/archunit/graph/fluentapi/graph.rb +29 -0
  57. data/lib/archunit/graph/fluentapi/project_graph_builder.rb +106 -0
  58. data/lib/archunit/graph/projection/aggregate_edges.rb +48 -0
  59. data/lib/archunit/graph/projection/collapse_node.rb +33 -0
  60. data/lib/archunit/graph/projection/create_snapshot.rb +84 -0
  61. data/lib/archunit/graph/projection/folder_depth_collapse.rb +18 -0
  62. data/lib/archunit/graph/projection/graph_query_options.rb +81 -0
  63. data/lib/archunit/graph/projection/graph_report_edge.rb +50 -0
  64. data/lib/archunit/graph/projection/graph_report_node.rb +22 -0
  65. data/lib/archunit/graph/projection/graph_report_snapshot.rb +43 -0
  66. data/lib/archunit/graph/projection/graph_report_summary.rb +20 -0
  67. data/lib/archunit/graph/projection/node_selection.rb +111 -0
  68. data/lib/archunit/graph/projection/pattern_collapse.rb +24 -0
  69. data/lib/archunit/graph/rendering/csv_renderer.rb +30 -0
  70. data/lib/archunit/graph/rendering/d2_renderer.rb +30 -0
  71. data/lib/archunit/graph/rendering/dot_renderer.rb +51 -0
  72. data/lib/archunit/graph/rendering/escaping.rb +31 -0
  73. data/lib/archunit/graph/rendering/export_report.rb +29 -0
  74. data/lib/archunit/graph/rendering/graph_renderer.rb +54 -0
  75. data/lib/archunit/graph/rendering/html_document.rb +46 -0
  76. data/lib/archunit/graph/rendering/html_renderer.rb +102 -0
  77. data/lib/archunit/graph/rendering/json_renderer.rb +38 -0
  78. data/lib/archunit/graph/rendering/mermaid_renderer.rb +33 -0
  79. data/lib/archunit/graph/rendering/rendering_support.rb +24 -0
  80. data/lib/archunit/layers/assertion/layer_definition.rb +56 -0
  81. data/lib/archunit/layers/assertion/layer_dependencies.rb +91 -0
  82. data/lib/archunit/layers/assertion/layer_dependency_violation.rb +59 -0
  83. data/lib/archunit/layers/fluentapi/layer_definition_builder.rb +46 -0
  84. data/lib/archunit/layers/fluentapi/layer_dependency_rule_builder.rb +44 -0
  85. data/lib/archunit/layers/fluentapi/layered_architecture.rb +133 -0
  86. data/lib/archunit/layers/fluentapi/layered_architecture_values.rb +51 -0
  87. data/lib/archunit/layers/fluentapi/layers.rb +29 -0
  88. data/lib/archunit/metrics/assertion/custom_metric.rb +73 -0
  89. data/lib/archunit/metrics/assertion/metric_predicate.rb +66 -0
  90. data/lib/archunit/metrics/assertion/metric_threshold.rb +99 -0
  91. data/lib/archunit/metrics/assertion/metric_zone.rb +57 -0
  92. data/lib/archunit/metrics/calculation/count.rb +45 -0
  93. data/lib/archunit/metrics/calculation/distance.rb +84 -0
  94. data/lib/archunit/metrics/calculation/lcom.rb +103 -0
  95. data/lib/archunit/metrics/calculation/metric.rb +52 -0
  96. data/lib/archunit/metrics/calculation/numeric_value.rb +25 -0
  97. data/lib/archunit/metrics/extraction/distance_info.rb +60 -0
  98. data/lib/archunit/metrics/extraction/extract_distance_info.rb +50 -0
  99. data/lib/archunit/metrics/extraction/extract_project_info.rb +90 -0
  100. data/lib/archunit/metrics/extraction/metric_info.rb +159 -0
  101. data/lib/archunit/metrics/extraction/source_metrics_visitor.rb +278 -0
  102. data/lib/archunit/metrics/fluentapi/count_metrics_builder.rb +49 -0
  103. data/lib/archunit/metrics/fluentapi/custom_metric_builder.rb +30 -0
  104. data/lib/archunit/metrics/fluentapi/custom_metric_condition.rb +40 -0
  105. data/lib/archunit/metrics/fluentapi/distance_metrics_builder.rb +48 -0
  106. data/lib/archunit/metrics/fluentapi/lcom_metrics_builder.rb +39 -0
  107. data/lib/archunit/metrics/fluentapi/metric_measurement.rb +36 -0
  108. data/lib/archunit/metrics/fluentapi/metric_predicate_condition.rb +42 -0
  109. data/lib/archunit/metrics/fluentapi/metric_report_builder.rb +34 -0
  110. data/lib/archunit/metrics/fluentapi/metric_selection.rb +54 -0
  111. data/lib/archunit/metrics/fluentapi/metric_threshold_condition.rb +47 -0
  112. data/lib/archunit/metrics/fluentapi/metrics.rb +21 -0
  113. data/lib/archunit/metrics/fluentapi/metrics_builder.rb +136 -0
  114. data/lib/archunit/metrics/fluentapi/zone_condition.rb +40 -0
  115. data/lib/archunit/metrics/reporting/metrics_export_options.rb +57 -0
  116. data/lib/archunit/metrics/reporting/metrics_exporter.rb +130 -0
  117. data/lib/archunit/slices/assertion/adhere_to_diagram.rb +57 -0
  118. data/lib/archunit/slices/assertion/contain_dependency.rb +42 -0
  119. data/lib/archunit/slices/assertion/diagram_adherence_options.rb +37 -0
  120. data/lib/archunit/slices/assertion/slice_dependency_violation.rb +70 -0
  121. data/lib/archunit/slices/fluentapi/diagram_slice_condition.rb +65 -0
  122. data/lib/archunit/slices/fluentapi/diagram_source.rb +36 -0
  123. data/lib/archunit/slices/fluentapi/forbidden_slice_dependency_condition.rb +57 -0
  124. data/lib/archunit/slices/fluentapi/negative_slice_condition_builder.rb +27 -0
  125. data/lib/archunit/slices/fluentapi/positive_slice_condition_builder.rb +51 -0
  126. data/lib/archunit/slices/fluentapi/slice_scope_builder.rb +83 -0
  127. data/lib/archunit/slices/fluentapi/slices.rb +29 -0
  128. data/lib/archunit/slices/projection/slice_projection.rb +76 -0
  129. data/lib/archunit/slices/projection/slicing_projections.rb +98 -0
  130. data/lib/archunit/slices/uml/export_diagram.rb +70 -0
  131. data/lib/archunit/slices/uml/parse_diagram.rb +53 -0
  132. data/lib/archunit/slices/uml/plant_uml_dependency.rb +25 -0
  133. data/lib/archunit/slices/uml/plant_uml_diagram.rb +44 -0
  134. data/lib/archunit/testing/assert_passes.rb +24 -0
  135. data/lib/archunit/testing/assertion_failure.rb +22 -0
  136. data/lib/archunit/testing/color_utils.rb +66 -0
  137. data/lib/archunit/testing/layer_violation_formatter.rb +26 -0
  138. data/lib/archunit/testing/metric_violation_formatter.rb +58 -0
  139. data/lib/archunit/testing/minitest_adapter.rb +33 -0
  140. data/lib/archunit/testing/result_factory.rb +93 -0
  141. data/lib/archunit/testing/rspec_adapter.rb +74 -0
  142. data/lib/archunit/testing/slice_violation_formatter.rb +25 -0
  143. data/lib/archunit/testing/test_result.rb +26 -0
  144. data/lib/archunit/testing/test_violation.rb +23 -0
  145. data/lib/archunit/testing/violation_factory.rb +138 -0
  146. data/lib/archunit/testing.rb +39 -0
  147. data/lib/archunit/version.rb +5 -0
  148. data/lib/archunit.rb +110 -0
  149. metadata +251 -0
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../logging/logging_options'
4
+
5
+ module ArchUnit
6
+ module Common
7
+ module FluentApi
8
+ # Immutable options shared by every terminal rule check.
9
+ CheckOptions = Data.define(:allow_empty_tests, :logging, :clear_cache) do
10
+ def self.resolve(value)
11
+ return new if value.nil?
12
+ return value if value.is_a?(self)
13
+
14
+ raise ArgumentError, 'options must be a CheckOptions value or nil'
15
+ end
16
+
17
+ def initialize(allow_empty_tests: false, logging: nil, clear_cache: false)
18
+ validate_boolean(allow_empty_tests, :allow_empty_tests)
19
+ validate_logging(logging)
20
+ validate_boolean(clear_cache, :clear_cache)
21
+ super
22
+ end
23
+
24
+ def allow_empty_tests?
25
+ allow_empty_tests
26
+ end
27
+
28
+ def clear_cache?
29
+ clear_cache
30
+ end
31
+
32
+ private
33
+
34
+ def validate_boolean(value, attribute)
35
+ return if [true, false].include?(value)
36
+
37
+ raise ArgumentError, "#{attribute} must be true or false"
38
+ end
39
+
40
+ def validate_logging(value)
41
+ return if value.nil? || value.is_a?(Logging::LoggingOptions)
42
+
43
+ raise ArgumentError, 'logging must be a LoggingOptions value or nil'
44
+ end
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../assertion/violation'
4
+ require_relative '../assertion/empty_test_violation'
5
+ require_relative '../logging/check_logger'
6
+ require_relative 'check_options'
7
+
8
+ module ArchUnit
9
+ module Common
10
+ module FluentApi
11
+ # The shared execution contract implemented by every terminal rule.
12
+ module Checkable
13
+ def check(options = nil)
14
+ resolved_options = CheckOptions.resolve(options)
15
+ logger = Logging::CheckLogger.new(resolved_options.logging)
16
+ check_name = self.class.name || self.class.to_s
17
+ logger.start_check(check_name)
18
+ execute_check(resolved_options, logger, check_name)
19
+ ensure
20
+ logger&.close
21
+ end
22
+
23
+ private
24
+
25
+ def perform_check(_options)
26
+ raise NotImplementedError, "#{self.class} must implement #perform_check"
27
+ end
28
+
29
+ def execute_check(options, logger, check_name)
30
+ logger.log_progress("executing #{check_name}")
31
+ violations = validate_violations(perform_check(options))
32
+ log_violations(logger, violations)
33
+ logger.end_check(check_name, violation_count: violations.length)
34
+ violations
35
+ rescue StandardError, NotImplementedError => e
36
+ logger.end_check(check_name, error: e)
37
+ raise
38
+ end
39
+
40
+ def log_violations(logger, violations)
41
+ violations.each do |violation|
42
+ logger.log_violation(violation)
43
+ next unless violation.respond_to?(:metric_name) && violation.respond_to?(:value)
44
+
45
+ subject = violation.identifier if violation.respond_to?(:identifier)
46
+ logger.log_metric(name: violation.metric_name, value: violation.value, subject:)
47
+ end
48
+ end
49
+
50
+ def empty_test_violation(selected_items, filters:, negated:, options:)
51
+ unless selected_items.respond_to?(:empty?)
52
+ raise ArgumentError, 'selected_items must respond to empty?'
53
+ end
54
+ unless options.is_a?(CheckOptions)
55
+ raise ArgumentError, 'options must be a CheckOptions value'
56
+ end
57
+ return if !selected_items.empty? || options.allow_empty_tests?
58
+
59
+ [Assertion::EmptyTestViolation.new(filters:, is_negated: negated)]
60
+ end
61
+
62
+ def validate_violations(violations)
63
+ return violations if violations.is_a?(Array) && violations.all?(Assertion::Violation)
64
+
65
+ raise TypeError, 'check must return an Array of Violation values'
66
+ end
67
+ end
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,124 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fileutils'
4
+ require 'time'
5
+ require_relative 'logging_options'
6
+
7
+ module ArchUnit
8
+ module Common
9
+ module Logging
10
+ # A logger owned by one check invocation; no configuration or output state is global.
11
+ class CheckLogger
12
+ LEVEL_PRIORITIES = { debug: 0, info: 1, warn: 2, error: 3 }.freeze
13
+
14
+ attr_reader :log_path
15
+
16
+ def initialize(options = nil, clock: -> { Time.now.utc })
17
+ validate_options(options)
18
+ raise ArgumentError, 'clock must respond to call' unless clock.respond_to?(:call)
19
+
20
+ @options = options
21
+ @clock = clock
22
+ @file = nil
23
+ @log_path = nil
24
+ @mutex = Mutex.new
25
+ end
26
+
27
+ def start_check(check_name)
28
+ write(:info, "start check: #{label(check_name, 'check_name')}")
29
+ end
30
+
31
+ def end_check(check_name, violation_count: nil, error: nil)
32
+ name = label(check_name, 'check_name')
33
+ if error
34
+ write(:error, "end check: #{name} failed with #{error.class}: #{error.message}")
35
+ else
36
+ write(:info, "end check: #{name} (#{Integer(violation_count)} violations)")
37
+ end
38
+ end
39
+
40
+ def log_progress(message)
41
+ write(:info, "log progress: #{label(message, 'message')}")
42
+ end
43
+
44
+ def log_violation(violation)
45
+ raise ArgumentError, 'violation is required' if violation.nil?
46
+
47
+ write(:warn, "log violation: #{violation_description(violation)}")
48
+ end
49
+
50
+ def log_metric(name:, value:, subject: nil)
51
+ metric = label(name, 'name')
52
+ context = subject.nil? ? '' : " [#{subject}]"
53
+ write(:debug, "log metric: #{metric}=#{value}#{context}")
54
+ end
55
+
56
+ def close
57
+ @mutex.synchronize do
58
+ @file&.close
59
+ @file = nil
60
+ end
61
+ nil
62
+ end
63
+
64
+ private
65
+
66
+ def validate_options(value)
67
+ return if value.nil? || value.is_a?(LoggingOptions)
68
+
69
+ raise ArgumentError, 'options must be a LoggingOptions value or nil'
70
+ end
71
+
72
+ def write(level, message)
73
+ return if @options.nil? || !enabled?(level)
74
+
75
+ timestamp = current_time
76
+ line = "[#{timestamp.iso8601(6)}] [#{level.to_s.upcase}] #{message}\n"
77
+ @mutex.synchronize do
78
+ @options.io&.write(line)
79
+ output_file(timestamp)&.write(line)
80
+ end
81
+ nil
82
+ end
83
+
84
+ def enabled?(level)
85
+ LEVEL_PRIORITIES.fetch(level) >= LEVEL_PRIORITIES.fetch(@options.level)
86
+ end
87
+
88
+ def current_time
89
+ value = @clock.call
90
+ raise ArgumentError, 'clock must return a Time' unless value.is_a?(Time)
91
+
92
+ value.utc
93
+ end
94
+
95
+ def output_file(timestamp)
96
+ return unless @options.file_output?
97
+ return @file if @file
98
+
99
+ FileUtils.mkdir_p(@options.output_directory)
100
+ @log_path = File.join(
101
+ @options.output_directory,
102
+ "archunit-#{timestamp.strftime('%Y-%m-%d_%H-%M-%S-%6N')}.log"
103
+ ).freeze
104
+ @file = File.open(@log_path, @options.append ? 'ab' : 'wb')
105
+ end
106
+
107
+ def label(value, attribute)
108
+ text = value.to_s
109
+ return text unless text.empty?
110
+
111
+ raise ArgumentError, "#{attribute} must not be empty"
112
+ end
113
+
114
+ def violation_description(violation)
115
+ description = violation.class.name.to_s
116
+ description = violation.class.to_s if description.empty?
117
+ return description unless violation.respond_to?(:identifier)
118
+
119
+ "#{description} [#{violation.identifier}]"
120
+ end
121
+ end
122
+ end
123
+ end
124
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ArchUnit
4
+ module Common
5
+ module Logging
6
+ LOG_LEVELS = %i[debug info warn error].freeze
7
+ private_constant :LOG_LEVELS
8
+
9
+ # Immutable, per-check logging configuration. A nil CheckOptions#logging disables logging.
10
+ LoggingOptions = Data.define(:level, :io, :output_directory, :append) do
11
+ def initialize(level: :info, io: $stderr, output_directory: nil, append: false)
12
+ level = normalize_level(level)
13
+ validate_io(io)
14
+ output_directory = normalize_output_directory(output_directory)
15
+ validate_append(append)
16
+ super
17
+ end
18
+
19
+ def file_output?
20
+ !output_directory.nil?
21
+ end
22
+
23
+ private
24
+
25
+ def normalize_level(value)
26
+ normalized = value.respond_to?(:to_sym) ? value.to_sym : value
27
+ return normalized if LOG_LEVELS.include?(normalized)
28
+
29
+ raise ArgumentError, "level must be one of: #{LOG_LEVELS.join(', ')}"
30
+ end
31
+
32
+ def validate_io(value)
33
+ return if value.nil? || value.respond_to?(:write)
34
+
35
+ raise ArgumentError, 'io must respond to write or be nil'
36
+ end
37
+
38
+ def normalize_output_directory(value)
39
+ return if value.nil?
40
+
41
+ path = value.respond_to?(:to_path) ? value.to_path : value
42
+ unless path.is_a?(String) && !path.empty?
43
+ raise ArgumentError, 'output_directory must be a non-empty path or nil'
44
+ end
45
+
46
+ File.expand_path(path).freeze
47
+ end
48
+
49
+ def validate_append(value)
50
+ return if [true, false].include?(value)
51
+
52
+ raise ArgumentError, 'append must be true or false'
53
+ end
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,99 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ArchUnit
4
+ module Common
5
+ # Compiles user-facing glob strings into the regular expressions used by the kernel.
6
+ module Pattern
7
+ module_function
8
+
9
+ def compile(pattern)
10
+ case pattern
11
+ when String
12
+ compile_glob(pattern.tr('\\', '/'))
13
+ when Regexp
14
+ pattern.dup.freeze
15
+ else
16
+ raise ArgumentError, 'pattern must be a String glob or Regexp'
17
+ end
18
+ end
19
+
20
+ def valid?(pattern)
21
+ pattern.is_a?(String) || pattern.is_a?(Regexp)
22
+ end
23
+
24
+ def compile_glob(glob)
25
+ characters = glob.chars
26
+ source = +'\\A'
27
+ index = 0
28
+
29
+ while index < characters.length
30
+ fragment, index = compile_fragment(characters, index)
31
+ source << fragment
32
+ end
33
+
34
+ Regexp.new("#{source}\\z").freeze
35
+ end
36
+ private_class_method :compile_glob
37
+
38
+ def compile_fragment(characters, index)
39
+ case characters[index]
40
+ when '*'
41
+ compile_star(characters, index)
42
+ when '?'
43
+ ['[^/]', index + 1]
44
+ when '['
45
+ compile_character_class(characters, index)
46
+ else
47
+ [Regexp.escape(characters[index]), index + 1]
48
+ end
49
+ end
50
+ private_class_method :compile_fragment
51
+
52
+ def compile_star(characters, index)
53
+ return ['[^/]*', index + 1] unless characters[index + 1] == '*'
54
+
55
+ index += 2
56
+ index += 1 while characters[index] == '*'
57
+ return ['.*', index] unless characters[index] == '/'
58
+
59
+ ['(?:.*/)?', index + 1]
60
+ end
61
+ private_class_method :compile_star
62
+
63
+ def compile_character_class(characters, index)
64
+ closing_index = character_class_closing_index(characters, index)
65
+ return ['\\[', index + 1] if closing_index.nil? || closing_index == index + 1
66
+
67
+ content = characters[(index + 1)...closing_index]
68
+ return ['\\[', index + 1] if content == ['!']
69
+
70
+ [character_class_fragment(content), closing_index + 1]
71
+ end
72
+ private_class_method :compile_character_class
73
+
74
+ def character_class_closing_index(characters, index)
75
+ closing_offset = characters[(index + 1)..].index(']')
76
+ closing_offset && (index + closing_offset + 1)
77
+ end
78
+ private_class_method :character_class_closing_index
79
+
80
+ def character_class_fragment(content)
81
+ content = content.dup
82
+ negated = content.first == '!'
83
+ content.shift if negated
84
+
85
+ escaped_content = content.map { |character| escape_class_character(character) }.join
86
+ prefix = negated ? '^' : ''
87
+ "(?=[^/])[#{prefix}#{escaped_content}]"
88
+ end
89
+ private_class_method :character_class_fragment
90
+
91
+ def escape_class_character(character)
92
+ return "\\#{character}" if ['\\', '[', ']', '&', '^'].include?(character)
93
+
94
+ character
95
+ end
96
+ private_class_method :escape_class_character
97
+ end
98
+ end
99
+ end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'filter'
4
+
5
+ module ArchUnit
6
+ module Common
7
+ # Generic matching operations. The Filter selects which part of a candidate is tested.
8
+ module PatternMatching
9
+ module_function
10
+
11
+ def matches_pattern?(file_path, filter, class_name: nil)
12
+ validate_arguments(file_path, filter, class_name)
13
+ target = target_string(file_path, filter.target, class_name)
14
+ matches = regexp_matches?(filter, target)
15
+
16
+ matches && filter.exclusions.none? do |exclusion|
17
+ matches_pattern?(file_path, exclusion, class_name:)
18
+ end
19
+ end
20
+
21
+ def matches_all_patterns?(file_path, filters, class_name: nil)
22
+ filters.all? { |filter| matches_pattern?(file_path, filter, class_name:) }
23
+ end
24
+
25
+ def matches_any_pattern?(file_path, filters, class_name: nil)
26
+ filters.any? { |filter| matches_pattern?(file_path, filter, class_name:) }
27
+ end
28
+
29
+ def normalize_path(path)
30
+ path.tr('\\', '/')
31
+ end
32
+
33
+ def extract_filename(path)
34
+ normalize_path(path).split('/').last.to_s
35
+ end
36
+
37
+ def path_without_filename(path)
38
+ parts = normalize_path(path).split('/')
39
+ parts.pop
40
+ parts.join('/')
41
+ end
42
+
43
+ def validate_arguments(file_path, filter, class_name)
44
+ raise ArgumentError, 'file_path must be a String' unless file_path.is_a?(String)
45
+ raise ArgumentError, 'filter must be a Filter' unless filter.is_a?(Filter)
46
+ return if class_name.nil? || class_name.is_a?(String)
47
+
48
+ raise ArgumentError, 'class_name must be a String'
49
+ end
50
+ private_class_method :validate_arguments
51
+
52
+ def target_string(file_path, target, class_name)
53
+ case target
54
+ when :filename
55
+ extract_filename(file_path)
56
+ when :path
57
+ normalize_path(file_path)
58
+ when :path_without_filename
59
+ path_without_filename(file_path)
60
+ when :classname
61
+ class_name || raise(ArgumentError, 'class_name is required for a classname filter')
62
+ end
63
+ end
64
+ private_class_method :target_string
65
+
66
+ def regexp_matches?(filter, target)
67
+ match = filter.regexp.match(target)
68
+ return false unless match
69
+ return true if filter.matching == :partial
70
+
71
+ match.begin(0).zero? && match.end(0) == target.length
72
+ end
73
+ private_class_method :regexp_matches?
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'tarjan_scc'
4
+
5
+ module ArchUnit
6
+ module Common
7
+ module Projection
8
+ module Cycles
9
+ # Enumerates every elementary directed cycle once using Johnson's algorithm.
10
+ class JohnsonCycles
11
+ def self.call(adjacency)
12
+ new(adjacency).call
13
+ end
14
+
15
+ def initialize(adjacency)
16
+ @vertices = (adjacency.keys + adjacency.values.flatten).uniq.sort
17
+ @adjacency = @vertices.to_h do |vertex|
18
+ neighbours = adjacency.fetch(vertex, []).reject { |item| item == vertex }.uniq.sort
19
+ [vertex, neighbours]
20
+ end
21
+ @cycles = []
22
+ end
23
+
24
+ def call
25
+ lower_bound = @vertices.first
26
+
27
+ while lower_bound
28
+ component = next_component(lower_bound)
29
+ break unless component
30
+
31
+ start = component.min
32
+ prepare_search(component, start)
33
+ circuit(start)
34
+ lower_bound = @vertices.find { |vertex| vertex > start }
35
+ end
36
+
37
+ @cycles.map(&:freeze).freeze
38
+ end
39
+
40
+ private
41
+
42
+ def next_component(lower_bound)
43
+ remaining = @vertices.select { |vertex| vertex >= lower_bound }
44
+ TarjanScc.call(@adjacency, vertices: remaining)
45
+ .select { |component| component.length > 1 }
46
+ .min_by(&:min)
47
+ end
48
+
49
+ def prepare_search(component, start)
50
+ @component = component.to_set
51
+ @start = start
52
+ @stack = []
53
+ @blocked = Set.new
54
+ @blocked_by = Hash.new { |hash, vertex| hash[vertex] = Set.new }
55
+ end
56
+
57
+ def circuit(vertex)
58
+ @stack << vertex
59
+ @blocked.add(vertex)
60
+ found_cycle = explore_neighbours(vertex)
61
+ found_cycle ? unblock(vertex) : record_blockers(vertex)
62
+ @stack.pop
63
+ found_cycle
64
+ end
65
+
66
+ def explore_neighbours(vertex)
67
+ found_cycle = false
68
+ component_neighbours(vertex).each do |neighbour|
69
+ closes_cycle = record_cycle?(neighbour)
70
+ finds_cycle = !@blocked.include?(neighbour) && circuit(neighbour)
71
+ found_cycle = true if closes_cycle || finds_cycle
72
+ end
73
+ found_cycle
74
+ end
75
+
76
+ def record_cycle?(neighbour)
77
+ return false unless neighbour == @start
78
+
79
+ @cycles << @stack.dup
80
+ true
81
+ end
82
+
83
+ def unblock(vertex)
84
+ return unless @blocked.delete?(vertex)
85
+
86
+ dependants = @blocked_by.delete(vertex) || []
87
+ dependants.each { |dependant| unblock(dependant) }
88
+ end
89
+
90
+ def record_blockers(vertex)
91
+ component_neighbours(vertex).each do |neighbour|
92
+ @blocked_by[neighbour].add(vertex)
93
+ end
94
+ end
95
+
96
+ def component_neighbours(vertex)
97
+ @adjacency.fetch(vertex, []).select { |neighbour| @component.include?(neighbour) }
98
+ end
99
+ end
100
+ end
101
+ end
102
+ end
103
+ end
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ArchUnit
4
+ module Common
5
+ module Projection
6
+ module Cycles
7
+ # Finds strongly connected components in a directed adjacency map.
8
+ class TarjanScc
9
+ def self.call(adjacency, vertices: adjacency.keys)
10
+ new(adjacency, vertices).call
11
+ end
12
+
13
+ def initialize(adjacency, vertices)
14
+ @adjacency = adjacency
15
+ @vertices = vertices.to_a
16
+ @allowed = @vertices.to_set
17
+ @next_index = 0
18
+ @indices = {}
19
+ @lowlinks = {}
20
+ @stack = []
21
+ @on_stack = Set.new
22
+ @components = []
23
+ end
24
+
25
+ def call
26
+ @vertices.each { |vertex| visit(vertex) unless @indices.key?(vertex) }
27
+ @components.map(&:freeze).freeze
28
+ end
29
+
30
+ private
31
+
32
+ def visit(vertex)
33
+ index_vertex(vertex)
34
+
35
+ neighbours(vertex).each do |neighbour|
36
+ inspect_neighbour(vertex, neighbour)
37
+ end
38
+
39
+ extract_component(vertex) if @lowlinks.fetch(vertex) == @indices.fetch(vertex)
40
+ end
41
+
42
+ def index_vertex(vertex)
43
+ @indices[vertex] = @next_index
44
+ @lowlinks[vertex] = @next_index
45
+ @next_index += 1
46
+ @stack << vertex
47
+ @on_stack.add(vertex)
48
+ end
49
+
50
+ def inspect_neighbour(vertex, neighbour)
51
+ unless @indices.key?(neighbour)
52
+ visit(neighbour)
53
+ @lowlinks[vertex] = [@lowlinks.fetch(vertex), @lowlinks.fetch(neighbour)].min
54
+ return
55
+ end
56
+
57
+ return unless @on_stack.include?(neighbour)
58
+
59
+ @lowlinks[vertex] = [@lowlinks.fetch(vertex), @indices.fetch(neighbour)].min
60
+ end
61
+
62
+ def extract_component(root)
63
+ component = []
64
+
65
+ loop do
66
+ vertex = @stack.pop
67
+ @on_stack.delete(vertex)
68
+ component << vertex
69
+ break if vertex == root
70
+ end
71
+
72
+ @components << component.sort
73
+ end
74
+
75
+ def neighbours(vertex)
76
+ @adjacency.fetch(vertex, []).select { |neighbour| @allowed.include?(neighbour) }
77
+ end
78
+ end
79
+ end
80
+ end
81
+ end
82
+ end