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,84 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative '../../common/extraction/graph'
|
|
4
|
+
require_relative 'aggregate_edges'
|
|
5
|
+
require_relative 'collapse_node'
|
|
6
|
+
require_relative 'graph_query_options'
|
|
7
|
+
require_relative 'graph_report_snapshot'
|
|
8
|
+
require_relative 'node_selection'
|
|
9
|
+
|
|
10
|
+
module ArchUnit
|
|
11
|
+
module GraphReporting
|
|
12
|
+
module Projection
|
|
13
|
+
DEFAULT_TITLE = 'ArchUnitRuby Dependency Graph'
|
|
14
|
+
|
|
15
|
+
# Builds the one immutable representation consumed by every graph renderer.
|
|
16
|
+
class SnapshotFactory
|
|
17
|
+
class << self
|
|
18
|
+
def create(graph, options = nil)
|
|
19
|
+
validate_graph(graph)
|
|
20
|
+
options = GraphQueryOptions.resolve(options)
|
|
21
|
+
query_edges = external_query_edges(graph, options)
|
|
22
|
+
selected_nodes = NodeSelection.select(query_edges, options)
|
|
23
|
+
raw_edges = selected_edges(query_edges, selected_nodes, options)
|
|
24
|
+
report_edges = aggregate_edges(raw_edges, options)
|
|
25
|
+
report_nodes = build_nodes(selected_nodes, report_edges, options.collapse)
|
|
26
|
+
|
|
27
|
+
build_snapshot(options, report_nodes, report_edges, raw_edges)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
def validate_graph(value)
|
|
33
|
+
return if value.is_a?(Common::Extraction::Graph)
|
|
34
|
+
|
|
35
|
+
raise ArgumentError, 'graph must be a Graph value'
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def external_query_edges(graph, options)
|
|
39
|
+
return graph.to_a if options.include_external_dependencies
|
|
40
|
+
|
|
41
|
+
graph.reject(&:external)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def selected_edges(edges, selected_nodes, options)
|
|
45
|
+
edges.select do |edge|
|
|
46
|
+
(options.include_self_dependencies || edge.source != edge.target) &&
|
|
47
|
+
selected_nodes.include?(edge.source) && selected_nodes.include?(edge.target)
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def aggregate_edges(raw_edges, options)
|
|
52
|
+
AggregateEdges.aggregate(
|
|
53
|
+
raw_edges,
|
|
54
|
+
collapse: options.collapse,
|
|
55
|
+
include_self_dependencies: options.include_self_dependencies
|
|
56
|
+
)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def build_nodes(selected_nodes, report_edges, collapse)
|
|
60
|
+
labels = selected_nodes.map { |node| CollapseNode.collapse(node, collapse) }
|
|
61
|
+
labels.concat(report_edges.flat_map { |edge| [edge.source, edge.target] })
|
|
62
|
+
labels.uniq.sort.each_with_index.map do |label, index|
|
|
63
|
+
GraphReportNode.new(id: "n#{index}", label:)
|
|
64
|
+
end.freeze
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def build_snapshot(options, nodes, edges, raw_edges)
|
|
68
|
+
GraphReportSnapshot.new(
|
|
69
|
+
title: options.title || DEFAULT_TITLE,
|
|
70
|
+
nodes:, edges:,
|
|
71
|
+
summary: GraphReportSummary.new(
|
|
72
|
+
node_count: nodes.length, edge_count: edges.length,
|
|
73
|
+
raw_edge_count: raw_edges.length,
|
|
74
|
+
external_edge_count: raw_edges.count(&:external)
|
|
75
|
+
)
|
|
76
|
+
)
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
private_class_method :new
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ArchUnit
|
|
4
|
+
module GraphReporting
|
|
5
|
+
module Projection
|
|
6
|
+
# Collapse file nodes to their folder path at a fixed depth.
|
|
7
|
+
FolderDepthCollapse = Data.define(:depth) do
|
|
8
|
+
def initialize(depth:)
|
|
9
|
+
unless depth.is_a?(Integer) && depth.positive?
|
|
10
|
+
raise ArgumentError, 'depth must be a positive Integer'
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
super
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative '../../common/filter'
|
|
4
|
+
require_relative 'folder_depth_collapse'
|
|
5
|
+
require_relative 'pattern_collapse'
|
|
6
|
+
|
|
7
|
+
module ArchUnit
|
|
8
|
+
module GraphReporting
|
|
9
|
+
module Projection
|
|
10
|
+
# Immutable selection, collapsing, and presentation options for a graph snapshot.
|
|
11
|
+
GraphQueryOptions = Data.define(
|
|
12
|
+
:include_external_dependencies, :include_self_dependencies,
|
|
13
|
+
:focus, :focus_depth, :reachable_from, :dependents_of,
|
|
14
|
+
:collapse, :title
|
|
15
|
+
) do
|
|
16
|
+
def self.resolve(value)
|
|
17
|
+
return new if value.nil?
|
|
18
|
+
return value if value.is_a?(self)
|
|
19
|
+
|
|
20
|
+
raise ArgumentError, 'options must be a GraphQueryOptions value or nil'
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# The options bag deliberately mirrors the eight independent fluent modifiers.
|
|
24
|
+
# rubocop:disable Metrics/ParameterLists
|
|
25
|
+
def initialize(
|
|
26
|
+
include_external_dependencies: false, include_self_dependencies: false,
|
|
27
|
+
focus: nil, focus_depth: 1, reachable_from: nil, dependents_of: nil,
|
|
28
|
+
collapse: nil, title: nil
|
|
29
|
+
)
|
|
30
|
+
validate_boolean(include_external_dependencies, :include_external_dependencies)
|
|
31
|
+
validate_boolean(include_self_dependencies, :include_self_dependencies)
|
|
32
|
+
validate_filter(focus, :focus)
|
|
33
|
+
validate_focus_depth(focus_depth)
|
|
34
|
+
validate_filter(reachable_from, :reachable_from)
|
|
35
|
+
validate_filter(dependents_of, :dependents_of)
|
|
36
|
+
validate_collapse(collapse)
|
|
37
|
+
title = immutable_title(title)
|
|
38
|
+
super
|
|
39
|
+
end
|
|
40
|
+
# rubocop:enable Metrics/ParameterLists
|
|
41
|
+
|
|
42
|
+
def with(**overrides)
|
|
43
|
+
self.class.new(**to_h, **overrides)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
def validate_boolean(value, attribute)
|
|
49
|
+
return if [true, false].include?(value)
|
|
50
|
+
|
|
51
|
+
raise ArgumentError, "#{attribute} must be true or false"
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def validate_filter(value, attribute)
|
|
55
|
+
return if value.nil? || value.is_a?(Common::Filter)
|
|
56
|
+
|
|
57
|
+
raise ArgumentError, "#{attribute} must be a Filter or nil"
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def validate_focus_depth(value)
|
|
61
|
+
return if value.is_a?(Integer) && !value.negative?
|
|
62
|
+
|
|
63
|
+
raise ArgumentError, 'focus_depth must be a non-negative Integer'
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def validate_collapse(value)
|
|
67
|
+
return if value.nil? || value.is_a?(FolderDepthCollapse) || value.is_a?(PatternCollapse)
|
|
68
|
+
|
|
69
|
+
raise ArgumentError, 'collapse must be a graph collapse strategy or nil'
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def immutable_title(value)
|
|
73
|
+
return if value.nil?
|
|
74
|
+
return value.dup.freeze if value.is_a?(String) && !value.strip.empty?
|
|
75
|
+
|
|
76
|
+
raise ArgumentError, 'title must be a non-empty String or nil'
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative '../../common/extraction/import_kind'
|
|
4
|
+
|
|
5
|
+
module ArchUnit
|
|
6
|
+
module GraphReporting
|
|
7
|
+
module Projection
|
|
8
|
+
# Aggregated dependency evidence in a graph report snapshot.
|
|
9
|
+
GraphReportEdge = Data.define(:source, :target, :count, :external, :import_kinds) do
|
|
10
|
+
def initialize(source:, target:, count:, external:, import_kinds: [])
|
|
11
|
+
source = immutable_string(source, :source)
|
|
12
|
+
target = immutable_string(target, :target)
|
|
13
|
+
validate_count(count)
|
|
14
|
+
validate_external(external)
|
|
15
|
+
import_kinds = immutable_import_kinds(import_kinds)
|
|
16
|
+
super
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
private
|
|
20
|
+
|
|
21
|
+
def immutable_string(value, attribute)
|
|
22
|
+
return value.dup.freeze if value.is_a?(String) && !value.empty?
|
|
23
|
+
|
|
24
|
+
raise ArgumentError, "#{attribute} must be a non-empty String"
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def validate_count(value)
|
|
28
|
+
return if value.is_a?(Integer) && value.positive?
|
|
29
|
+
|
|
30
|
+
raise ArgumentError, 'count must be a positive Integer'
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def validate_external(value)
|
|
34
|
+
return if [true, false].include?(value)
|
|
35
|
+
|
|
36
|
+
raise ArgumentError, 'external must be true or false'
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def immutable_import_kinds(values)
|
|
40
|
+
kinds = Array(values).uniq.sort_by(&:to_s)
|
|
41
|
+
unless kinds.all? { |kind| Common::Extraction::ImportKind.valid?(kind) }
|
|
42
|
+
raise ArgumentError, 'import_kinds contains an unknown import kind'
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
kinds.freeze
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ArchUnit
|
|
4
|
+
module GraphReporting
|
|
5
|
+
module Projection
|
|
6
|
+
# Stable node identity and display label in a graph report snapshot.
|
|
7
|
+
GraphReportNode = Data.define(:id, :label) do
|
|
8
|
+
def initialize(id:, label:)
|
|
9
|
+
super(id: immutable_string(id, :id), label: immutable_string(label, :label))
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
private
|
|
13
|
+
|
|
14
|
+
def immutable_string(value, attribute)
|
|
15
|
+
return value.dup.freeze if value.is_a?(String) && !value.empty?
|
|
16
|
+
|
|
17
|
+
raise ArgumentError, "#{attribute} must be a non-empty String"
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'graph_report_edge'
|
|
4
|
+
require_relative 'graph_report_node'
|
|
5
|
+
require_relative 'graph_report_summary'
|
|
6
|
+
|
|
7
|
+
module ArchUnit
|
|
8
|
+
module GraphReporting
|
|
9
|
+
module Projection
|
|
10
|
+
# Immutable graph after filtering, collapsing, aggregation, and counting.
|
|
11
|
+
GraphReportSnapshot = Data.define(:title, :nodes, :edges, :summary) do
|
|
12
|
+
def initialize(title:, nodes:, edges:, summary:)
|
|
13
|
+
title = immutable_title(title)
|
|
14
|
+
nodes = immutable_values(nodes, GraphReportNode, :nodes)
|
|
15
|
+
edges = immutable_values(edges, GraphReportEdge, :edges)
|
|
16
|
+
unless summary.is_a?(GraphReportSummary)
|
|
17
|
+
raise ArgumentError, 'summary must be a GraphReportSummary'
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
super
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
def immutable_title(value)
|
|
26
|
+
return value.dup.freeze if value.is_a?(String) && !value.strip.empty?
|
|
27
|
+
|
|
28
|
+
raise ArgumentError, 'title must be a non-empty String'
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def immutable_values(values, type, attribute)
|
|
32
|
+
entries = Array(values).dup
|
|
33
|
+
unless entries.all?(type)
|
|
34
|
+
type_name = type.name.split('::').last
|
|
35
|
+
raise ArgumentError, "#{attribute} must contain only #{type_name} values"
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
entries.freeze
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ArchUnit
|
|
4
|
+
module GraphReporting
|
|
5
|
+
module Projection
|
|
6
|
+
# Counts describing the selected and aggregated graph snapshot.
|
|
7
|
+
GraphReportSummary = Data.define(
|
|
8
|
+
:node_count, :edge_count, :raw_edge_count, :external_edge_count
|
|
9
|
+
) do
|
|
10
|
+
def initialize(node_count:, edge_count:, raw_edge_count:, external_edge_count:)
|
|
11
|
+
values = { node_count:, edge_count:, raw_edge_count:, external_edge_count: }
|
|
12
|
+
invalid = values.find { |_name, value| !value.is_a?(Integer) || value.negative? }
|
|
13
|
+
raise ArgumentError, "#{invalid.first} must be a non-negative Integer" if invalid
|
|
14
|
+
|
|
15
|
+
super
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative '../../common/pattern_matching'
|
|
4
|
+
|
|
5
|
+
module ArchUnit
|
|
6
|
+
module GraphReporting
|
|
7
|
+
module Projection
|
|
8
|
+
# Pure graph traversal for focus, reachability, and dependent queries.
|
|
9
|
+
module NodeSelection
|
|
10
|
+
module_function
|
|
11
|
+
|
|
12
|
+
def select(edges, options)
|
|
13
|
+
return all_nodes(edges) unless query?(options)
|
|
14
|
+
|
|
15
|
+
selected = Set.new
|
|
16
|
+
selected.merge(expand_focus(edges, options.focus, options.focus_depth)) if options.focus
|
|
17
|
+
selected.merge(walk(edges, options.reachable_from, :outgoing)) if options.reachable_from
|
|
18
|
+
selected.merge(walk(edges, options.dependents_of, :incoming)) if options.dependents_of
|
|
19
|
+
selected
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def query?(options)
|
|
23
|
+
options.focus || options.reachable_from || options.dependents_of
|
|
24
|
+
end
|
|
25
|
+
private_class_method :query?
|
|
26
|
+
|
|
27
|
+
def all_nodes(edges)
|
|
28
|
+
edges.each_with_object(Set.new) do |edge, nodes|
|
|
29
|
+
nodes.add(edge.source).add(edge.target)
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
private_class_method :all_nodes
|
|
33
|
+
|
|
34
|
+
def expand_focus(edges, filter, depth)
|
|
35
|
+
selected = matching_nodes(edges, filter)
|
|
36
|
+
queue = selected.map { |node| [node, 0] }
|
|
37
|
+
|
|
38
|
+
until queue.empty?
|
|
39
|
+
current, current_depth = queue.shift
|
|
40
|
+
next if current_depth >= depth
|
|
41
|
+
|
|
42
|
+
add_neighbors(edges, current, current_depth, selected, queue)
|
|
43
|
+
end
|
|
44
|
+
selected
|
|
45
|
+
end
|
|
46
|
+
private_class_method :expand_focus
|
|
47
|
+
|
|
48
|
+
def add_neighbors(edges, current, current_depth, selected, queue)
|
|
49
|
+
neighbors_of(edges, current).each do |neighbor|
|
|
50
|
+
next if selected.include?(neighbor)
|
|
51
|
+
|
|
52
|
+
selected.add(neighbor)
|
|
53
|
+
queue << [neighbor, current_depth + 1]
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
private_class_method :add_neighbors
|
|
57
|
+
|
|
58
|
+
def walk(edges, filter, direction)
|
|
59
|
+
selected = matching_nodes(edges, filter)
|
|
60
|
+
queue = selected.to_a
|
|
61
|
+
|
|
62
|
+
until queue.empty?
|
|
63
|
+
current = queue.shift
|
|
64
|
+
add_next_nodes(edges, current, direction, selected, queue)
|
|
65
|
+
end
|
|
66
|
+
selected
|
|
67
|
+
end
|
|
68
|
+
private_class_method :walk
|
|
69
|
+
|
|
70
|
+
def add_next_nodes(edges, current, direction, selected, queue)
|
|
71
|
+
next_nodes(edges, current, direction).each do |node|
|
|
72
|
+
next if selected.include?(node)
|
|
73
|
+
|
|
74
|
+
selected.add(node)
|
|
75
|
+
queue << node
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
private_class_method :add_next_nodes
|
|
79
|
+
|
|
80
|
+
def matching_nodes(edges, filter)
|
|
81
|
+
all_nodes(edges).select do |node|
|
|
82
|
+
Common::PatternMatching.matches_pattern?(node, filter)
|
|
83
|
+
end.to_set
|
|
84
|
+
end
|
|
85
|
+
private_class_method :matching_nodes
|
|
86
|
+
|
|
87
|
+
def neighbors_of(edges, node)
|
|
88
|
+
edges.filter_map do |edge|
|
|
89
|
+
if edge.source == node && edge.target != node
|
|
90
|
+
edge.target
|
|
91
|
+
elsif edge.target == node && edge.source != node
|
|
92
|
+
edge.source
|
|
93
|
+
end
|
|
94
|
+
end.uniq
|
|
95
|
+
end
|
|
96
|
+
private_class_method :neighbors_of
|
|
97
|
+
|
|
98
|
+
def next_nodes(edges, node, direction)
|
|
99
|
+
edges.filter_map do |edge|
|
|
100
|
+
if direction == :outgoing && edge.source == node
|
|
101
|
+
edge.target
|
|
102
|
+
elsif direction == :incoming && edge.target == node
|
|
103
|
+
edge.source
|
|
104
|
+
end
|
|
105
|
+
end.uniq
|
|
106
|
+
end
|
|
107
|
+
private_class_method :next_nodes
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ArchUnit
|
|
4
|
+
module GraphReporting
|
|
5
|
+
module Projection
|
|
6
|
+
# Collapse nodes by applying a regular-expression replacement.
|
|
7
|
+
PatternCollapse = Data.define(:regexp, :replacement) do
|
|
8
|
+
def initialize(regexp:, replacement: '\\1')
|
|
9
|
+
raise ArgumentError, 'regexp must be a Regexp' unless regexp.is_a?(Regexp)
|
|
10
|
+
raise ArgumentError, 'replacement must be a String' unless replacement.is_a?(String)
|
|
11
|
+
|
|
12
|
+
super(regexp: regexp.dup.freeze, replacement: replacement.dup.freeze)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def self.from(pattern, replacement = '\\1')
|
|
16
|
+
regexp = pattern.is_a?(String) ? Regexp.new(pattern) : pattern
|
|
17
|
+
new(regexp:, replacement:)
|
|
18
|
+
rescue RegexpError => e
|
|
19
|
+
raise ArgumentError, "invalid collapse pattern: #{e.message}"
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'csv'
|
|
4
|
+
require_relative 'rendering_support'
|
|
5
|
+
|
|
6
|
+
module ArchUnit
|
|
7
|
+
module GraphReporting
|
|
8
|
+
module Rendering
|
|
9
|
+
# Renders aggregated graph edges as standards-compliant CSV.
|
|
10
|
+
module CsvRenderer
|
|
11
|
+
HEADERS = %w[source target count external import_kinds].freeze
|
|
12
|
+
|
|
13
|
+
module_function
|
|
14
|
+
|
|
15
|
+
def render(snapshot)
|
|
16
|
+
snapshot = RenderingSupport.validate_snapshot(snapshot)
|
|
17
|
+
CSV.generate(row_sep: "\n") do |csv|
|
|
18
|
+
csv << HEADERS
|
|
19
|
+
snapshot.edges.each do |edge|
|
|
20
|
+
csv << [
|
|
21
|
+
edge.source, edge.target, edge.count, edge.external,
|
|
22
|
+
edge.import_kinds.join('|')
|
|
23
|
+
]
|
|
24
|
+
end
|
|
25
|
+
end.rstrip
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
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 D2 diagram source from a report snapshot.
|
|
10
|
+
module D2Renderer
|
|
11
|
+
module_function
|
|
12
|
+
|
|
13
|
+
def render(snapshot)
|
|
14
|
+
snapshot = RenderingSupport.validate_snapshot(snapshot)
|
|
15
|
+
lines = ["# #{Escaping.single_line(snapshot.title)}"]
|
|
16
|
+
snapshot.nodes.each { |node| lines << Escaping.quoted(node.label) }
|
|
17
|
+
snapshot.edges.each { |edge| lines << edge_line(edge) }
|
|
18
|
+
lines.join("\n")
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def edge_line(edge)
|
|
22
|
+
label = edge.count > 1 ? ": #{Escaping.quoted(edge.count.to_s)}" : ''
|
|
23
|
+
style = edge.external ? ' { style.stroke-dash: 4 }' : ''
|
|
24
|
+
"#{Escaping.quoted(edge.source)} -> #{Escaping.quoted(edge.target)}#{label}#{style}"
|
|
25
|
+
end
|
|
26
|
+
private_class_method :edge_line
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,51 @@
|
|
|
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 Graphviz DOT directed graph from a report snapshot.
|
|
10
|
+
module DotRenderer
|
|
11
|
+
module_function
|
|
12
|
+
|
|
13
|
+
def render(snapshot)
|
|
14
|
+
snapshot = RenderingSupport.validate_snapshot(snapshot)
|
|
15
|
+
lines = header(snapshot)
|
|
16
|
+
snapshot.nodes.each { |node| lines << " #{Escaping.quoted(node.label)};" }
|
|
17
|
+
snapshot.edges.each { |edge| lines << edge_line(edge) }
|
|
18
|
+
lines << '}'
|
|
19
|
+
lines.join("\n")
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def header(snapshot)
|
|
23
|
+
[
|
|
24
|
+
'digraph dependencies {',
|
|
25
|
+
' rankdir=LR;',
|
|
26
|
+
" label=#{Escaping.quoted(snapshot.title)};",
|
|
27
|
+
' labelloc=t;'
|
|
28
|
+
]
|
|
29
|
+
end
|
|
30
|
+
private_class_method :header
|
|
31
|
+
|
|
32
|
+
def edge_line(edge)
|
|
33
|
+
attributes = edge_attributes(edge)
|
|
34
|
+
suffix = attributes.empty? ? '' : " [#{attributes.join(', ')}]"
|
|
35
|
+
" #{Escaping.quoted(edge.source)} -> #{Escaping.quoted(edge.target)}#{suffix};"
|
|
36
|
+
end
|
|
37
|
+
private_class_method :edge_line
|
|
38
|
+
|
|
39
|
+
def edge_attributes(edge)
|
|
40
|
+
attributes = []
|
|
41
|
+
attributes << "label=#{Escaping.quoted(edge.count.to_s)}" if edge.count > 1
|
|
42
|
+
attributes << 'style=dashed' if edge.external
|
|
43
|
+
return attributes if edge.import_kinds.empty?
|
|
44
|
+
|
|
45
|
+
attributes << "tooltip=#{Escaping.quoted(edge.import_kinds.join(', '))}"
|
|
46
|
+
end
|
|
47
|
+
private_class_method :edge_attributes
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'cgi/escape'
|
|
4
|
+
|
|
5
|
+
module ArchUnit
|
|
6
|
+
module GraphReporting
|
|
7
|
+
module Rendering
|
|
8
|
+
# Format-specific escaping kept out of the renderers' graph logic.
|
|
9
|
+
module Escaping
|
|
10
|
+
module_function
|
|
11
|
+
|
|
12
|
+
def quoted(value)
|
|
13
|
+
escaped = value.gsub('\\', '\\\\').gsub('"', '\\"').gsub("\n", '\\n')
|
|
14
|
+
"\"#{escaped}\""
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def mermaid_label(value)
|
|
18
|
+
CGI.escapeHTML(value).gsub("\n", '<br/>')
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def html(value)
|
|
22
|
+
CGI.escapeHTML(value.to_s)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def single_line(value)
|
|
26
|
+
value.gsub(/[\r\n]+/, ' ')
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'fileutils'
|
|
4
|
+
|
|
5
|
+
module ArchUnit
|
|
6
|
+
module GraphReporting
|
|
7
|
+
module Rendering
|
|
8
|
+
# UTF-8 file export shared by every graph report format.
|
|
9
|
+
module ExportReport
|
|
10
|
+
module_function
|
|
11
|
+
|
|
12
|
+
def write(output_path, content)
|
|
13
|
+
path = normalized_path(output_path)
|
|
14
|
+
FileUtils.mkdir_p(File.dirname(path)) unless File.dirname(path) == '.'
|
|
15
|
+
File.binwrite(path, content.encode(Encoding::UTF_8))
|
|
16
|
+
nil
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def normalized_path(value)
|
|
20
|
+
value = value.to_path if value.respond_to?(:to_path)
|
|
21
|
+
return value if value.is_a?(String) && !value.empty?
|
|
22
|
+
|
|
23
|
+
raise ArgumentError, 'output_path must be a non-empty path'
|
|
24
|
+
end
|
|
25
|
+
private_class_method :normalized_path
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'csv_renderer'
|
|
4
|
+
require_relative 'd2_renderer'
|
|
5
|
+
require_relative 'dot_renderer'
|
|
6
|
+
require_relative 'export_report'
|
|
7
|
+
require_relative 'html_renderer'
|
|
8
|
+
require_relative 'json_renderer'
|
|
9
|
+
require_relative 'mermaid_renderer'
|
|
10
|
+
|
|
11
|
+
module ArchUnit
|
|
12
|
+
module GraphReporting
|
|
13
|
+
module Rendering
|
|
14
|
+
# One dispatch point for every renderer consuming the shared snapshot.
|
|
15
|
+
class GraphRenderer
|
|
16
|
+
RENDERERS = {
|
|
17
|
+
dot: DotRenderer, mermaid: MermaidRenderer, d2: D2Renderer,
|
|
18
|
+
csv: CsvRenderer, json: JsonRenderer, html: HtmlRenderer
|
|
19
|
+
}.freeze
|
|
20
|
+
|
|
21
|
+
class << self
|
|
22
|
+
def render(snapshot, format)
|
|
23
|
+
renderer(format).render(snapshot)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def export(snapshot, format, output_path)
|
|
27
|
+
ExportReport.write(output_path, render(snapshot, format))
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
RENDERERS.each_key do |format|
|
|
31
|
+
define_method("to_#{format}") do |snapshot|
|
|
32
|
+
render(snapshot, format)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
define_method("export_as_#{format}") do |snapshot, output_path|
|
|
36
|
+
export(snapshot, format, output_path)
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
def renderer(format)
|
|
43
|
+
key = format.respond_to?(:to_sym) ? format.to_sym : format
|
|
44
|
+
RENDERERS.fetch(key)
|
|
45
|
+
rescue KeyError
|
|
46
|
+
raise ArgumentError, "unknown graph report format: #{format.inspect}"
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private_class_method :new
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|