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,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!
|