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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +13 -0
- data/LICENSE +7 -0
- data/README.md +375 -0
- data/lib/archunit/common/assertion/empty_test_violation.rb +50 -0
- data/lib/archunit/common/assertion/violation.rb +14 -0
- data/lib/archunit/common/extraction/edge.rb +46 -0
- data/lib/archunit/common/extraction/graph.rb +73 -0
- data/lib/archunit/common/extraction/import_kind.rb +21 -0
- data/lib/archunit/common/filter.rb +46 -0
- data/lib/archunit/common/fluentapi/check_options.rb +48 -0
- data/lib/archunit/common/fluentapi/checkable.rb +70 -0
- data/lib/archunit/common/logging/check_logger.rb +124 -0
- data/lib/archunit/common/logging/logging_options.rb +57 -0
- data/lib/archunit/common/pattern.rb +99 -0
- data/lib/archunit/common/pattern_matching.rb +76 -0
- data/lib/archunit/common/projection/cycles/johnson_cycles.rb +103 -0
- data/lib/archunit/common/projection/cycles/tarjan_scc.rb +82 -0
- data/lib/archunit/common/projection/edge_projections.rb +50 -0
- data/lib/archunit/common/projection/mapped_edge.rb +26 -0
- data/lib/archunit/common/projection/project_cycles.rb +92 -0
- data/lib/archunit/common/projection/project_edges.rb +69 -0
- data/lib/archunit/common/projection/project_to_nodes.rb +59 -0
- data/lib/archunit/common/projection/projected_edge.rb +35 -0
- data/lib/archunit/common/projection/projected_node.rb +36 -0
- data/lib/archunit/common/regex_factory.rb +96 -0
- data/lib/archunit/error/technical_error.rb +6 -0
- data/lib/archunit/error/user_error.rb +6 -0
- data/lib/archunit/extraction/enumerate_source_files.rb +131 -0
- data/lib/archunit/extraction/extract_dependencies.rb +61 -0
- data/lib/archunit/extraction/extract_graph.rb +89 -0
- data/lib/archunit/extraction/extract_imports.rb +197 -0
- data/lib/archunit/extraction/locate_project.rb +80 -0
- data/lib/archunit/extraction/resolve_import.rb +76 -0
- data/lib/archunit/extraction/resolved_import.rb +46 -0
- data/lib/archunit/files/assertion/custom_file_condition.rb +89 -0
- data/lib/archunit/files/assertion/cycle_free.rb +58 -0
- data/lib/archunit/files/assertion/depend_on_external_modules.rb +116 -0
- data/lib/archunit/files/assertion/depend_on_files.rb +110 -0
- data/lib/archunit/files/assertion/matching_files.rb +87 -0
- data/lib/archunit/files/extraction/extract_file_info.rb +84 -0
- data/lib/archunit/files/extraction/file_info.rb +46 -0
- data/lib/archunit/files/fluentapi/custom_file_condition.rb +73 -0
- data/lib/archunit/files/fluentapi/cycle_free_file_condition.rb +55 -0
- data/lib/archunit/files/fluentapi/depend_on_external_module_condition.rb +69 -0
- data/lib/archunit/files/fluentapi/depend_on_external_module_condition_builder.rb +36 -0
- data/lib/archunit/files/fluentapi/depend_on_file_condition.rb +80 -0
- data/lib/archunit/files/fluentapi/depend_on_file_condition_builder.rb +49 -0
- data/lib/archunit/files/fluentapi/file_condition_builder.rb +71 -0
- data/lib/archunit/files/fluentapi/file_rule_support.rb +24 -0
- data/lib/archunit/files/fluentapi/files.rb +31 -0
- data/lib/archunit/files/fluentapi/match_pattern_file_condition.rb +59 -0
- data/lib/archunit/files/fluentapi/match_pattern_file_condition_builder.rb +66 -0
- data/lib/archunit/files/fluentapi/negated_match_pattern_file_condition_builder.rb +16 -0
- data/lib/archunit/files/fluentapi/positive_match_pattern_file_condition_builder.rb +21 -0
- data/lib/archunit/graph/fluentapi/graph.rb +29 -0
- data/lib/archunit/graph/fluentapi/project_graph_builder.rb +106 -0
- data/lib/archunit/graph/projection/aggregate_edges.rb +48 -0
- data/lib/archunit/graph/projection/collapse_node.rb +33 -0
- data/lib/archunit/graph/projection/create_snapshot.rb +84 -0
- data/lib/archunit/graph/projection/folder_depth_collapse.rb +18 -0
- data/lib/archunit/graph/projection/graph_query_options.rb +81 -0
- data/lib/archunit/graph/projection/graph_report_edge.rb +50 -0
- data/lib/archunit/graph/projection/graph_report_node.rb +22 -0
- data/lib/archunit/graph/projection/graph_report_snapshot.rb +43 -0
- data/lib/archunit/graph/projection/graph_report_summary.rb +20 -0
- data/lib/archunit/graph/projection/node_selection.rb +111 -0
- data/lib/archunit/graph/projection/pattern_collapse.rb +24 -0
- data/lib/archunit/graph/rendering/csv_renderer.rb +30 -0
- data/lib/archunit/graph/rendering/d2_renderer.rb +30 -0
- data/lib/archunit/graph/rendering/dot_renderer.rb +51 -0
- data/lib/archunit/graph/rendering/escaping.rb +31 -0
- data/lib/archunit/graph/rendering/export_report.rb +29 -0
- data/lib/archunit/graph/rendering/graph_renderer.rb +54 -0
- data/lib/archunit/graph/rendering/html_document.rb +46 -0
- data/lib/archunit/graph/rendering/html_renderer.rb +102 -0
- data/lib/archunit/graph/rendering/json_renderer.rb +38 -0
- data/lib/archunit/graph/rendering/mermaid_renderer.rb +33 -0
- data/lib/archunit/graph/rendering/rendering_support.rb +24 -0
- data/lib/archunit/layers/assertion/layer_definition.rb +56 -0
- data/lib/archunit/layers/assertion/layer_dependencies.rb +91 -0
- data/lib/archunit/layers/assertion/layer_dependency_violation.rb +59 -0
- data/lib/archunit/layers/fluentapi/layer_definition_builder.rb +46 -0
- data/lib/archunit/layers/fluentapi/layer_dependency_rule_builder.rb +44 -0
- data/lib/archunit/layers/fluentapi/layered_architecture.rb +133 -0
- data/lib/archunit/layers/fluentapi/layered_architecture_values.rb +51 -0
- data/lib/archunit/layers/fluentapi/layers.rb +29 -0
- data/lib/archunit/metrics/assertion/custom_metric.rb +73 -0
- data/lib/archunit/metrics/assertion/metric_predicate.rb +66 -0
- data/lib/archunit/metrics/assertion/metric_threshold.rb +99 -0
- data/lib/archunit/metrics/assertion/metric_zone.rb +57 -0
- data/lib/archunit/metrics/calculation/count.rb +45 -0
- data/lib/archunit/metrics/calculation/distance.rb +84 -0
- data/lib/archunit/metrics/calculation/lcom.rb +103 -0
- data/lib/archunit/metrics/calculation/metric.rb +52 -0
- data/lib/archunit/metrics/calculation/numeric_value.rb +25 -0
- data/lib/archunit/metrics/extraction/distance_info.rb +60 -0
- data/lib/archunit/metrics/extraction/extract_distance_info.rb +50 -0
- data/lib/archunit/metrics/extraction/extract_project_info.rb +90 -0
- data/lib/archunit/metrics/extraction/metric_info.rb +159 -0
- data/lib/archunit/metrics/extraction/source_metrics_visitor.rb +278 -0
- data/lib/archunit/metrics/fluentapi/count_metrics_builder.rb +49 -0
- data/lib/archunit/metrics/fluentapi/custom_metric_builder.rb +30 -0
- data/lib/archunit/metrics/fluentapi/custom_metric_condition.rb +40 -0
- data/lib/archunit/metrics/fluentapi/distance_metrics_builder.rb +48 -0
- data/lib/archunit/metrics/fluentapi/lcom_metrics_builder.rb +39 -0
- data/lib/archunit/metrics/fluentapi/metric_measurement.rb +36 -0
- data/lib/archunit/metrics/fluentapi/metric_predicate_condition.rb +42 -0
- data/lib/archunit/metrics/fluentapi/metric_report_builder.rb +34 -0
- data/lib/archunit/metrics/fluentapi/metric_selection.rb +54 -0
- data/lib/archunit/metrics/fluentapi/metric_threshold_condition.rb +47 -0
- data/lib/archunit/metrics/fluentapi/metrics.rb +21 -0
- data/lib/archunit/metrics/fluentapi/metrics_builder.rb +136 -0
- data/lib/archunit/metrics/fluentapi/zone_condition.rb +40 -0
- data/lib/archunit/metrics/reporting/metrics_export_options.rb +57 -0
- data/lib/archunit/metrics/reporting/metrics_exporter.rb +130 -0
- data/lib/archunit/slices/assertion/adhere_to_diagram.rb +57 -0
- data/lib/archunit/slices/assertion/contain_dependency.rb +42 -0
- data/lib/archunit/slices/assertion/diagram_adherence_options.rb +37 -0
- data/lib/archunit/slices/assertion/slice_dependency_violation.rb +70 -0
- data/lib/archunit/slices/fluentapi/diagram_slice_condition.rb +65 -0
- data/lib/archunit/slices/fluentapi/diagram_source.rb +36 -0
- data/lib/archunit/slices/fluentapi/forbidden_slice_dependency_condition.rb +57 -0
- data/lib/archunit/slices/fluentapi/negative_slice_condition_builder.rb +27 -0
- data/lib/archunit/slices/fluentapi/positive_slice_condition_builder.rb +51 -0
- data/lib/archunit/slices/fluentapi/slice_scope_builder.rb +83 -0
- data/lib/archunit/slices/fluentapi/slices.rb +29 -0
- data/lib/archunit/slices/projection/slice_projection.rb +76 -0
- data/lib/archunit/slices/projection/slicing_projections.rb +98 -0
- data/lib/archunit/slices/uml/export_diagram.rb +70 -0
- data/lib/archunit/slices/uml/parse_diagram.rb +53 -0
- data/lib/archunit/slices/uml/plant_uml_dependency.rb +25 -0
- data/lib/archunit/slices/uml/plant_uml_diagram.rb +44 -0
- data/lib/archunit/testing/assert_passes.rb +24 -0
- data/lib/archunit/testing/assertion_failure.rb +22 -0
- data/lib/archunit/testing/color_utils.rb +66 -0
- data/lib/archunit/testing/layer_violation_formatter.rb +26 -0
- data/lib/archunit/testing/metric_violation_formatter.rb +58 -0
- data/lib/archunit/testing/minitest_adapter.rb +33 -0
- data/lib/archunit/testing/result_factory.rb +93 -0
- data/lib/archunit/testing/rspec_adapter.rb +74 -0
- data/lib/archunit/testing/slice_violation_formatter.rb +25 -0
- data/lib/archunit/testing/test_result.rb +26 -0
- data/lib/archunit/testing/test_violation.rb +23 -0
- data/lib/archunit/testing/violation_factory.rb +138 -0
- data/lib/archunit/testing.rb +39 -0
- data/lib/archunit/version.rb +5 -0
- data/lib/archunit.rb +110 -0
- 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
|