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,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../../common/extraction/edge'
4
+ require_relative '../../common/pattern_matching'
5
+ require_relative '../../common/projection/mapped_edge'
6
+
7
+ module ArchUnit
8
+ module Slices
9
+ # Projections from file dependencies into architectural slices.
10
+ module Projection
11
+ # Immutable callable mapper that also exposes the selected internal slice names.
12
+ class SliceProjection
13
+ def initialize(&labeler)
14
+ raise ArgumentError, 'a slice labeler block is required' unless labeler
15
+
16
+ @labeler = labeler.freeze
17
+ freeze
18
+ end
19
+
20
+ def call(edge)
21
+ validate_edge(edge)
22
+ return if edge.source == edge.target
23
+
24
+ source_label = label_for(edge.source)
25
+ return unless source_label
26
+
27
+ target_label = edge.external ? edge.target : label_for(edge.target)
28
+ return unless target_label
29
+ return if !edge.external && source_label == target_label
30
+
31
+ Common::Projection::MappedEdge.new(source_label:, target_label:)
32
+ end
33
+
34
+ def slice_labels(graph)
35
+ labels = []
36
+ each_edge(graph) do |edge|
37
+ labels << label_for(edge.source)
38
+ labels << label_for(edge.target) unless edge.external
39
+ end
40
+ labels.compact.uniq.sort.freeze
41
+ end
42
+
43
+ def label_for(path)
44
+ unless path.is_a?(String) && !path.empty?
45
+ raise ArgumentError, 'path must be a non-empty String'
46
+ end
47
+
48
+ label = @labeler.call(Common::PatternMatching.normalize_path(path))
49
+ return if label.nil?
50
+ return label.dup.freeze if label.is_a?(String) && !label.empty?
51
+
52
+ raise TypeError, 'slice labelers must return a non-empty String or nil'
53
+ end
54
+
55
+ private
56
+
57
+ def each_edge(graph)
58
+ unless graph.respond_to?(:each)
59
+ raise ArgumentError, 'graph must be an enumerable of Edge values'
60
+ end
61
+
62
+ graph.each do |edge|
63
+ validate_edge(edge)
64
+ yield edge
65
+ end
66
+ end
67
+
68
+ def validate_edge(value)
69
+ return if value.is_a?(Common::Extraction::Edge)
70
+
71
+ raise ArgumentError, 'graph must contain only Edge values'
72
+ end
73
+ end
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'slice_projection'
4
+ require_relative '../../common/pattern_matching'
5
+ require_relative '../../common/regex_factory'
6
+
7
+ module ArchUnit
8
+ module Slices
9
+ # Projections from file dependencies into architectural slices.
10
+ module Projection
11
+ CAPTURE = '(**)'
12
+
13
+ module_function
14
+
15
+ def identity
16
+ SliceProjection.new { |path| path }
17
+ end
18
+
19
+ def slice_by_pattern(pattern, except: nil)
20
+ regexp = slice_pattern_regexp(pattern)
21
+ slice_by_regex(regexp, except:)
22
+ end
23
+
24
+ def slice_by_regex(regexp, except: nil)
25
+ regexp = immutable_regexp(regexp)
26
+ selector = Common::RegexFactory.path_matcher('**', except:)
27
+ SliceProjection.new do |path|
28
+ next unless Common::PatternMatching.matches_pattern?(path, selector)
29
+
30
+ match = regexp.match(path)
31
+ match && non_empty_capture(match[1])
32
+ end
33
+ end
34
+
35
+ def slice_by_file_suffix(labeling)
36
+ labels = immutable_suffix_labels(labeling)
37
+ SliceProjection.new do |path|
38
+ filename = path.split('/').last
39
+ extension = File.extname(filename)
40
+ stem = extension.empty? ? filename : filename.delete_suffix(extension)
41
+ labels.find { |suffix, _label| stem.end_with?(suffix) }&.last
42
+ end
43
+ end
44
+
45
+ def slice_pattern_regexp(pattern)
46
+ validate_slice_pattern(pattern)
47
+
48
+ escaped = Regexp.escape(pattern.tr('\\', '/'))
49
+ source = escaped.sub(Regexp.escape(CAPTURE), '([^/]+)')
50
+ .gsub('\\*\\*', '.*')
51
+ .gsub('\\*', '[^/]*')
52
+ Regexp.new(source).freeze
53
+ end
54
+ private_class_method :slice_pattern_regexp
55
+
56
+ def validate_slice_pattern(value)
57
+ unless value.is_a?(String) && !value.empty?
58
+ raise ArgumentError, 'pattern must be a non-empty String'
59
+ end
60
+ return if value.scan(CAPTURE).length == 1
61
+
62
+ raise ArgumentError, "pattern must contain exactly one #{CAPTURE} slice capture"
63
+ end
64
+ private_class_method :validate_slice_pattern
65
+
66
+ def immutable_regexp(value)
67
+ return Regexp.new(value.source, value.options).freeze if value.is_a?(Regexp)
68
+
69
+ raise ArgumentError, 'regexp must be a Regexp with a slice capture group'
70
+ end
71
+ private_class_method :immutable_regexp
72
+
73
+ def non_empty_capture(value)
74
+ value if value.is_a?(String) && !value.empty?
75
+ end
76
+ private_class_method :non_empty_capture
77
+
78
+ def immutable_suffix_labels(value)
79
+ unless value.is_a?(Hash) && !value.empty?
80
+ raise ArgumentError, 'labeling must be a non-empty Hash of suffixes to slice names'
81
+ end
82
+
83
+ labels = value.map do |suffix, label|
84
+ [non_empty_string(suffix, :suffix), non_empty_string(label, :slice_name)]
85
+ end
86
+ labels.sort_by { |suffix, _label| -suffix.length }.freeze
87
+ end
88
+ private_class_method :immutable_suffix_labels
89
+
90
+ def non_empty_string(value, attribute)
91
+ return value.dup.freeze if value.is_a?(String) && !value.empty?
92
+
93
+ raise ArgumentError, "#{attribute} must be a non-empty String"
94
+ end
95
+ private_class_method :non_empty_string
96
+ end
97
+ end
98
+ end
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fileutils'
4
+ require_relative '../../common/projection/projected_edge'
5
+
6
+ module ArchUnit
7
+ module Slices
8
+ # Parsing and rendering of the supported PlantUML component-diagram subset.
9
+ module Uml
10
+ # Stable PlantUML generation and UTF-8 file export for projected slice graphs.
11
+ module PlantUmlRenderer
12
+ module_function
13
+
14
+ def render(edges, components: [])
15
+ validate_edges(edges)
16
+ components = component_names(edges, components)
17
+ lines = ['@startuml']
18
+ lines.concat(components.map { |name| " component [#{plant_uml_name(name)}]" })
19
+ lines.concat(sorted_edges(edges).map do |edge|
20
+ " [#{plant_uml_name(edge.source_label)}] --> " \
21
+ "[#{plant_uml_name(edge.target_label)}]"
22
+ end)
23
+ "#{lines.append('@enduml').join("\n")}\n"
24
+ end
25
+
26
+ def export(edges, output_path, components: [])
27
+ path = normalized_path(output_path)
28
+ FileUtils.mkdir_p(File.dirname(path)) unless File.dirname(path) == '.'
29
+ File.binwrite(path, render(edges, components:).encode(Encoding::UTF_8))
30
+ nil
31
+ end
32
+
33
+ def component_names(edges, values)
34
+ edge_names = edges.flat_map { |edge| [edge.source_label, edge.target_label] }
35
+ names = [*Array(values), *edge_names]
36
+ names.map { |name| plant_uml_name(name) }.uniq.sort.freeze
37
+ end
38
+ private_class_method :component_names
39
+
40
+ def sorted_edges(edges)
41
+ edges.sort_by { |edge| [edge.source_label, edge.target_label] }
42
+ end
43
+ private_class_method :sorted_edges
44
+
45
+ def validate_edges(value)
46
+ return if value.is_a?(Array) && value.all?(Common::Projection::ProjectedEdge)
47
+
48
+ raise ArgumentError, 'edges must be an Array of ProjectedEdge values'
49
+ end
50
+ private_class_method :validate_edges
51
+
52
+ def plant_uml_name(value)
53
+ valid = value.is_a?(String) && !value.strip.empty? && !value.match?(/[\]\r\n]/)
54
+ return value.strip if valid
55
+
56
+ raise ArgumentError, 'PlantUML component names must be non-empty and cannot contain ]'
57
+ end
58
+ private_class_method :plant_uml_name
59
+
60
+ def normalized_path(value)
61
+ value = value.to_path if value.respond_to?(:to_path)
62
+ return value if value.is_a?(String) && !value.empty?
63
+
64
+ raise ArgumentError, 'output_path must be a non-empty path'
65
+ end
66
+ private_class_method :normalized_path
67
+ end
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'plant_uml_diagram'
4
+
5
+ module ArchUnit
6
+ module Slices
7
+ # Parsing and rendering of the supported PlantUML component-diagram subset.
8
+ module Uml
9
+ # Line-based parser for PlantUML component declarations and directed arrows.
10
+ module PlantUmlParser
11
+ COMPONENT = /\Acomponent\s+\[([^\]]+)\]/i
12
+ DEPENDENCY = /\A\[([^\]]+)\]\s*-{1,2}>\s*\[([^\]]+)\]/
13
+
14
+ module_function
15
+
16
+ def parse(text)
17
+ unless text.is_a?(String) && !text.strip.empty?
18
+ raise ArgumentError, 'diagram text must be a non-empty String'
19
+ end
20
+
21
+ components = []
22
+ dependencies = []
23
+ text.each_line do |line|
24
+ parse_line(line, components, dependencies)
25
+ end
26
+ PlantUmlDiagram.new(components:, dependencies:)
27
+ end
28
+
29
+ def parse_line(line, components, dependencies)
30
+ line = uncommented(line)
31
+ return if line.empty? || line.start_with?('@')
32
+
33
+ if (match = COMPONENT.match(line))
34
+ components << match[1]
35
+ elsif (match = DEPENDENCY.match(line))
36
+ dependency = PlantUmlDependency.new(source: match[1], target: match[2])
37
+ dependencies << dependency
38
+ components.push(dependency.source, dependency.target)
39
+ end
40
+ end
41
+ private_class_method :parse_line
42
+
43
+ def uncommented(line)
44
+ stripped = line.strip
45
+ return '' if stripped.start_with?("'", '//')
46
+
47
+ stripped.split("'", 2).first.to_s.strip
48
+ end
49
+ private_class_method :uncommented
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ArchUnit
4
+ module Slices
5
+ # Parsing and rendering of the supported PlantUML component-diagram subset.
6
+ module Uml
7
+ # One allowed directed component dependency from a PlantUML diagram.
8
+ PlantUmlDependency = Data.define(:source, :target) do
9
+ def initialize(source:, target:)
10
+ source = component_name(source, :source)
11
+ target = component_name(target, :target)
12
+ super
13
+ end
14
+
15
+ private
16
+
17
+ def component_name(value, attribute)
18
+ return value.strip.freeze if value.is_a?(String) && !value.strip.empty?
19
+
20
+ raise ArgumentError, "#{attribute} must be a non-empty component name"
21
+ end
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'plant_uml_dependency'
4
+
5
+ module ArchUnit
6
+ module Slices
7
+ # Parsing and rendering of the supported PlantUML component-diagram subset.
8
+ module Uml
9
+ # Immutable component names and allowed directed dependencies parsed from PlantUML.
10
+ PlantUmlDiagram = Data.define(:components, :dependencies) do
11
+ def initialize(components: [], dependencies: [])
12
+ dependencies = immutable_dependencies(dependencies)
13
+ components = immutable_components(components, dependencies)
14
+ super
15
+ end
16
+
17
+ def allows?(source, target)
18
+ dependencies.include?(PlantUmlDependency.new(source:, target:))
19
+ end
20
+
21
+ private
22
+
23
+ def immutable_dependencies(values)
24
+ dependencies = Array(values).dup
25
+ unless dependencies.all?(PlantUmlDependency)
26
+ raise ArgumentError, 'dependencies must contain only PlantUmlDependency values'
27
+ end
28
+
29
+ dependencies.uniq.freeze
30
+ end
31
+
32
+ def immutable_components(values, dependencies)
33
+ dependency_names = dependencies.flat_map { |item| [item.source, item.target] }
34
+ components = [*Array(values), *dependency_names]
35
+ unless components.all? { |name| name.is_a?(String) && !name.strip.empty? }
36
+ raise ArgumentError, 'components must contain only non-empty Strings'
37
+ end
38
+
39
+ components.map { |name| name.strip.freeze }.uniq.freeze
40
+ end
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../common/fluentapi/checkable'
4
+ require_relative 'assertion_failure'
5
+ require_relative 'result_factory'
6
+
7
+ # Public framework-neutral architecture assertion entry point.
8
+ module ArchUnit
9
+ # Violation presentation and test-framework integration.
10
+ module Testing
11
+ module_function
12
+
13
+ def assert_passes(rule, options = nil)
14
+ result = result_for(rule, options)
15
+ return if result.passed?
16
+
17
+ raise AssertionFailure, result
18
+ end
19
+ end
20
+
21
+ def self.assert_passes(rule, options = nil)
22
+ Testing.assert_passes(rule, options)
23
+ end
24
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'test_result'
4
+
5
+ module ArchUnit
6
+ # Violation presentation and test-framework integration.
7
+ module Testing
8
+ # Framework-neutral assertion failure used by the documented fallback helper.
9
+ class AssertionFailure < StandardError
10
+ attr_reader :result
11
+
12
+ def initialize(result)
13
+ unless result.is_a?(TestResult) && result.failed?
14
+ raise ArgumentError, 'result must be a failed TestResult'
15
+ end
16
+
17
+ @result = result
18
+ super(result.message)
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ArchUnit
4
+ # Violation presentation and test-framework integration.
5
+ module Testing
6
+ # ANSI colour helpers that degrade to plain text outside capable terminals.
7
+ class ColorUtils
8
+ RESET = "\e[0m"
9
+
10
+ class << self
11
+ def supported?(output: $stdout, environment: ENV)
12
+ return false unless environment['NO_COLOR'].to_s.empty?
13
+ return false if environment['TERM'] == 'dumb'
14
+
15
+ output.respond_to?(:tty?) && output.tty?
16
+ end
17
+
18
+ def red(text, enabled: supported?)
19
+ wrap('31', text, enabled:)
20
+ end
21
+
22
+ def green(text, enabled: supported?)
23
+ wrap('32', text, enabled:)
24
+ end
25
+
26
+ def yellow(text, enabled: supported?)
27
+ wrap('33', text, enabled:)
28
+ end
29
+
30
+ def blue(text, enabled: supported?)
31
+ wrap('34', text, enabled:)
32
+ end
33
+
34
+ def magenta(text, enabled: supported?)
35
+ wrap('35', text, enabled:)
36
+ end
37
+
38
+ def cyan(text, enabled: supported?)
39
+ wrap('36', text, enabled:)
40
+ end
41
+
42
+ def bold(text, enabled: supported?)
43
+ wrap('1', text, enabled:)
44
+ end
45
+
46
+ def dim(text, enabled: supported?)
47
+ wrap('2', text, enabled:)
48
+ end
49
+
50
+ private
51
+
52
+ def wrap(code, text, enabled:)
53
+ raise ArgumentError, 'text must be a String' unless text.is_a?(String)
54
+ unless [true, false].include?(enabled)
55
+ raise ArgumentError, 'enabled must be true or false'
56
+ end
57
+ return text unless enabled
58
+
59
+ "\e[#{code}m#{text}#{RESET}"
60
+ end
61
+ end
62
+
63
+ private_class_method :new
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ArchUnit
4
+ module Testing
5
+ # Human-readable presentation for structured layer violations.
6
+ module LayerViolationFormatter
7
+ private
8
+
9
+ def layer_dependency(violation)
10
+ edge = violation.dependency
11
+ TestViolation.new(
12
+ message: 'Layer dependency violation',
13
+ details: "Layer '#{violation.source_layer}' #{layer_relationship(violation)} " \
14
+ "'#{violation.target_layer}' via '#{edge.source_label}' -> " \
15
+ "'#{edge.target_label}'."
16
+ )
17
+ end
18
+
19
+ def layer_relationship(violation)
20
+ return 'depends on forbidden layer' if violation.rule == :may_not_depend_on_layers
21
+
22
+ 'depends on layer outside its allowlist'
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ArchUnit
4
+ module Testing
5
+ # Formatting helpers for structured metric violations.
6
+ module MetricViolationFormatter
7
+ FORMATTERS = {
8
+ Metrics::Assertion::CustomMetricViolation => :custom_metric,
9
+ Metrics::Assertion::MetricPredicateViolation => :metric_predicate,
10
+ Metrics::Assertion::MetricThresholdViolation => :metric_threshold,
11
+ Metrics::Assertion::MetricZoneViolation => :metric_zone
12
+ }.freeze
13
+
14
+ private
15
+
16
+ def custom_metric(violation)
17
+ TestViolation.new(
18
+ message: 'Custom metric violation',
19
+ details: "Class '#{violation.class_info.name}' in '#{violation.class_info.file_path}' " \
20
+ "has #{violation.metric_name}=#{violation.value.inspect}; " \
21
+ "#{violation.description}."
22
+ )
23
+ end
24
+
25
+ def metric_zone(violation)
26
+ zone = violation.zone == :pain ? 'pain' : 'uselessness'
27
+ TestViolation.new(
28
+ message: 'Metric zone violation',
29
+ details: "File '#{violation.distance_info.path}' is in the zone of #{zone} " \
30
+ "(abstractness=#{format('%.2f', violation.abstractness)}, " \
31
+ "instability=#{format('%.2f', violation.instability)})."
32
+ )
33
+ end
34
+
35
+ def metric_threshold(violation)
36
+ expectation = {
37
+ below: 'below', above: 'above', equal: 'equal to',
38
+ below_or_equal: 'below or equal to', above_or_equal: 'above or equal to'
39
+ }.fetch(violation.comparison)
40
+ TestViolation.new(
41
+ message: 'Metric threshold violation',
42
+ details: "Metric subject '#{violation.identifier}' has " \
43
+ "#{violation.metric_name}=#{violation.value.inspect}; " \
44
+ "expected #{expectation} #{violation.threshold.inspect}."
45
+ )
46
+ end
47
+
48
+ def metric_predicate(violation)
49
+ TestViolation.new(
50
+ message: 'Metric predicate violation',
51
+ details: "Metric subject '#{violation.identifier}' has " \
52
+ "#{violation.metric_name}=#{violation.value.inspect}; " \
53
+ 'the should_satisfy predicate returned false.'
54
+ )
55
+ end
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ArchUnit
4
+ module Testing
5
+ # Native Minitest assertion methods mixed in only when Minitest is already loaded.
6
+ module MinitestAssertions
7
+ def assert_passes(rule, options = nil)
8
+ result = ArchUnit::Testing.result_for(rule, options)
9
+ assert(result.passed?, result.message)
10
+ end
11
+ end
12
+
13
+ # Silently installs the helper without making Minitest a runtime dependency.
14
+ module MinitestAdapter
15
+ module_function
16
+
17
+ def install!
18
+ return unless defined?(::Minitest::Assertions)
19
+ return if installed?
20
+
21
+ ::Minitest::Assertions.include(MinitestAssertions)
22
+ nil
23
+ end
24
+
25
+ def installed?
26
+ defined?(::Minitest::Assertions) &&
27
+ ::Minitest::Assertions.include?(MinitestAssertions)
28
+ end
29
+ end
30
+ end
31
+ end
32
+
33
+ ArchUnit::Testing::MinitestAdapter.install!