archunit 0.0.1 → 1.0.0
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 +4 -4
- data/API.md +290 -0
- data/CHANGELOG.md +45 -0
- data/README.md +389 -90
- data/assets/logo-rounded.png +0 -0
- data/lib/archunit/common/extraction/dependency_evidence.rb +44 -0
- data/lib/archunit/common/extraction/edge.rb +7 -2
- data/lib/archunit/common/extraction/import_kind.rb +2 -1
- data/lib/archunit/common/extraction/parse_diagnostic.rb +23 -0
- data/lib/archunit/common/fluentapi/check_options.rb +43 -2
- data/lib/archunit/common/fluentapi/checkable.rb +25 -1
- data/lib/archunit/common/logging/check_logger.rb +8 -0
- data/lib/archunit/common/logging/inspection.rb +59 -0
- data/lib/archunit/common/logging/logging_options.rb +5 -0
- data/lib/archunit/common/pattern.rb +18 -8
- data/lib/archunit/common/projection/cycles/johnson_cycles.rb +35 -19
- data/lib/archunit/common/projection/cycles/tarjan_scc.rb +30 -10
- data/lib/archunit/error/source_parse_error.rb +38 -0
- data/lib/archunit/extraction/arch_ignore.rb +91 -0
- data/lib/archunit/extraction/conservative_local_flow.rb +100 -0
- data/lib/archunit/extraction/constant_index.rb +110 -0
- data/lib/archunit/extraction/constant_visitor.rb +129 -0
- data/lib/archunit/extraction/discover_gemspec_load_paths.rb +96 -0
- data/lib/archunit/extraction/enumerate_source_files.rb +23 -8
- data/lib/archunit/extraction/extract_dependencies.rb +82 -14
- data/lib/archunit/extraction/extract_graph.rb +56 -12
- data/lib/archunit/extraction/extract_imports.rb +60 -124
- data/lib/archunit/extraction/extraction_profile.rb +105 -0
- data/lib/archunit/extraction/import_resolver.rb +56 -0
- data/lib/archunit/extraction/import_visitor.rb +104 -0
- data/lib/archunit/extraction/local_string_tracker.rb +80 -0
- data/lib/archunit/extraction/resolve_import.rb +5 -3
- data/lib/archunit/extraction/resolve_load_paths.rb +93 -0
- data/lib/archunit/extraction/resolved_import.rb +5 -2
- data/lib/archunit/extraction/validate_extraction_paths.rb +37 -0
- data/lib/archunit/files/extraction/extract_file_info.rb +14 -5
- data/lib/archunit/files/extraction/file_info.rb +1 -2
- data/lib/archunit/files/fluentapi/depend_on_external_module_condition.rb +2 -0
- data/lib/archunit/files/fluentapi/depend_on_external_module_condition_builder.rb +2 -0
- data/lib/archunit/files/fluentapi/depend_on_file_condition.rb +6 -0
- data/lib/archunit/files/fluentapi/depend_on_file_condition_builder.rb +6 -0
- data/lib/archunit/files/fluentapi/file_condition_builder.rb +12 -0
- data/lib/archunit/files/fluentapi/file_rule_support.rb +8 -1
- data/lib/archunit/files/fluentapi/files.rb +6 -0
- data/lib/archunit/files/fluentapi/match_pattern_file_condition_builder.rb +14 -0
- data/lib/archunit/files/fluentapi/positive_match_pattern_file_condition_builder.rb +2 -0
- data/lib/archunit/graph/fluentapi/graph.rb +5 -0
- data/lib/archunit/graph/fluentapi/project_graph_builder.rb +44 -0
- data/lib/archunit/graph/projection/aggregate_edges.rb +4 -2
- data/lib/archunit/graph/projection/graph_query_options.rb +1 -2
- data/lib/archunit/graph/projection/graph_report_edge.rb +12 -2
- data/lib/archunit/graph/rendering/json_renderer.rb +3 -1
- data/lib/archunit/layers/fluentapi/layer_definition_builder.rb +4 -0
- data/lib/archunit/layers/fluentapi/layer_dependency_rule_builder.rb +4 -0
- data/lib/archunit/layers/fluentapi/layered_architecture.rb +5 -0
- data/lib/archunit/layers/fluentapi/layers.rb +5 -0
- data/lib/archunit/metrics/extraction/extract_distance_info.rb +1 -2
- data/lib/archunit/metrics/extraction/extract_project_info.rb +6 -6
- data/lib/archunit/metrics/extraction/metric_info.rb +2 -4
- data/lib/archunit/metrics/extraction/source_metrics_visitor.rb +2 -4
- data/lib/archunit/metrics/fluentapi/custom_metric_builder.rb +2 -0
- data/lib/archunit/metrics/fluentapi/custom_metric_condition.rb +4 -1
- data/lib/archunit/metrics/fluentapi/logged_metric.rb +23 -0
- data/lib/archunit/metrics/fluentapi/metric_predicate_condition.rb +4 -1
- data/lib/archunit/metrics/fluentapi/metric_report_builder.rb +2 -0
- data/lib/archunit/metrics/fluentapi/metric_selection.rb +18 -2
- data/lib/archunit/metrics/fluentapi/metric_threshold_condition.rb +2 -1
- data/lib/archunit/metrics/fluentapi/metrics.rb +5 -0
- data/lib/archunit/metrics/fluentapi/metrics_builder.rb +15 -0
- data/lib/archunit/slices/fluentapi/diagram_slice_condition.rb +1 -0
- data/lib/archunit/slices/fluentapi/forbidden_slice_dependency_condition.rb +1 -0
- data/lib/archunit/slices/fluentapi/negative_slice_condition_builder.rb +2 -0
- data/lib/archunit/slices/fluentapi/positive_slice_condition_builder.rb +8 -0
- data/lib/archunit/slices/fluentapi/slice_scope_builder.rb +12 -0
- data/lib/archunit/slices/fluentapi/slices.rb +5 -0
- data/lib/archunit/testing/assert_passes.rb +4 -0
- data/lib/archunit/testing/result_factory.rb +16 -4
- data/lib/archunit/testing/rspec_adapter.rb +1 -2
- data/lib/archunit/testing.rb +7 -3
- data/lib/archunit/version.rb +1 -1
- data/lib/archunit.rb +9 -0
- metadata +20 -2
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'pathname'
|
|
4
|
+
require_relative '../error/user_error'
|
|
5
|
+
require_relative 'discover_gemspec_load_paths'
|
|
6
|
+
|
|
7
|
+
module ArchUnit
|
|
8
|
+
module Extraction
|
|
9
|
+
# Builds the deterministic feature search path used for static Ruby import resolution.
|
|
10
|
+
module LoadPaths
|
|
11
|
+
module_function
|
|
12
|
+
|
|
13
|
+
def resolve(project_root, explicit_paths: [], source_files: nil)
|
|
14
|
+
root = canonical_root(project_root)
|
|
15
|
+
defaults = [root.join('lib'), root].select(&:directory?)
|
|
16
|
+
explicit = resolve_explicit_paths(root, explicit_paths)
|
|
17
|
+
discovered = GemspecLoadPaths.resolve(root, source_files:)
|
|
18
|
+
|
|
19
|
+
deduplicate(defaults + explicit + discovered)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def canonical_root(value)
|
|
23
|
+
path = value.respond_to?(:to_path) ? value.to_path : value
|
|
24
|
+
unless path.is_a?(String) && !path.empty?
|
|
25
|
+
raise UserError, 'project_root must be a non-empty path'
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
root = Pathname.new(path).expand_path
|
|
29
|
+
raise UserError, 'project_root must be an existing directory' unless root.directory?
|
|
30
|
+
|
|
31
|
+
root.realpath
|
|
32
|
+
end
|
|
33
|
+
private_class_method :canonical_root
|
|
34
|
+
|
|
35
|
+
def resolve_explicit_paths(root, paths)
|
|
36
|
+
validate_explicit_paths(paths)
|
|
37
|
+
paths.map { |value| resolve_explicit_path(root, value) }
|
|
38
|
+
end
|
|
39
|
+
private_class_method :resolve_explicit_paths
|
|
40
|
+
|
|
41
|
+
def resolve_explicit_path(root, value)
|
|
42
|
+
candidate = Pathname.new(value.tr('\\', '/'))
|
|
43
|
+
candidate = root.join(candidate) unless candidate.absolute?
|
|
44
|
+
candidate = candidate.expand_path
|
|
45
|
+
validate_explicit_path(candidate, root, value)
|
|
46
|
+
candidate.realpath
|
|
47
|
+
end
|
|
48
|
+
private_class_method :resolve_explicit_path
|
|
49
|
+
|
|
50
|
+
def validate_explicit_path(candidate, root, value)
|
|
51
|
+
unless candidate.directory?
|
|
52
|
+
raise UserError, "load path must be an existing directory: #{value}"
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
canonical = candidate.realpath
|
|
56
|
+
return if within_project?(canonical, root)
|
|
57
|
+
|
|
58
|
+
raise UserError, "load path must stay inside the project root: #{value}"
|
|
59
|
+
end
|
|
60
|
+
private_class_method :validate_explicit_path
|
|
61
|
+
|
|
62
|
+
def validate_explicit_paths(paths)
|
|
63
|
+
valid = paths.is_a?(Array) && paths.all? do |path|
|
|
64
|
+
path.is_a?(String) && !path.empty?
|
|
65
|
+
end
|
|
66
|
+
return if valid
|
|
67
|
+
|
|
68
|
+
raise UserError, 'load_paths must be an Array of non-empty paths'
|
|
69
|
+
end
|
|
70
|
+
private_class_method :validate_explicit_paths
|
|
71
|
+
|
|
72
|
+
def within_project?(path, root)
|
|
73
|
+
path.relative_path_from(root).each_filename.first != '..'
|
|
74
|
+
rescue ArgumentError
|
|
75
|
+
false
|
|
76
|
+
end
|
|
77
|
+
private_class_method :within_project?
|
|
78
|
+
|
|
79
|
+
def deduplicate(paths)
|
|
80
|
+
paths.each_with_object([]) do |path, result|
|
|
81
|
+
value = normalized(path).freeze
|
|
82
|
+
result << value unless result.include?(value)
|
|
83
|
+
end.freeze
|
|
84
|
+
end
|
|
85
|
+
private_class_method :deduplicate
|
|
86
|
+
|
|
87
|
+
def normalized(path)
|
|
88
|
+
path.to_s.tr('\\', '/')
|
|
89
|
+
end
|
|
90
|
+
private_class_method :normalized
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
@@ -4,11 +4,14 @@ require_relative '../common/extraction/import_kind'
|
|
|
4
4
|
|
|
5
5
|
module ArchUnit
|
|
6
6
|
module Extraction
|
|
7
|
-
ResolvedImport = Data.define(:module_name, :import_kind, :line_number, :resolved_path
|
|
8
|
-
|
|
7
|
+
ResolvedImport = Data.define(:module_name, :import_kind, :line_number, :resolved_path,
|
|
8
|
+
:end_line) do
|
|
9
|
+
def initialize(module_name:, import_kind:, line_number:, resolved_path: nil,
|
|
10
|
+
end_line: line_number)
|
|
9
11
|
validate_module_name(module_name)
|
|
10
12
|
validate_import_kind(import_kind)
|
|
11
13
|
validate_line_number(line_number)
|
|
14
|
+
validate_line_number(end_line)
|
|
12
15
|
validate_resolved_path(resolved_path)
|
|
13
16
|
|
|
14
17
|
module_name = module_name.dup.freeze
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'pathname'
|
|
4
|
+
require_relative '../error/user_error'
|
|
5
|
+
|
|
6
|
+
module ArchUnit
|
|
7
|
+
module Extraction
|
|
8
|
+
# Validates public extractor paths before the trusted internal pipeline runs.
|
|
9
|
+
module ExtractionPaths
|
|
10
|
+
module_function
|
|
11
|
+
|
|
12
|
+
def source_file(value)
|
|
13
|
+
path = extraction_path(value, 'source_file')
|
|
14
|
+
raise UserError, 'source_file must be an existing file' unless path.file?
|
|
15
|
+
|
|
16
|
+
path
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def project_root(value)
|
|
20
|
+
path = extraction_path(value, 'project_root')
|
|
21
|
+
raise UserError, 'project_root must be an existing directory' unless path.directory?
|
|
22
|
+
|
|
23
|
+
path.realpath
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def extraction_path(value, attribute)
|
|
27
|
+
value = value.to_path if value.respond_to?(:to_path)
|
|
28
|
+
unless value.is_a?(String) && !value.empty?
|
|
29
|
+
raise UserError, "#{attribute} must be a non-empty path"
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
Pathname.new(value).expand_path
|
|
33
|
+
end
|
|
34
|
+
private_class_method :extraction_path
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -63,16 +63,25 @@ module ArchUnit
|
|
|
63
63
|
|
|
64
64
|
def source_path_within(root, identifier)
|
|
65
65
|
path = root.join(identifier).cleanpath
|
|
66
|
-
|
|
67
|
-
if relative == '..' || relative.start_with?('../')
|
|
68
|
-
raise TechnicalError, 'source file must be inside the project root'
|
|
69
|
-
end
|
|
66
|
+
ensure_inside_root(root, path)
|
|
70
67
|
raise TechnicalError, "source file does not exist: #{identifier}" unless path.file?
|
|
71
68
|
|
|
72
|
-
path
|
|
69
|
+
resolved = path.realpath
|
|
70
|
+
ensure_inside_root(root, resolved)
|
|
71
|
+
resolved
|
|
73
72
|
end
|
|
74
73
|
private_class_method :source_path_within
|
|
75
74
|
|
|
75
|
+
def ensure_inside_root(root, path)
|
|
76
|
+
relative = path.relative_path_from(root).to_s.tr('\\', '/')
|
|
77
|
+
return unless relative == '..' || relative.start_with?('../')
|
|
78
|
+
|
|
79
|
+
raise TechnicalError, 'source file must be inside the project root'
|
|
80
|
+
rescue ArgumentError
|
|
81
|
+
raise TechnicalError, 'source file must be inside the project root'
|
|
82
|
+
end
|
|
83
|
+
private_class_method :ensure_inside_root
|
|
84
|
+
|
|
76
85
|
def directory_of(identifier)
|
|
77
86
|
return '' unless identifier.include?('/')
|
|
78
87
|
|
|
@@ -8,7 +8,7 @@ module ArchUnit
|
|
|
8
8
|
FileInfo = Data.define(
|
|
9
9
|
:path, :name, :extension, :directory, :content, :lines_of_code
|
|
10
10
|
) do
|
|
11
|
-
# rubocop:disable Metrics/ParameterLists -- FileInfo has six required public fields.
|
|
11
|
+
# rubocop:disable-next Metrics/ParameterLists -- FileInfo has six required public fields.
|
|
12
12
|
def initialize(path:, name:, extension:, directory:, content:, lines_of_code:)
|
|
13
13
|
path = immutable_string(path, :path, allow_empty: false, normalize_path: true)
|
|
14
14
|
name = immutable_string(name, :name, allow_empty: false)
|
|
@@ -20,7 +20,6 @@ module ArchUnit
|
|
|
20
20
|
lines_of_code = non_negative_integer(lines_of_code)
|
|
21
21
|
super
|
|
22
22
|
end
|
|
23
|
-
# rubocop:enable Metrics/ParameterLists
|
|
24
23
|
|
|
25
24
|
private
|
|
26
25
|
|
|
@@ -34,6 +34,8 @@ module ArchUnit
|
|
|
34
34
|
is_negated
|
|
35
35
|
end
|
|
36
36
|
|
|
37
|
+
# Adds another external module pattern using OR semantics.
|
|
38
|
+
# @return [DependOnExternalModuleCondition]
|
|
37
39
|
def matching(module_name, except: nil)
|
|
38
40
|
filter = Common::RegexFactory.path_matcher(module_name, except:)
|
|
39
41
|
self.class.new(builder, module_filters: [*module_filters, filter])
|
|
@@ -26,6 +26,8 @@ module ArchUnit
|
|
|
26
26
|
is_negated
|
|
27
27
|
end
|
|
28
28
|
|
|
29
|
+
# Selects an allowed or forbidden external module name.
|
|
30
|
+
# @return [DependOnExternalModuleCondition]
|
|
29
31
|
def matching(module_name, except: nil)
|
|
30
32
|
filter = Common::RegexFactory.path_matcher(module_name, except:)
|
|
31
33
|
DependOnExternalModuleCondition.new(self, module_filters: [filter])
|
|
@@ -34,14 +34,20 @@ module ArchUnit
|
|
|
34
34
|
is_negated
|
|
35
35
|
end
|
|
36
36
|
|
|
37
|
+
# Adds another allowed or forbidden basename using OR semantics.
|
|
38
|
+
# @return [DependOnFileCondition]
|
|
37
39
|
def with_name(pattern, except: nil)
|
|
38
40
|
with_filter(Common::RegexFactory.filename_matcher(pattern, except:))
|
|
39
41
|
end
|
|
40
42
|
|
|
43
|
+
# Adds another allowed or forbidden folder using OR semantics.
|
|
44
|
+
# @return [DependOnFileCondition]
|
|
41
45
|
def in_folder(pattern, except: nil)
|
|
42
46
|
with_filter(Common::RegexFactory.folder_matcher(pattern, except:))
|
|
43
47
|
end
|
|
44
48
|
|
|
49
|
+
# Adds another allowed or forbidden path using OR semantics.
|
|
50
|
+
# @return [DependOnFileCondition]
|
|
45
51
|
def in_path(pattern, except: nil)
|
|
46
52
|
with_filter(Common::RegexFactory.path_matcher(pattern, except:))
|
|
47
53
|
end
|
|
@@ -26,14 +26,20 @@ module ArchUnit
|
|
|
26
26
|
is_negated
|
|
27
27
|
end
|
|
28
28
|
|
|
29
|
+
# Selects dependency targets by basename.
|
|
30
|
+
# @return [DependOnFileCondition]
|
|
29
31
|
def with_name(pattern, except: nil)
|
|
30
32
|
condition(Common::RegexFactory.filename_matcher(pattern, except:))
|
|
31
33
|
end
|
|
32
34
|
|
|
35
|
+
# Selects dependency targets by containing folder.
|
|
36
|
+
# @return [DependOnFileCondition]
|
|
33
37
|
def in_folder(pattern, except: nil)
|
|
34
38
|
condition(Common::RegexFactory.folder_matcher(pattern, except:))
|
|
35
39
|
end
|
|
36
40
|
|
|
41
|
+
# Selects dependency targets by complete path.
|
|
42
|
+
# @return [DependOnFileCondition]
|
|
37
43
|
def in_path(pattern, except: nil)
|
|
38
44
|
condition(Common::RegexFactory.path_matcher(pattern, except:))
|
|
39
45
|
end
|
|
@@ -16,26 +16,38 @@ module ArchUnit
|
|
|
16
16
|
freeze
|
|
17
17
|
end
|
|
18
18
|
|
|
19
|
+
# Selects files by basename.
|
|
20
|
+
# @return [FileConditionBuilder]
|
|
19
21
|
def with_name(pattern, except: nil)
|
|
20
22
|
with_filter(Common::RegexFactory.filename_matcher(pattern, except:))
|
|
21
23
|
end
|
|
22
24
|
|
|
25
|
+
# Selects files by project-relative containing folder.
|
|
26
|
+
# @return [FileConditionBuilder]
|
|
23
27
|
def in_folder(pattern, except: nil)
|
|
24
28
|
with_filter(Common::RegexFactory.folder_matcher(pattern, except:))
|
|
25
29
|
end
|
|
26
30
|
|
|
31
|
+
# Selects files by complete project-relative path.
|
|
32
|
+
# @return [FileConditionBuilder]
|
|
27
33
|
def in_path(pattern, except: nil)
|
|
28
34
|
with_filter(Common::RegexFactory.path_matcher(pattern, except:))
|
|
29
35
|
end
|
|
30
36
|
|
|
37
|
+
# Selects one exact project-relative file.
|
|
38
|
+
# @return [FileConditionBuilder]
|
|
31
39
|
def in_file(file_path, except: nil)
|
|
32
40
|
with_filter(Common::RegexFactory.exact_file_matcher(file_path, except:))
|
|
33
41
|
end
|
|
34
42
|
|
|
43
|
+
# Enters the positive rule mood.
|
|
44
|
+
# @return [PositiveMatchPatternFileConditionBuilder]
|
|
35
45
|
def should
|
|
36
46
|
PositiveMatchPatternFileConditionBuilder.new(self)
|
|
37
47
|
end
|
|
38
48
|
|
|
49
|
+
# Enters the negated rule mood.
|
|
50
|
+
# @return [NegatedMatchPatternFileConditionBuilder]
|
|
39
51
|
def should_not
|
|
40
52
|
NegatedMatchPatternFileConditionBuilder.new(self)
|
|
41
53
|
end
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative '../../common/pattern_matching'
|
|
4
|
+
require_relative '../../common/logging/inspection'
|
|
4
5
|
require_relative '../../common/projection/project_to_nodes'
|
|
5
6
|
|
|
6
7
|
module ArchUnit
|
|
@@ -12,12 +13,18 @@ module ArchUnit
|
|
|
12
13
|
|
|
13
14
|
def selected_nodes(graph, filters)
|
|
14
15
|
nodes = Common::Projection.project_to_nodes(graph)
|
|
15
|
-
|
|
16
|
+
selected = filters.empty? ? nodes : matching_nodes(nodes, filters)
|
|
17
|
+
Common::Logging::Inspection.selection(
|
|
18
|
+
'selected file', selected, &:label
|
|
19
|
+
)
|
|
20
|
+
end
|
|
16
21
|
|
|
22
|
+
def matching_nodes(nodes, filters)
|
|
17
23
|
nodes.select do |node|
|
|
18
24
|
Common::PatternMatching.matches_all_patterns?(node.label, filters)
|
|
19
25
|
end
|
|
20
26
|
end
|
|
27
|
+
private_class_method :matching_nodes
|
|
21
28
|
end
|
|
22
29
|
end
|
|
23
30
|
end
|
|
@@ -11,6 +11,9 @@ module ArchUnit
|
|
|
11
11
|
module FluentApi
|
|
12
12
|
module_function
|
|
13
13
|
|
|
14
|
+
# Starts an immutable file-rule sentence.
|
|
15
|
+
# @param project_locator [String, Pathname, nil] project directory, Gemfile, or gemspec
|
|
16
|
+
# @return [FileConditionBuilder]
|
|
14
17
|
def project_files(project_locator = nil)
|
|
15
18
|
FileConditionBuilder.new(project_locator:)
|
|
16
19
|
end
|
|
@@ -21,6 +24,9 @@ module ArchUnit
|
|
|
21
24
|
end
|
|
22
25
|
end
|
|
23
26
|
|
|
27
|
+
# Starts an immutable file-rule sentence.
|
|
28
|
+
# @param project_locator [String, Pathname, nil] project directory, Gemfile, or gemspec
|
|
29
|
+
# @return [Files::FluentApi::FileConditionBuilder]
|
|
24
30
|
def self.project_files(project_locator = nil)
|
|
25
31
|
Files::FluentApi.project_files(project_locator)
|
|
26
32
|
end
|
|
@@ -31,26 +31,40 @@ module ArchUnit
|
|
|
31
31
|
@negated
|
|
32
32
|
end
|
|
33
33
|
|
|
34
|
+
# Requires selected files to match a basename pattern.
|
|
35
|
+
# @return [MatchPatternFileCondition]
|
|
34
36
|
def have_name(pattern, except: nil)
|
|
35
37
|
matching(Common::RegexFactory.filename_matcher(pattern, except:))
|
|
36
38
|
end
|
|
37
39
|
|
|
40
|
+
# Requires selected files to match a containing-folder pattern.
|
|
41
|
+
# @return [MatchPatternFileCondition]
|
|
38
42
|
def be_in_folder(pattern, except: nil)
|
|
39
43
|
matching(Common::RegexFactory.folder_matcher(pattern, except:))
|
|
40
44
|
end
|
|
41
45
|
|
|
46
|
+
# Requires selected files to match a complete-path pattern.
|
|
47
|
+
# @return [MatchPatternFileCondition]
|
|
42
48
|
def be_in_path(pattern, except: nil)
|
|
43
49
|
matching(Common::RegexFactory.path_matcher(pattern, except:))
|
|
44
50
|
end
|
|
45
51
|
|
|
52
|
+
# Starts the object stage for internal file dependencies.
|
|
53
|
+
# @return [DependOnFileConditionBuilder]
|
|
46
54
|
def depend_on_files
|
|
47
55
|
DependOnFileConditionBuilder.new(self)
|
|
48
56
|
end
|
|
49
57
|
|
|
58
|
+
# Starts the object stage for standard-library and gem imports.
|
|
59
|
+
# @return [DependOnExternalModuleConditionBuilder]
|
|
50
60
|
def depend_on_external_modules
|
|
51
61
|
DependOnExternalModuleConditionBuilder.new(self)
|
|
52
62
|
end
|
|
53
63
|
|
|
64
|
+
# Applies a custom predicate to each selected FileInfo.
|
|
65
|
+
# @param condition [#call] predicate receiving one FileInfo
|
|
66
|
+
# @param message [String] explanation used for violations
|
|
67
|
+
# @return [CustomFileCondition]
|
|
54
68
|
def adhere_to(condition, message)
|
|
55
69
|
CustomFileCondition.new(self, condition:, message:)
|
|
56
70
|
end
|
|
@@ -9,6 +9,8 @@ module ArchUnit
|
|
|
9
9
|
module FluentApi
|
|
10
10
|
module_function
|
|
11
11
|
|
|
12
|
+
# Starts an immutable dependency-graph report.
|
|
13
|
+
# @return [ProjectGraphBuilder]
|
|
12
14
|
def project_graph(project_locator = nil)
|
|
13
15
|
ProjectGraphBuilder.new(project_locator:)
|
|
14
16
|
end
|
|
@@ -19,6 +21,9 @@ module ArchUnit
|
|
|
19
21
|
end
|
|
20
22
|
end
|
|
21
23
|
|
|
24
|
+
# Starts an immutable dependency-graph report.
|
|
25
|
+
# @param project_locator [String, Pathname, nil] project directory, Gemfile, or gemspec
|
|
26
|
+
# @return [GraphReporting::FluentApi::ProjectGraphBuilder]
|
|
22
27
|
def self.project_graph(project_locator = nil)
|
|
23
28
|
GraphReporting::FluentApi.project_graph(project_locator)
|
|
24
29
|
end
|
|
@@ -10,6 +10,30 @@ module ArchUnit
|
|
|
10
10
|
module GraphReporting
|
|
11
11
|
module FluentApi
|
|
12
12
|
# Immutable query builder for dependency graph snapshots and reports.
|
|
13
|
+
# @!method to_dot
|
|
14
|
+
# @return [String] the current graph report as Graphviz DOT
|
|
15
|
+
# @!method to_mermaid
|
|
16
|
+
# @return [String] the current graph report as Mermaid
|
|
17
|
+
# @!method to_d2
|
|
18
|
+
# @return [String] the current graph report as D2
|
|
19
|
+
# @!method to_csv
|
|
20
|
+
# @return [String] the current graph report as CSV
|
|
21
|
+
# @!method to_json
|
|
22
|
+
# @return [String] the current graph report as JSON
|
|
23
|
+
# @!method to_html
|
|
24
|
+
# @return [String] the current graph report as self-contained HTML
|
|
25
|
+
# @!method export_as_dot(output_path)
|
|
26
|
+
# @return [nil] writes Graphviz DOT to output_path
|
|
27
|
+
# @!method export_as_mermaid(output_path)
|
|
28
|
+
# @return [nil] writes Mermaid to output_path
|
|
29
|
+
# @!method export_as_d2(output_path)
|
|
30
|
+
# @return [nil] writes D2 to output_path
|
|
31
|
+
# @!method export_as_csv(output_path)
|
|
32
|
+
# @return [nil] writes CSV to output_path
|
|
33
|
+
# @!method export_as_json(output_path)
|
|
34
|
+
# @return [nil] writes JSON to output_path
|
|
35
|
+
# @!method export_as_html(output_path)
|
|
36
|
+
# @return [nil] writes self-contained HTML to output_path
|
|
13
37
|
class ProjectGraphBuilder
|
|
14
38
|
attr_reader :project_locator, :options, :check_options
|
|
15
39
|
|
|
@@ -20,51 +44,71 @@ module ArchUnit
|
|
|
20
44
|
freeze
|
|
21
45
|
end
|
|
22
46
|
|
|
47
|
+
# Includes standard-library and gem imports in the report.
|
|
48
|
+
# @return [ProjectGraphBuilder]
|
|
23
49
|
def include_external_dependencies
|
|
24
50
|
with_options(options.with(include_external_dependencies: true))
|
|
25
51
|
end
|
|
26
52
|
|
|
53
|
+
# Includes the self-edges that represent dependency-free source files.
|
|
54
|
+
# @return [ProjectGraphBuilder]
|
|
27
55
|
def include_self_dependencies
|
|
28
56
|
with_options(options.with(include_self_dependencies: true))
|
|
29
57
|
end
|
|
30
58
|
|
|
59
|
+
# Keeps matching nodes and neighbors within depth hops.
|
|
60
|
+
# @return [ProjectGraphBuilder]
|
|
31
61
|
def focus_on(pattern, depth = 1, except: nil)
|
|
32
62
|
filter = Common::RegexFactory.path_matcher(pattern, except:)
|
|
33
63
|
with_options(options.with(focus: filter, focus_depth: depth))
|
|
34
64
|
end
|
|
35
65
|
|
|
66
|
+
# Keeps nodes reachable from matching starting nodes.
|
|
67
|
+
# @return [ProjectGraphBuilder]
|
|
36
68
|
def reachable_from(pattern, except: nil)
|
|
37
69
|
filter = Common::RegexFactory.path_matcher(pattern, except:)
|
|
38
70
|
with_options(options.with(reachable_from: filter))
|
|
39
71
|
end
|
|
40
72
|
|
|
73
|
+
# Keeps matching nodes and everything that depends on them.
|
|
74
|
+
# @return [ProjectGraphBuilder]
|
|
41
75
|
def dependents_of(pattern, except: nil)
|
|
42
76
|
filter = Common::RegexFactory.path_matcher(pattern, except:)
|
|
43
77
|
with_options(options.with(dependents_of: filter))
|
|
44
78
|
end
|
|
45
79
|
|
|
80
|
+
# Groups file nodes by the requested project-relative folder depth.
|
|
81
|
+
# @return [ProjectGraphBuilder]
|
|
46
82
|
def collapse_to_folder_depth(depth)
|
|
47
83
|
with_options(options.with(collapse: Projection::FolderDepthCollapse.new(depth:)))
|
|
48
84
|
end
|
|
49
85
|
|
|
86
|
+
# Groups node names through a regular-expression replacement.
|
|
87
|
+
# @return [ProjectGraphBuilder]
|
|
50
88
|
def collapse_by_pattern(pattern, replacement = '\\1')
|
|
51
89
|
collapse = Projection::PatternCollapse.from(pattern, replacement)
|
|
52
90
|
with_options(options.with(collapse:))
|
|
53
91
|
end
|
|
54
92
|
|
|
93
|
+
# Sets the report title.
|
|
94
|
+
# @return [ProjectGraphBuilder]
|
|
55
95
|
def titled(title)
|
|
56
96
|
with_options(options.with(title:))
|
|
57
97
|
end
|
|
58
98
|
|
|
99
|
+
# Attaches extraction-related CheckOptions to this report.
|
|
100
|
+
# @return [ProjectGraphBuilder]
|
|
59
101
|
def with_check_options(value)
|
|
60
102
|
self.class.new(project_locator:, options:, check_options: value)
|
|
61
103
|
end
|
|
62
104
|
|
|
105
|
+
# @return [GraphReportSnapshot] the immutable queried graph
|
|
63
106
|
def snapshot
|
|
64
107
|
graph = ArchUnit::Extraction.extract_graph(project_locator, options: check_options)
|
|
65
108
|
Projection::SnapshotFactory.create(graph, options)
|
|
66
109
|
end
|
|
67
110
|
|
|
111
|
+
# @return [GraphReportSummary] counts for the current snapshot
|
|
68
112
|
def summary
|
|
69
113
|
snapshot.summary
|
|
70
114
|
end
|
|
@@ -12,7 +12,7 @@ module ArchUnit
|
|
|
12
12
|
|
|
13
13
|
def aggregate(edges, collapse:, include_self_dependencies:)
|
|
14
14
|
groups = Hash.new do |hash, key|
|
|
15
|
-
hash[key] = { count: 0, external: false, import_kinds: [] }
|
|
15
|
+
hash[key] = { count: 0, external: false, import_kinds: [], evidence: [] }
|
|
16
16
|
end
|
|
17
17
|
|
|
18
18
|
edges.each do |edge|
|
|
@@ -30,6 +30,7 @@ module ArchUnit
|
|
|
30
30
|
group[:count] += 1
|
|
31
31
|
group[:external] ||= edge.external
|
|
32
32
|
group[:import_kinds] |= edge.import_kinds
|
|
33
|
+
group[:evidence] |= edge.evidence
|
|
33
34
|
end
|
|
34
35
|
private_class_method :accumulate
|
|
35
36
|
|
|
@@ -37,7 +38,8 @@ module ArchUnit
|
|
|
37
38
|
groups.sort_by { |(source, target), _group| [source, target] }.map do |key, group|
|
|
38
39
|
GraphReportEdge.new(
|
|
39
40
|
source: key.first, target: key.last, count: group[:count],
|
|
40
|
-
external: group[:external], import_kinds: group[:import_kinds]
|
|
41
|
+
external: group[:external], import_kinds: group[:import_kinds],
|
|
42
|
+
evidence: group[:evidence]
|
|
41
43
|
)
|
|
42
44
|
end.freeze
|
|
43
45
|
end
|
|
@@ -21,7 +21,7 @@ module ArchUnit
|
|
|
21
21
|
end
|
|
22
22
|
|
|
23
23
|
# The options bag deliberately mirrors the eight independent fluent modifiers.
|
|
24
|
-
# rubocop:disable Metrics/ParameterLists
|
|
24
|
+
# rubocop:disable-next Metrics/ParameterLists
|
|
25
25
|
def initialize(
|
|
26
26
|
include_external_dependencies: false, include_self_dependencies: false,
|
|
27
27
|
focus: nil, focus_depth: 1, reachable_from: nil, dependents_of: nil,
|
|
@@ -37,7 +37,6 @@ module ArchUnit
|
|
|
37
37
|
title = immutable_title(title)
|
|
38
38
|
super
|
|
39
39
|
end
|
|
40
|
-
# rubocop:enable Metrics/ParameterLists
|
|
41
40
|
|
|
42
41
|
def with(**overrides)
|
|
43
42
|
self.class.new(**to_h, **overrides)
|
|
@@ -1,18 +1,28 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative '../../common/extraction/import_kind'
|
|
4
|
+
require_relative '../../common/extraction/dependency_evidence'
|
|
4
5
|
|
|
5
6
|
module ArchUnit
|
|
6
7
|
module GraphReporting
|
|
7
8
|
module Projection
|
|
8
9
|
# Aggregated dependency evidence in a graph report snapshot.
|
|
9
|
-
GraphReportEdge = Data.define(:source, :target, :count, :external, :import_kinds
|
|
10
|
-
|
|
10
|
+
GraphReportEdge = Data.define(:source, :target, :count, :external, :import_kinds,
|
|
11
|
+
:evidence) do
|
|
12
|
+
# Keywords mirror this immutable report value.
|
|
13
|
+
def initialize( # rubocop:disable Metrics/ParameterLists
|
|
14
|
+
source:, target:, count:, external:, import_kinds: [], evidence: []
|
|
15
|
+
)
|
|
11
16
|
source = immutable_string(source, :source)
|
|
12
17
|
target = immutable_string(target, :target)
|
|
13
18
|
validate_count(count)
|
|
14
19
|
validate_external(external)
|
|
15
20
|
import_kinds = immutable_import_kinds(import_kinds)
|
|
21
|
+
evidence = Array(evidence).uniq.freeze
|
|
22
|
+
unless evidence.all?(Common::Extraction::DependencyEvidence)
|
|
23
|
+
raise ArgumentError, 'evidence must contain DependencyEvidence values'
|
|
24
|
+
end
|
|
25
|
+
|
|
16
26
|
super
|
|
17
27
|
end
|
|
18
28
|
|
|
@@ -26,10 +26,12 @@ module ArchUnit
|
|
|
26
26
|
private_class_method :snapshot_hash
|
|
27
27
|
|
|
28
28
|
def edge_hash(edge)
|
|
29
|
-
{
|
|
29
|
+
result = {
|
|
30
30
|
source: edge.source, target: edge.target, count: edge.count,
|
|
31
31
|
external: edge.external, import_kinds: edge.import_kinds
|
|
32
32
|
}
|
|
33
|
+
result[:evidence] = edge.evidence.map(&:to_h) unless edge.evidence.empty?
|
|
34
|
+
result
|
|
33
35
|
end
|
|
34
36
|
private_class_method :edge_hash
|
|
35
37
|
end
|
|
@@ -15,10 +15,14 @@ module ArchUnit
|
|
|
15
15
|
freeze
|
|
16
16
|
end
|
|
17
17
|
|
|
18
|
+
# Adds a full-path selector to this layer definition.
|
|
19
|
+
# @return [LayeredArchitecture]
|
|
18
20
|
def defined_by(pattern, except: nil)
|
|
19
21
|
add_filter(Common::RegexFactory.path_matcher(pattern, except:))
|
|
20
22
|
end
|
|
21
23
|
|
|
24
|
+
# Adds a containing-folder selector to this layer definition.
|
|
25
|
+
# @return [LayeredArchitecture]
|
|
22
26
|
def defined_by_folder(pattern, except: nil)
|
|
23
27
|
add_filter(Common::RegexFactory.folder_matcher(pattern, except:))
|
|
24
28
|
end
|
|
@@ -13,10 +13,14 @@ module ArchUnit
|
|
|
13
13
|
freeze
|
|
14
14
|
end
|
|
15
15
|
|
|
16
|
+
# Allows only the listed target layers; no targets seals the source layer.
|
|
17
|
+
# @return [LayeredArchitecture]
|
|
16
18
|
def may_only_depend_on_layers(*layer_names)
|
|
17
19
|
architecture.__send__(:with_allowed_dependencies, layer_name, layer_names)
|
|
18
20
|
end
|
|
19
21
|
|
|
22
|
+
# Forbids dependencies on the listed target layers.
|
|
23
|
+
# @return [LayeredArchitecture]
|
|
20
24
|
def may_not_depend_on_layers(*layer_names)
|
|
21
25
|
if layer_names.empty?
|
|
22
26
|
raise ArgumentError, 'may_not_depend_on_layers requires at least one layer name'
|
|
@@ -34,10 +34,14 @@ module ArchUnit
|
|
|
34
34
|
freeze
|
|
35
35
|
end
|
|
36
36
|
|
|
37
|
+
# Starts or extends the definition of a named layer.
|
|
38
|
+
# @return [LayerDefinitionBuilder]
|
|
37
39
|
def layer(name)
|
|
38
40
|
LayerDefinitionBuilder.new(self, validated_layer_name(name))
|
|
39
41
|
end
|
|
40
42
|
|
|
43
|
+
# Selects a defined source layer for a dependency policy.
|
|
44
|
+
# @return [LayerDependencyRuleBuilder]
|
|
41
45
|
def where_layer(name)
|
|
42
46
|
name = validated_layer_name(name)
|
|
43
47
|
ensure_defined_layer!(name)
|
|
@@ -71,6 +75,7 @@ module ArchUnit
|
|
|
71
75
|
graph = ArchUnit::Extraction.extract_graph(project_locator, options:)
|
|
72
76
|
nodes = Common::Projection.project_to_nodes(graph)
|
|
73
77
|
edges = Common::Projection.project_edges(graph, Common::Projection.per_internal_edge)
|
|
78
|
+
Common::Logging::Inspection.projection(edges)
|
|
74
79
|
|
|
75
80
|
empty_policy_violations(nodes, options) +
|
|
76
81
|
Assertion.gather_layer_dependency_violations(
|