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,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'mapped_edge'
|
|
4
|
+
|
|
5
|
+
module ArchUnit
|
|
6
|
+
module Common
|
|
7
|
+
# Reusable edge-to-label mapping functions for shared graph projections.
|
|
8
|
+
module Projection
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
# Maps every non-self edge without changing its labels.
|
|
12
|
+
def per_edge
|
|
13
|
+
lambda do |edge|
|
|
14
|
+
next if edge.source == edge.target
|
|
15
|
+
|
|
16
|
+
map_identity(edge)
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Maps non-self edges whose targets are part of the extracted project.
|
|
21
|
+
def per_internal_edge
|
|
22
|
+
lambda do |edge|
|
|
23
|
+
next if edge.external || edge.source == edge.target
|
|
24
|
+
|
|
25
|
+
map_identity(edge)
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Maps non-self edges whose targets are outside the extracted project.
|
|
30
|
+
def per_external_edge
|
|
31
|
+
lambda do |edge|
|
|
32
|
+
next unless edge.external
|
|
33
|
+
next if edge.source == edge.target
|
|
34
|
+
|
|
35
|
+
map_identity(edge)
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Maps an edge without filtering, including source-file self-edges.
|
|
40
|
+
def identity
|
|
41
|
+
->(edge) { map_identity(edge) }
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def map_identity(edge)
|
|
45
|
+
MappedEdge.new(source_label: edge.source, target_label: edge.target)
|
|
46
|
+
end
|
|
47
|
+
private_class_method :map_identity
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ArchUnit
|
|
4
|
+
module Common
|
|
5
|
+
module Projection
|
|
6
|
+
# The labels produced when a raw dependency edge is mapped into a domain view.
|
|
7
|
+
MappedEdge = Data.define(:source_label, :target_label) do
|
|
8
|
+
def initialize(source_label:, target_label:)
|
|
9
|
+
source_label = immutable_label(source_label, :source_label)
|
|
10
|
+
target_label = immutable_label(target_label, :target_label)
|
|
11
|
+
super
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
private
|
|
15
|
+
|
|
16
|
+
def immutable_label(value, attribute)
|
|
17
|
+
unless value.is_a?(String) && !value.empty?
|
|
18
|
+
raise ArgumentError, "#{attribute} must be a non-empty String"
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
value.dup.freeze
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'cycles/johnson_cycles'
|
|
4
|
+
require_relative 'edge_projections'
|
|
5
|
+
require_relative 'project_edges'
|
|
6
|
+
require_relative 'projected_edge'
|
|
7
|
+
|
|
8
|
+
module ArchUnit
|
|
9
|
+
module Common
|
|
10
|
+
# Cycle projection over immutable, evidence-retaining projected edges.
|
|
11
|
+
module Projection
|
|
12
|
+
module_function
|
|
13
|
+
|
|
14
|
+
def project_internal_cycles(graph)
|
|
15
|
+
project_cycles(project_edges(graph, per_internal_edge))
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def project_cycles(edges)
|
|
19
|
+
projected_edges = normalize_projected_edges(edges)
|
|
20
|
+
label_ids = label_ids_for(projected_edges)
|
|
21
|
+
edges_by_ids = index_edges(projected_edges, label_ids)
|
|
22
|
+
paths = Cycles::JohnsonCycles.call(adjacency_for(edges_by_ids))
|
|
23
|
+
cycles_from_paths(paths, edges_by_ids)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def cycles_from_paths(paths, edges_by_ids)
|
|
27
|
+
paths.map do |path|
|
|
28
|
+
path.each_index.map do |index|
|
|
29
|
+
source = path.fetch(index)
|
|
30
|
+
target = path.fetch((index + 1) % path.length)
|
|
31
|
+
edges_by_ids.fetch([source, target])
|
|
32
|
+
end.freeze
|
|
33
|
+
end.freeze
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def normalize_projected_edges(edges)
|
|
37
|
+
unless edges.respond_to?(:each)
|
|
38
|
+
raise ArgumentError, 'edges must be an enumerable of ProjectedEdge values'
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
groups = {}
|
|
42
|
+
edges.each { |edge| add_to_projection_groups(groups, edge) }
|
|
43
|
+
projected_edges_from(groups)
|
|
44
|
+
end
|
|
45
|
+
private_class_method :normalize_projected_edges
|
|
46
|
+
|
|
47
|
+
def add_to_projection_groups(groups, edge)
|
|
48
|
+
unless edge.is_a?(ProjectedEdge)
|
|
49
|
+
raise ArgumentError, 'edges must contain only ProjectedEdge values'
|
|
50
|
+
end
|
|
51
|
+
return if edge.source_label == edge.target_label
|
|
52
|
+
|
|
53
|
+
key = [edge.source_label, edge.target_label]
|
|
54
|
+
(groups[key] ||= []).concat(edge.cumulated_edges)
|
|
55
|
+
end
|
|
56
|
+
private_class_method :add_to_projection_groups
|
|
57
|
+
|
|
58
|
+
def projected_edges_from(groups)
|
|
59
|
+
groups.map do |(source_label, target_label), raw_edges|
|
|
60
|
+
ProjectedEdge.new(
|
|
61
|
+
source_label:, target_label:, cumulated_edges: raw_edges.uniq
|
|
62
|
+
)
|
|
63
|
+
end.freeze
|
|
64
|
+
end
|
|
65
|
+
private_class_method :projected_edges_from
|
|
66
|
+
|
|
67
|
+
def label_ids_for(edges)
|
|
68
|
+
labels = edges.flat_map { |edge| [edge.source_label, edge.target_label] }.uniq
|
|
69
|
+
labels.each_with_index.to_h.freeze
|
|
70
|
+
end
|
|
71
|
+
private_class_method :label_ids_for
|
|
72
|
+
|
|
73
|
+
def index_edges(edges, label_ids)
|
|
74
|
+
edges.to_h do |edge|
|
|
75
|
+
key = [label_ids.fetch(edge.source_label), label_ids.fetch(edge.target_label)]
|
|
76
|
+
[key, edge]
|
|
77
|
+
end.freeze
|
|
78
|
+
end
|
|
79
|
+
private_class_method :index_edges
|
|
80
|
+
|
|
81
|
+
def adjacency_for(edges_by_ids)
|
|
82
|
+
adjacency = Hash.new { |hash, vertex| hash[vertex] = [] }
|
|
83
|
+
edges_by_ids.each_key do |source, target|
|
|
84
|
+
adjacency[source] << target
|
|
85
|
+
adjacency[target] ||= []
|
|
86
|
+
end
|
|
87
|
+
adjacency.transform_values { |targets| targets.uniq.freeze }.freeze
|
|
88
|
+
end
|
|
89
|
+
private_class_method :adjacency_for
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative '../extraction/edge'
|
|
4
|
+
require_relative 'mapped_edge'
|
|
5
|
+
require_relative 'projected_edge'
|
|
6
|
+
|
|
7
|
+
module ArchUnit
|
|
8
|
+
module Common
|
|
9
|
+
# Pure functions and immutable values for relabeling dependency graphs.
|
|
10
|
+
module Projection
|
|
11
|
+
module_function
|
|
12
|
+
|
|
13
|
+
# Applies a callable Edge -> MappedEdge/nil hook and cumulates equal labels.
|
|
14
|
+
def project_edges(graph, mapper = nil, &block)
|
|
15
|
+
map_function = resolve_map_function(mapper, block)
|
|
16
|
+
projection_groups(graph, map_function).map do |(source_label, target_label), edges|
|
|
17
|
+
ProjectedEdge.new(source_label:, target_label:, cumulated_edges: edges)
|
|
18
|
+
end.freeze
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def projection_groups(graph, map_function)
|
|
22
|
+
groups = {}
|
|
23
|
+
|
|
24
|
+
each_edge(graph) do |edge|
|
|
25
|
+
mapped = map_function.call(edge)
|
|
26
|
+
next if mapped.nil?
|
|
27
|
+
|
|
28
|
+
validate_mapped_edge(mapped)
|
|
29
|
+
key = [mapped.source_label, mapped.target_label]
|
|
30
|
+
(groups[key] ||= []) << edge
|
|
31
|
+
end
|
|
32
|
+
groups
|
|
33
|
+
end
|
|
34
|
+
private_class_method :projection_groups
|
|
35
|
+
|
|
36
|
+
def resolve_map_function(mapper, block)
|
|
37
|
+
raise ArgumentError, 'provide a mapper or a block, not both' if mapper && block
|
|
38
|
+
|
|
39
|
+
map_function = mapper || block
|
|
40
|
+
return map_function if map_function.respond_to?(:call)
|
|
41
|
+
|
|
42
|
+
raise ArgumentError, 'mapper must be callable'
|
|
43
|
+
end
|
|
44
|
+
private_class_method :resolve_map_function
|
|
45
|
+
|
|
46
|
+
def each_edge(graph)
|
|
47
|
+
unless graph.respond_to?(:each)
|
|
48
|
+
raise ArgumentError, 'graph must be an enumerable of Edge values'
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
graph.each do |edge|
|
|
52
|
+
unless edge.is_a?(Extraction::Edge)
|
|
53
|
+
raise ArgumentError, 'graph must contain only Edge values'
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
yield edge
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
private_class_method :each_edge
|
|
60
|
+
|
|
61
|
+
def validate_mapped_edge(value)
|
|
62
|
+
return if value.is_a?(MappedEdge)
|
|
63
|
+
|
|
64
|
+
raise ArgumentError, 'mapper must return a MappedEdge or nil'
|
|
65
|
+
end
|
|
66
|
+
private_class_method :validate_mapped_edge
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative '../extraction/edge'
|
|
4
|
+
require_relative 'projected_node'
|
|
5
|
+
require_relative 'project_edges'
|
|
6
|
+
|
|
7
|
+
module ArchUnit
|
|
8
|
+
module Common
|
|
9
|
+
# Pure functions and immutable values for relabeling dependency graphs.
|
|
10
|
+
module Projection
|
|
11
|
+
module_function
|
|
12
|
+
|
|
13
|
+
def project_to_nodes(graph, include_externals: false)
|
|
14
|
+
validate_include_externals(include_externals)
|
|
15
|
+
labels, incoming, outgoing = collect_node_edges(graph, include_externals)
|
|
16
|
+
|
|
17
|
+
labels.keys.sort.map do |label|
|
|
18
|
+
ProjectedNode.new(label:, incoming: incoming[label], outgoing: outgoing[label])
|
|
19
|
+
end.freeze
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def collect_node_edges(graph, include_externals)
|
|
23
|
+
incoming = Hash.new { |hash, label| hash[label] = [] }
|
|
24
|
+
outgoing = Hash.new { |hash, label| hash[label] = [] }
|
|
25
|
+
labels = {}
|
|
26
|
+
|
|
27
|
+
each_edge(graph) do |edge|
|
|
28
|
+
collect_node_edge(edge, labels, incoming, outgoing, include_externals)
|
|
29
|
+
end
|
|
30
|
+
[labels, incoming, outgoing]
|
|
31
|
+
end
|
|
32
|
+
private_class_method :collect_node_edges
|
|
33
|
+
|
|
34
|
+
def collect_node_edge(edge, labels, incoming, outgoing, include_externals)
|
|
35
|
+
labels[edge.source] = true
|
|
36
|
+
return if self_edge?(edge)
|
|
37
|
+
|
|
38
|
+
outgoing[edge.source] << edge
|
|
39
|
+
return if edge.external && !include_externals
|
|
40
|
+
|
|
41
|
+
labels[edge.target] = true
|
|
42
|
+
incoming[edge.target] << edge
|
|
43
|
+
end
|
|
44
|
+
private_class_method :collect_node_edge
|
|
45
|
+
|
|
46
|
+
def self_edge?(edge)
|
|
47
|
+
edge.source == edge.target
|
|
48
|
+
end
|
|
49
|
+
private_class_method :self_edge?
|
|
50
|
+
|
|
51
|
+
def validate_include_externals(value)
|
|
52
|
+
return if [true, false].include?(value)
|
|
53
|
+
|
|
54
|
+
raise ArgumentError, 'include_externals must be true or false'
|
|
55
|
+
end
|
|
56
|
+
private_class_method :validate_include_externals
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative '../extraction/edge'
|
|
4
|
+
require_relative 'mapped_edge'
|
|
5
|
+
|
|
6
|
+
module ArchUnit
|
|
7
|
+
module Common
|
|
8
|
+
module Projection
|
|
9
|
+
# A labeled dependency that retains every raw edge collapsed into it.
|
|
10
|
+
ProjectedEdge = Data.define(:source_label, :target_label, :cumulated_edges) do
|
|
11
|
+
def initialize(source_label:, target_label:, cumulated_edges:)
|
|
12
|
+
mapped = MappedEdge.new(source_label:, target_label:)
|
|
13
|
+
cumulated_edges = immutable_edges(cumulated_edges)
|
|
14
|
+
|
|
15
|
+
super(
|
|
16
|
+
source_label: mapped.source_label,
|
|
17
|
+
target_label: mapped.target_label,
|
|
18
|
+
cumulated_edges:
|
|
19
|
+
)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
private
|
|
23
|
+
|
|
24
|
+
def immutable_edges(values)
|
|
25
|
+
edges = Array(values).dup
|
|
26
|
+
unless edges.any? && edges.all?(Extraction::Edge)
|
|
27
|
+
raise ArgumentError, 'cumulated_edges must contain at least one Edge'
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
edges.freeze
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative '../extraction/edge'
|
|
4
|
+
|
|
5
|
+
module ArchUnit
|
|
6
|
+
module Common
|
|
7
|
+
module Projection
|
|
8
|
+
# A graph node with its incoming and outgoing raw dependency edges.
|
|
9
|
+
ProjectedNode = Data.define(:label, :incoming, :outgoing) do
|
|
10
|
+
def initialize(label:, incoming: [], outgoing: [])
|
|
11
|
+
label = immutable_label(label)
|
|
12
|
+
incoming = immutable_edges(incoming, :incoming)
|
|
13
|
+
outgoing = immutable_edges(outgoing, :outgoing)
|
|
14
|
+
super
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
private
|
|
18
|
+
|
|
19
|
+
def immutable_label(value)
|
|
20
|
+
return value.dup.freeze if value.is_a?(String) && !value.empty?
|
|
21
|
+
|
|
22
|
+
raise ArgumentError, 'label must be a non-empty String'
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def immutable_edges(values, attribute)
|
|
26
|
+
edges = Array(values).dup
|
|
27
|
+
unless edges.all?(Extraction::Edge)
|
|
28
|
+
raise ArgumentError, "#{attribute} must contain only Edge values"
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
edges.freeze
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'filter'
|
|
4
|
+
require_relative 'pattern'
|
|
5
|
+
|
|
6
|
+
module ArchUnit
|
|
7
|
+
module Common
|
|
8
|
+
# The single construction point for user patterns and their matching targets.
|
|
9
|
+
class RegexFactory
|
|
10
|
+
TARGETED_EXCLUSIONS = {
|
|
11
|
+
in_path: :path,
|
|
12
|
+
in_folder: :path_without_filename,
|
|
13
|
+
with_name: :filename,
|
|
14
|
+
for_classes_matching: :classname
|
|
15
|
+
}.freeze
|
|
16
|
+
DEFAULT_EXCLUSION_TARGETS = {
|
|
17
|
+
filename: %i[filename],
|
|
18
|
+
path: %i[path filename],
|
|
19
|
+
path_without_filename: %i[path path_without_filename filename],
|
|
20
|
+
classname: %i[classname]
|
|
21
|
+
}.freeze
|
|
22
|
+
|
|
23
|
+
class << self
|
|
24
|
+
def filename_matcher(pattern, except: nil)
|
|
25
|
+
create_matcher(pattern, :filename, except:)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def folder_matcher(pattern, except: nil)
|
|
29
|
+
create_matcher(pattern, :path_without_filename, except:)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def path_matcher(pattern, except: nil)
|
|
33
|
+
create_matcher(pattern, :path, except:)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def classname_matcher(pattern, except: nil)
|
|
37
|
+
create_matcher(pattern, :classname, except:)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def exact_file_matcher(file_path, except: nil)
|
|
41
|
+
unless file_path.is_a?(String) && !file_path.empty?
|
|
42
|
+
raise ArgumentError, 'file_path must be a non-empty String'
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
normalized_path = file_path.tr('\\', '/')
|
|
46
|
+
regexp = Regexp.new("\\A#{Regexp.escape(normalized_path)}\\z")
|
|
47
|
+
exclusions = create_exclusion_filters(:path, except)
|
|
48
|
+
Filter.new(regexp:, target: :path, matching: :exact, exclusions:)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
private
|
|
52
|
+
|
|
53
|
+
def create_matcher(pattern, target, except:)
|
|
54
|
+
exclusions = create_exclusion_filters(target, except)
|
|
55
|
+
Filter.new(regexp: Pattern.compile(pattern), target:, exclusions:)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def create_exclusion_filters(parent_target, exclusion)
|
|
59
|
+
return [] if exclusion.nil?
|
|
60
|
+
return targeted_exclusion_filters(exclusion) if exclusion.is_a?(Hash)
|
|
61
|
+
|
|
62
|
+
exclusion_patterns(exclusion).flat_map do |pattern|
|
|
63
|
+
DEFAULT_EXCLUSION_TARGETS.fetch(parent_target).map do |target|
|
|
64
|
+
simple_filter(pattern, target)
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def targeted_exclusion_filters(exclusion)
|
|
70
|
+
unknown = exclusion.keys - TARGETED_EXCLUSIONS.keys
|
|
71
|
+
unless unknown.empty?
|
|
72
|
+
raise ArgumentError, "unknown targeted exclusion: #{unknown.first.inspect}"
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
exclusion.flat_map do |name, patterns|
|
|
76
|
+
exclusion_patterns(patterns).map do |pattern|
|
|
77
|
+
simple_filter(pattern, TARGETED_EXCLUSIONS.fetch(name))
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def exclusion_patterns(value)
|
|
83
|
+
values = value.is_a?(Array) ? value : [value]
|
|
84
|
+
values.each { |pattern| Pattern.compile(pattern) }
|
|
85
|
+
values
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def simple_filter(pattern, target)
|
|
89
|
+
Filter.new(regexp: Pattern.compile(pattern), target:)
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
private_class_method :new
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'find'
|
|
4
|
+
require 'pathname'
|
|
5
|
+
require_relative '../error/technical_error'
|
|
6
|
+
require_relative '../error/user_error'
|
|
7
|
+
|
|
8
|
+
module ArchUnit
|
|
9
|
+
# Ruby-specific project discovery and source extraction.
|
|
10
|
+
module Extraction
|
|
11
|
+
DEFAULT_EXCLUDED_DIRECTORIES = %w[
|
|
12
|
+
.bundle
|
|
13
|
+
.cache
|
|
14
|
+
.git
|
|
15
|
+
.hg
|
|
16
|
+
.ruby-lsp
|
|
17
|
+
.svn
|
|
18
|
+
.yardoc
|
|
19
|
+
build
|
|
20
|
+
coverage
|
|
21
|
+
dist
|
|
22
|
+
log
|
|
23
|
+
node_modules
|
|
24
|
+
pkg
|
|
25
|
+
tmp
|
|
26
|
+
vendor
|
|
27
|
+
].freeze
|
|
28
|
+
|
|
29
|
+
module_function
|
|
30
|
+
|
|
31
|
+
def enumerate_source_files(project_root, exclude_patterns: nil)
|
|
32
|
+
root = source_root(project_root)
|
|
33
|
+
patterns = resolve_exclude_patterns(exclude_patterns)
|
|
34
|
+
source_files_under(root, patterns).sort.freeze
|
|
35
|
+
rescue UserError, TechnicalError
|
|
36
|
+
raise
|
|
37
|
+
rescue SystemCallError => e
|
|
38
|
+
raise TechnicalError, "could not enumerate Ruby source files: #{e.message}"
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def source_files_under(root, exclude_patterns)
|
|
42
|
+
Find.find(root.to_s).filter_map do |entry|
|
|
43
|
+
if File.directory?(entry)
|
|
44
|
+
Find.prune if excluded_path?(entry, root, exclude_patterns)
|
|
45
|
+
next
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
if ruby_source_file?(entry) && !excluded_path?(entry, root, exclude_patterns)
|
|
49
|
+
relative_identifier(entry, root).freeze
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
private_class_method :source_files_under
|
|
54
|
+
|
|
55
|
+
def resolve_exclude_patterns(value)
|
|
56
|
+
patterns = value.nil? ? DEFAULT_EXCLUDED_DIRECTORIES : value
|
|
57
|
+
validate_exclude_patterns(patterns)
|
|
58
|
+
|
|
59
|
+
normalized = patterns.map { |pattern| normalize_exclude_pattern(pattern) }
|
|
60
|
+
raise_invalid_exclude_patterns if normalized.any?(&:empty?)
|
|
61
|
+
|
|
62
|
+
normalized.uniq.freeze
|
|
63
|
+
end
|
|
64
|
+
private_class_method :resolve_exclude_patterns
|
|
65
|
+
|
|
66
|
+
def validate_exclude_patterns(patterns)
|
|
67
|
+
return if patterns.is_a?(Array) && patterns.all? { |pattern| valid_exclude_pattern?(pattern) }
|
|
68
|
+
|
|
69
|
+
raise_invalid_exclude_patterns
|
|
70
|
+
end
|
|
71
|
+
private_class_method :validate_exclude_patterns
|
|
72
|
+
|
|
73
|
+
def raise_invalid_exclude_patterns
|
|
74
|
+
raise UserError, 'exclude_patterns must be an Array of non-empty Strings'
|
|
75
|
+
end
|
|
76
|
+
private_class_method :raise_invalid_exclude_patterns
|
|
77
|
+
|
|
78
|
+
def valid_exclude_pattern?(pattern)
|
|
79
|
+
pattern.is_a?(String) && !pattern.empty?
|
|
80
|
+
end
|
|
81
|
+
private_class_method :valid_exclude_pattern?
|
|
82
|
+
|
|
83
|
+
def normalize_exclude_pattern(pattern)
|
|
84
|
+
pattern.tr('\\', '/').delete_prefix('./').delete_suffix('/').freeze
|
|
85
|
+
end
|
|
86
|
+
private_class_method :normalize_exclude_pattern
|
|
87
|
+
|
|
88
|
+
def ruby_source_file?(entry)
|
|
89
|
+
File.file?(entry) && File.extname(entry) == '.rb'
|
|
90
|
+
end
|
|
91
|
+
private_class_method :ruby_source_file?
|
|
92
|
+
|
|
93
|
+
def source_root(value)
|
|
94
|
+
value = value.to_path if value.respond_to?(:to_path)
|
|
95
|
+
unless value.is_a?(String) && !value.empty?
|
|
96
|
+
raise UserError, 'project_root must be a non-empty path'
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
root = Pathname.new(value).expand_path
|
|
100
|
+
raise UserError, 'project_root must be an existing directory' unless root.directory?
|
|
101
|
+
|
|
102
|
+
root.realpath
|
|
103
|
+
end
|
|
104
|
+
private_class_method :source_root
|
|
105
|
+
|
|
106
|
+
def excluded_path?(entry, root, exclude_patterns)
|
|
107
|
+
return false if entry == root.to_s
|
|
108
|
+
|
|
109
|
+
relative = relative_identifier(entry, root)
|
|
110
|
+
exclude_patterns.any? do |pattern|
|
|
111
|
+
exclude_pattern_matches?(pattern, relative, File.basename(entry))
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
private_class_method :excluded_path?
|
|
115
|
+
|
|
116
|
+
def exclude_pattern_matches?(pattern, relative, basename)
|
|
117
|
+
flags = File::FNM_DOTMATCH | File::FNM_PATHNAME
|
|
118
|
+
anchored = pattern.start_with?('/')
|
|
119
|
+
candidate = pattern.delete_prefix('/')
|
|
120
|
+
target = anchored || candidate.include?('/') ? relative : basename
|
|
121
|
+
|
|
122
|
+
File.fnmatch?(candidate, target, flags)
|
|
123
|
+
end
|
|
124
|
+
private_class_method :exclude_pattern_matches?
|
|
125
|
+
|
|
126
|
+
def relative_identifier(entry, root)
|
|
127
|
+
Pathname.new(entry).relative_path_from(root).to_s.tr('\\', '/')
|
|
128
|
+
end
|
|
129
|
+
private_class_method :relative_identifier
|
|
130
|
+
end
|
|
131
|
+
end
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'pathname'
|
|
4
|
+
require_relative '../common/extraction/edge'
|
|
5
|
+
require_relative 'enumerate_source_files'
|
|
6
|
+
require_relative 'extract_imports'
|
|
7
|
+
|
|
8
|
+
module ArchUnit
|
|
9
|
+
# Turns statically resolvable Ruby imports into dependency edges.
|
|
10
|
+
module Extraction
|
|
11
|
+
module_function
|
|
12
|
+
|
|
13
|
+
def extract_dependencies(project_root, exclude_patterns: nil)
|
|
14
|
+
source_files = enumerate_source_files(project_root, exclude_patterns:)
|
|
15
|
+
root = Pathname.new(path_value(project_root)).expand_path.realpath
|
|
16
|
+
extract_dependencies_from(root, source_files)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def extract_dependencies_from(root, source_files)
|
|
20
|
+
internal_targets = internal_target_index(root, source_files)
|
|
21
|
+
|
|
22
|
+
source_files.flat_map do |source|
|
|
23
|
+
extract_imports(root.join(source), project_root: root).map do |import|
|
|
24
|
+
dependency_edge(source, import, internal_targets)
|
|
25
|
+
end
|
|
26
|
+
end.freeze
|
|
27
|
+
end
|
|
28
|
+
private_class_method :extract_dependencies_from
|
|
29
|
+
|
|
30
|
+
def path_value(value)
|
|
31
|
+
value.respond_to?(:to_path) ? value.to_path : value
|
|
32
|
+
end
|
|
33
|
+
private_class_method :path_value
|
|
34
|
+
|
|
35
|
+
def internal_target_index(root, source_files)
|
|
36
|
+
source_files.to_h do |identifier|
|
|
37
|
+
[canonical_path(root.join(identifier)), identifier]
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
private_class_method :internal_target_index
|
|
41
|
+
|
|
42
|
+
def dependency_edge(source, import, internal_targets)
|
|
43
|
+
target = import.resolved_path && internal_targets[canonical_path(import.resolved_path)]
|
|
44
|
+
|
|
45
|
+
Common::Extraction::Edge.new(
|
|
46
|
+
source: source,
|
|
47
|
+
target: target || import.module_name,
|
|
48
|
+
external: target.nil?,
|
|
49
|
+
import_kinds: [import.import_kind]
|
|
50
|
+
)
|
|
51
|
+
end
|
|
52
|
+
private_class_method :dependency_edge
|
|
53
|
+
|
|
54
|
+
def canonical_path(path)
|
|
55
|
+
Pathname.new(path).realpath.to_s.tr('\\', '/')
|
|
56
|
+
rescue SystemCallError
|
|
57
|
+
Pathname.new(path).expand_path.to_s.tr('\\', '/')
|
|
58
|
+
end
|
|
59
|
+
private_class_method :canonical_path
|
|
60
|
+
end
|
|
61
|
+
end
|