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,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ArchUnit
4
+ module GraphReporting
5
+ module Rendering
6
+ # Static offline document shell used by the HTML graph renderer.
7
+ module HtmlDocument
8
+ TEMPLATE = <<~HTML
9
+ <!DOCTYPE html>
10
+ <html lang="en">
11
+ <head>
12
+ <meta charset="UTF-8" />
13
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
14
+ <title>%<title>s</title>
15
+ <style>
16
+ :root { color-scheme: light; font-family: Inter, Arial, sans-serif; }
17
+ body { margin: 0; color: #172033; background: #f5f7fb; }
18
+ header { padding: 28px 36px; color: white; background: #13294b; }
19
+ header h1 { margin: 0 0 6px; font-size: 28px; }
20
+ header p { margin: 0; color: #d7e3f4; }
21
+ main { max-width: 1180px; margin: 0 auto; padding: 28px 36px 48px; }
22
+ h2 { margin-top: 32px; font-size: 20px; }
23
+ .summary { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; }
24
+ .metric { padding: 16px; border: 1px solid #d8deea; border-radius: 8px; background: white; }
25
+ .metric strong { display: block; font-size: 26px; color: #13294b; }
26
+ table { width: 100%%; border-collapse: collapse; background: white; }
27
+ th, td { padding: 9px 11px; border: 1px solid #d8deea; text-align: left; }
28
+ th { background: #eaf0f8; }
29
+ code, pre { font-family: Consolas, "SFMono-Regular", monospace; }
30
+ pre { overflow: auto; padding: 16px; color: #e7edf7; background: #101827; border-radius: 8px; }
31
+ details { margin: 12px 0; }
32
+ summary { cursor: pointer; font-weight: 700; }
33
+ .empty { padding: 16px; border: 1px solid #d8deea; background: white; }
34
+ @media (max-width: 760px) { .summary { grid-template-columns: repeat(2, 1fr); } }
35
+ </style>
36
+ </head>
37
+ <body>
38
+ <header><h1>%<title>s</h1><p>Generated by ArchUnitRuby graph reporting</p></header>
39
+ <main>%<body>s</main>
40
+ </body>
41
+ </html>
42
+ HTML
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,102 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'd2_renderer'
4
+ require_relative 'dot_renderer'
5
+ require_relative 'escaping'
6
+ require_relative 'html_document'
7
+ require_relative 'json_renderer'
8
+ require_relative 'mermaid_renderer'
9
+ require_relative 'rendering_support'
10
+
11
+ module ArchUnit
12
+ module GraphReporting
13
+ module Rendering
14
+ # Renders a complete offline HTML report with no external resources.
15
+ module HtmlRenderer
16
+ module_function
17
+
18
+ def render(snapshot)
19
+ snapshot = RenderingSupport.validate_snapshot(snapshot)
20
+ title = Escaping.html(snapshot.title)
21
+ format(HtmlDocument::TEMPLATE, title:, body: report_body(snapshot))
22
+ end
23
+
24
+ def report_body(snapshot)
25
+ [
26
+ summary_section(snapshot), node_section(snapshot), dependency_section(snapshot),
27
+ source_section(snapshot)
28
+ ].join("\n")
29
+ end
30
+ private_class_method :report_body
31
+
32
+ def summary_section(snapshot)
33
+ summary = snapshot.summary
34
+ <<~HTML
35
+ <section class="summary" aria-label="Graph summary">
36
+ #{metric(summary.node_count, 'Nodes')}
37
+ #{metric(summary.edge_count, 'Aggregated edges')}
38
+ #{metric(summary.raw_edge_count, 'Raw edges')}
39
+ #{metric(summary.external_edge_count, 'External edges')}
40
+ </section>
41
+ HTML
42
+ end
43
+ private_class_method :summary_section
44
+
45
+ def metric(value, label)
46
+ "<div class=\"metric\"><strong>#{value}</strong>#{Escaping.html(label)}</div>"
47
+ end
48
+ private_class_method :metric
49
+
50
+ def node_section(snapshot)
51
+ items = snapshot.nodes.map do |node|
52
+ "<li><code>#{Escaping.html(node.label)}</code></li>"
53
+ end.join
54
+ "<section><h2>Nodes</h2><ul>#{items}</ul></section>"
55
+ end
56
+ private_class_method :node_section
57
+
58
+ def dependency_section(snapshot)
59
+ return empty_dependency_section if snapshot.edges.empty?
60
+
61
+ rows = snapshot.edges.map { |edge| dependency_row(edge) }.join
62
+ <<~HTML
63
+ <section><h2>Dependencies</h2><table>
64
+ <thead><tr><th>Source</th><th>Target</th><th>Count</th><th>External</th><th>Import kinds</th></tr></thead>
65
+ <tbody>#{rows}</tbody>
66
+ </table></section>
67
+ HTML
68
+ end
69
+ private_class_method :dependency_section
70
+
71
+ def dependency_row(edge)
72
+ values = [
73
+ edge.source, edge.target, edge.count,
74
+ edge.external ? 'yes' : 'no', edge.import_kinds.join(', ')
75
+ ]
76
+ "<tr>#{values.map { |value| "<td>#{Escaping.html(value)}</td>" }.join}</tr>"
77
+ end
78
+ private_class_method :dependency_row
79
+
80
+ def empty_dependency_section
81
+ '<section><h2>Dependencies</h2><div class="empty">' \
82
+ 'No dependency edges matched this graph query.</div></section>'
83
+ end
84
+ private_class_method :empty_dependency_section
85
+
86
+ def source_section(snapshot)
87
+ sources = {
88
+ 'Mermaid' => MermaidRenderer.render(snapshot),
89
+ 'DOT' => DotRenderer.render(snapshot),
90
+ 'D2' => D2Renderer.render(snapshot),
91
+ 'JSON snapshot' => JsonRenderer.render(snapshot)
92
+ }
93
+ details = sources.map do |name, source|
94
+ "<details><summary>#{name}</summary><pre>#{Escaping.html(source)}</pre></details>"
95
+ end.join
96
+ "<section><h2>Portable sources</h2>#{details}</section>"
97
+ end
98
+ private_class_method :source_section
99
+ end
100
+ end
101
+ end
102
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require_relative 'rendering_support'
5
+
6
+ module ArchUnit
7
+ module GraphReporting
8
+ module Rendering
9
+ # Renders the complete graph snapshot as stable, formatted JSON.
10
+ module JsonRenderer
11
+ module_function
12
+
13
+ def render(snapshot)
14
+ snapshot = RenderingSupport.validate_snapshot(snapshot)
15
+ JSON.pretty_generate(snapshot_hash(snapshot))
16
+ end
17
+
18
+ def snapshot_hash(snapshot)
19
+ {
20
+ title: snapshot.title,
21
+ nodes: snapshot.nodes.map { |node| { id: node.id, label: node.label } },
22
+ edges: snapshot.edges.map { |edge| edge_hash(edge) },
23
+ summary: snapshot.summary.to_h
24
+ }
25
+ end
26
+ private_class_method :snapshot_hash
27
+
28
+ def edge_hash(edge)
29
+ {
30
+ source: edge.source, target: edge.target, count: edge.count,
31
+ external: edge.external, import_kinds: edge.import_kinds
32
+ }
33
+ end
34
+ private_class_method :edge_hash
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'escaping'
4
+ require_relative 'rendering_support'
5
+
6
+ module ArchUnit
7
+ module GraphReporting
8
+ module Rendering
9
+ # Renders a Mermaid flowchart from a report snapshot.
10
+ module MermaidRenderer
11
+ module_function
12
+
13
+ def render(snapshot)
14
+ snapshot = RenderingSupport.validate_snapshot(snapshot)
15
+ ids = RenderingSupport.node_ids(snapshot)
16
+ lines = ["%% #{Escaping.single_line(snapshot.title)}", 'flowchart LR']
17
+ snapshot.nodes.each do |node|
18
+ lines << " #{node.id}[\"#{Escaping.mermaid_label(node.label)}\"]"
19
+ end
20
+ snapshot.edges.each { |edge| lines << edge_line(edge, ids) }
21
+ lines.join("\n")
22
+ end
23
+
24
+ def edge_line(edge, ids)
25
+ arrow = edge.external ? '-.->' : '-->'
26
+ label = edge.count > 1 ? "|#{edge.count}|" : ''
27
+ " #{ids.fetch(edge.source)} #{arrow}#{label} #{ids.fetch(edge.target)}"
28
+ end
29
+ private_class_method :edge_line
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../projection/graph_report_snapshot'
4
+
5
+ module ArchUnit
6
+ module GraphReporting
7
+ module Rendering
8
+ # Shared validation for renderers that accept only completed snapshots.
9
+ module RenderingSupport
10
+ module_function
11
+
12
+ def validate_snapshot(value)
13
+ return value if value.is_a?(Projection::GraphReportSnapshot)
14
+
15
+ raise ArgumentError, 'snapshot must be a GraphReportSnapshot'
16
+ end
17
+
18
+ def node_ids(snapshot)
19
+ snapshot.nodes.to_h { |node| [node.label, node.id] }
20
+ end
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../../common/filter'
4
+ require_relative '../../common/pattern_matching'
5
+
6
+ module ArchUnit
7
+ module Layers
8
+ # Pure assertions over dependencies between named architectural layers.
9
+ module Assertion
10
+ # Immutable association between a layer name and its file selectors.
11
+ class LayerDefinition
12
+ attr_reader :name, :filters
13
+
14
+ def initialize(name:, filters:)
15
+ @name = layer_name(name)
16
+ @filters = filter_values(filters)
17
+ freeze
18
+ end
19
+
20
+ def matches?(file_path)
21
+ Common::PatternMatching.matches_any_pattern?(file_path, filters)
22
+ end
23
+
24
+ def with_filter(filter)
25
+ self.class.new(name:, filters: [*filters, filter])
26
+ end
27
+
28
+ def ==(other)
29
+ other.is_a?(self.class) && name == other.name && filters == other.filters
30
+ end
31
+ alias eql? ==
32
+
33
+ def hash
34
+ [self.class, name, filters].hash
35
+ end
36
+
37
+ private
38
+
39
+ def layer_name(value)
40
+ return value.dup.freeze if value.is_a?(String) && !value.strip.empty?
41
+
42
+ raise ArgumentError, 'name must be a non-empty String'
43
+ end
44
+
45
+ def filter_values(values)
46
+ filters = Array(values).dup
47
+ unless !filters.empty? && filters.all?(Common::Filter)
48
+ raise ArgumentError, 'filters must contain at least one Filter'
49
+ end
50
+
51
+ filters.freeze
52
+ end
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../../common/projection/projected_edge'
4
+ require_relative 'layer_definition'
5
+ require_relative 'layer_dependency_violation'
6
+
7
+ module ArchUnit
8
+ module Layers
9
+ # Pure assertions over dependencies between named architectural layers.
10
+ module Assertion
11
+ module_function
12
+
13
+ def gather_layer_dependency_violations(
14
+ edges, layers, allowed_dependencies, forbidden_dependencies
15
+ )
16
+ validate_arguments(edges, layers, allowed_dependencies, forbidden_dependencies)
17
+
18
+ edges.filter_map do |edge|
19
+ dependency_violation(edge, layers, allowed_dependencies, forbidden_dependencies)
20
+ end
21
+ end
22
+
23
+ def dependency_violation(edge, layers, allowed_dependencies, forbidden_dependencies)
24
+ source_layer = find_layer(edge.source_label, layers)
25
+ target_layer = find_layer(edge.target_label, layers)
26
+ return unless source_layer && target_layer
27
+ return if source_layer.name == target_layer.name
28
+
29
+ rule = violated_rule(
30
+ source_layer.name, target_layer.name, allowed_dependencies, forbidden_dependencies
31
+ )
32
+ return unless rule
33
+
34
+ build_violation(edge, source_layer, target_layer, rule)
35
+ end
36
+ private_class_method :dependency_violation
37
+
38
+ def build_violation(edge, source_layer, target_layer, rule)
39
+ LayerDependencyViolation.new(
40
+ dependency: edge, source_layer: source_layer.name, target_layer: target_layer.name, rule:
41
+ )
42
+ end
43
+ private_class_method :build_violation
44
+
45
+ def violated_rule(source, target, allowed_dependencies, forbidden_dependencies)
46
+ forbidden_targets = forbidden_dependencies.fetch(source, [])
47
+ return :may_not_depend_on_layers if forbidden_targets.include?(target)
48
+ return unless allowed_dependencies.key?(source)
49
+ return if allowed_dependencies.fetch(source).include?(target)
50
+
51
+ :may_only_depend_on_layers
52
+ end
53
+ private_class_method :violated_rule
54
+
55
+ def find_layer(file_path, layers)
56
+ layers.find { |layer| layer.matches?(file_path) }
57
+ end
58
+ private_class_method :find_layer
59
+
60
+ def validate_arguments(edges, layers, allowed_dependencies, forbidden_dependencies)
61
+ unless edges.is_a?(Array) && edges.all?(Common::Projection::ProjectedEdge)
62
+ raise ArgumentError, 'edges must be an Array of ProjectedEdge values'
63
+ end
64
+ unless layers.is_a?(Array) && layers.all?(LayerDefinition)
65
+ raise ArgumentError, 'layers must be an Array of LayerDefinition values'
66
+ end
67
+
68
+ validate_dependency_map(allowed_dependencies, :allowed_dependencies)
69
+ validate_dependency_map(forbidden_dependencies, :forbidden_dependencies)
70
+ end
71
+ private_class_method :validate_arguments
72
+
73
+ def validate_dependency_map(value, attribute)
74
+ valid = value.is_a?(Hash) && value.all? do |source, targets|
75
+ valid_layer_name?(source) && targets.is_a?(Array) && targets.all? do |target|
76
+ valid_layer_name?(target)
77
+ end
78
+ end
79
+ return if valid
80
+
81
+ raise ArgumentError, "#{attribute} must map layer names to Arrays of layer names"
82
+ end
83
+ private_class_method :validate_dependency_map
84
+
85
+ def valid_layer_name?(value)
86
+ value.is_a?(String) && !value.strip.empty?
87
+ end
88
+ private_class_method :valid_layer_name?
89
+ end
90
+ end
91
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../../common/assertion/violation'
4
+ require_relative '../../common/projection/projected_edge'
5
+
6
+ module ArchUnit
7
+ module Layers
8
+ # Pure assertions over dependencies between named architectural layers.
9
+ module Assertion
10
+ # Data describing one cross-layer edge rejected by a layer policy.
11
+ class LayerDependencyViolation < Common::Assertion::Violation
12
+ RULES = %i[may_only_depend_on_layers may_not_depend_on_layers].freeze
13
+
14
+ attr_reader :dependency, :source_layer, :target_layer, :rule
15
+
16
+ def initialize(dependency:, source_layer:, target_layer:, rule:)
17
+ @dependency = projected_edge(dependency)
18
+ @source_layer = layer_name(source_layer, :source_layer)
19
+ @target_layer = layer_name(target_layer, :target_layer)
20
+ @rule = rule_value(rule)
21
+ super()
22
+ end
23
+
24
+ def ==(other)
25
+ other.is_a?(self.class) && values == other.send(:values)
26
+ end
27
+ alias eql? ==
28
+
29
+ def hash
30
+ [self.class, *values].hash
31
+ end
32
+
33
+ private
34
+
35
+ def values
36
+ [dependency, source_layer, target_layer, rule]
37
+ end
38
+
39
+ def projected_edge(value)
40
+ return value if value.is_a?(Common::Projection::ProjectedEdge)
41
+
42
+ raise ArgumentError, 'dependency must be a ProjectedEdge'
43
+ end
44
+
45
+ def layer_name(value, attribute)
46
+ return value.dup.freeze if value.is_a?(String) && !value.strip.empty?
47
+
48
+ raise ArgumentError, "#{attribute} must be a non-empty String"
49
+ end
50
+
51
+ def rule_value(value)
52
+ return value if RULES.include?(value)
53
+
54
+ raise ArgumentError, "rule must be one of: #{RULES.join(', ')}"
55
+ end
56
+ end
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../../common/regex_factory'
4
+
5
+ module ArchUnit
6
+ module Layers
7
+ module FluentApi
8
+ # Immutable stage defining which files belong to one named layer.
9
+ class LayerDefinitionBuilder
10
+ attr_reader :architecture, :layer_name
11
+
12
+ def initialize(architecture, layer_name)
13
+ @architecture = architecture_value(architecture)
14
+ @layer_name = layer_name_value(layer_name)
15
+ freeze
16
+ end
17
+
18
+ def defined_by(pattern, except: nil)
19
+ add_filter(Common::RegexFactory.path_matcher(pattern, except:))
20
+ end
21
+
22
+ def defined_by_folder(pattern, except: nil)
23
+ add_filter(Common::RegexFactory.folder_matcher(pattern, except:))
24
+ end
25
+
26
+ private
27
+
28
+ def add_filter(filter)
29
+ architecture.__send__(:with_layer_filter, layer_name, filter)
30
+ end
31
+
32
+ def architecture_value(value)
33
+ return value if value.is_a?(LayeredArchitecture)
34
+
35
+ raise ArgumentError, 'architecture must be a LayeredArchitecture'
36
+ end
37
+
38
+ def layer_name_value(value)
39
+ return value.dup.freeze if value.is_a?(String) && !value.strip.empty?
40
+
41
+ raise ArgumentError, 'layer_name must be a non-empty String'
42
+ end
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ArchUnit
4
+ module Layers
5
+ module FluentApi
6
+ # Immutable stage adding an allowlist or blocklist policy for one layer.
7
+ class LayerDependencyRuleBuilder
8
+ attr_reader :architecture, :layer_name
9
+
10
+ def initialize(architecture, layer_name)
11
+ @architecture = architecture_value(architecture)
12
+ @layer_name = layer_name_value(layer_name)
13
+ freeze
14
+ end
15
+
16
+ def may_only_depend_on_layers(*layer_names)
17
+ architecture.__send__(:with_allowed_dependencies, layer_name, layer_names)
18
+ end
19
+
20
+ def may_not_depend_on_layers(*layer_names)
21
+ if layer_names.empty?
22
+ raise ArgumentError, 'may_not_depend_on_layers requires at least one layer name'
23
+ end
24
+
25
+ architecture.__send__(:with_forbidden_dependencies, layer_name, layer_names)
26
+ end
27
+
28
+ private
29
+
30
+ def architecture_value(value)
31
+ return value if value.is_a?(LayeredArchitecture)
32
+
33
+ raise ArgumentError, 'architecture must be a LayeredArchitecture'
34
+ end
35
+
36
+ def layer_name_value(value)
37
+ return value.dup.freeze if value.is_a?(String) && !value.strip.empty?
38
+
39
+ raise ArgumentError, 'layer_name must be a non-empty String'
40
+ end
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,133 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../../common/assertion/empty_test_violation'
4
+ require_relative '../../common/fluentapi/checkable'
5
+ require_relative '../../common/projection/edge_projections'
6
+ require_relative '../../common/projection/project_edges'
7
+ require_relative '../../common/projection/project_to_nodes'
8
+ require_relative '../../extraction/extract_graph'
9
+ require_relative '../assertion/layer_definition'
10
+ require_relative '../assertion/layer_dependencies'
11
+ require_relative 'layer_definition_builder'
12
+ require_relative 'layer_dependency_rule_builder'
13
+ require_relative 'layered_architecture_values'
14
+
15
+ module ArchUnit
16
+ module Layers
17
+ module FluentApi
18
+ # Immutable, executable named-layer dependency policy.
19
+ class LayeredArchitecture
20
+ include Common::FluentApi::Checkable
21
+ include LayeredArchitectureValues
22
+
23
+ attr_reader :project_locator, :layer_definitions,
24
+ :allowed_dependencies, :forbidden_dependencies
25
+
26
+ def initialize(
27
+ project_locator: nil, layer_definitions: [],
28
+ allowed_dependencies: {}, forbidden_dependencies: {}
29
+ )
30
+ @project_locator = immutable_project_locator(project_locator)
31
+ @layer_definitions = immutable_layer_definitions(layer_definitions)
32
+ @allowed_dependencies = immutable_dependency_map(allowed_dependencies)
33
+ @forbidden_dependencies = immutable_dependency_map(forbidden_dependencies)
34
+ freeze
35
+ end
36
+
37
+ def layer(name)
38
+ LayerDefinitionBuilder.new(self, validated_layer_name(name))
39
+ end
40
+
41
+ def where_layer(name)
42
+ name = validated_layer_name(name)
43
+ ensure_defined_layer!(name)
44
+ LayerDependencyRuleBuilder.new(self, name)
45
+ end
46
+
47
+ private
48
+
49
+ def with_layer_filter(name, filter)
50
+ definition = layer_definition(name)
51
+ updated = if definition
52
+ replace_definition(definition, definition.with_filter(filter))
53
+ else
54
+ [*layer_definitions, Assertion::LayerDefinition.new(name:, filters: [filter])]
55
+ end
56
+ copy(layer_definitions: updated)
57
+ end
58
+
59
+ def with_allowed_dependencies(source, targets)
60
+ targets = validated_target_layers(targets)
61
+ copy(allowed_dependencies: allowed_dependencies.merge(source => targets))
62
+ end
63
+
64
+ def with_forbidden_dependencies(source, targets)
65
+ targets = validated_target_layers(targets)
66
+ combined = [*forbidden_dependencies.fetch(source, []), *targets].uniq
67
+ copy(forbidden_dependencies: forbidden_dependencies.merge(source => combined))
68
+ end
69
+
70
+ def perform_check(options)
71
+ graph = ArchUnit::Extraction.extract_graph(project_locator, options:)
72
+ nodes = Common::Projection.project_to_nodes(graph)
73
+ edges = Common::Projection.project_edges(graph, Common::Projection.per_internal_edge)
74
+
75
+ empty_policy_violations(nodes, options) +
76
+ Assertion.gather_layer_dependency_violations(
77
+ edges, layer_definitions, allowed_dependencies, forbidden_dependencies
78
+ )
79
+ end
80
+
81
+ def empty_policy_violations(nodes, options)
82
+ return [] if options.allow_empty_tests?
83
+
84
+ policy_source_names.filter_map do |name|
85
+ definition = layer_definition(name)
86
+ next if nodes.any? { |node| definition.matches?(node.label) }
87
+
88
+ Common::Assertion::EmptyTestViolation.new(filters: definition.filters)
89
+ end
90
+ end
91
+
92
+ def policy_source_names
93
+ [*allowed_dependencies.keys, *forbidden_dependencies.keys].uniq
94
+ end
95
+
96
+ def copy(overrides = {})
97
+ self.class.new(
98
+ project_locator: overrides.fetch(:project_locator, project_locator),
99
+ layer_definitions: overrides.fetch(:layer_definitions, layer_definitions),
100
+ allowed_dependencies: overrides.fetch(:allowed_dependencies, allowed_dependencies),
101
+ forbidden_dependencies: overrides.fetch(:forbidden_dependencies, forbidden_dependencies)
102
+ )
103
+ end
104
+
105
+ def replace_definition(old_definition, new_definition)
106
+ layer_definitions.map do |definition|
107
+ definition.equal?(old_definition) ? new_definition : definition
108
+ end
109
+ end
110
+
111
+ def layer_definition(name)
112
+ layer_definitions.find { |definition| definition.name == name }
113
+ end
114
+
115
+ def ensure_defined_layer!(name)
116
+ return if layer_definition(name)
117
+
118
+ raise ArgumentError, "layer '#{name}' must be defined before it can have a policy"
119
+ end
120
+
121
+ def validated_target_layers(values)
122
+ names = Array(values).map { |value| validated_layer_name(value) }.uniq
123
+ undefined = names.reject { |name| layer_definition(name) }
124
+ unless undefined.empty?
125
+ raise ArgumentError, "undefined target layer: #{undefined.join(', ')}"
126
+ end
127
+
128
+ names
129
+ end
130
+ end
131
+ end
132
+ end
133
+ end