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,89 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'pathname'
|
|
4
|
+
require_relative '../common/extraction/edge'
|
|
5
|
+
require_relative '../common/extraction/graph'
|
|
6
|
+
require_relative '../common/fluentapi/check_options'
|
|
7
|
+
require_relative 'enumerate_source_files'
|
|
8
|
+
require_relative 'extract_dependencies'
|
|
9
|
+
require_relative 'locate_project'
|
|
10
|
+
|
|
11
|
+
module ArchUnit
|
|
12
|
+
# Assembles the normalized dependency graph for a Ruby project.
|
|
13
|
+
module Extraction
|
|
14
|
+
GRAPH_CACHE_MUTEX = Mutex.new
|
|
15
|
+
GRAPH_INDEPENDENT_CHECK_OPTIONS = %i[allow_empty_tests logging clear_cache].freeze
|
|
16
|
+
|
|
17
|
+
module_function
|
|
18
|
+
|
|
19
|
+
def extract_graph(
|
|
20
|
+
locator = nil, exclude_patterns: nil, options: nil, working_directory: Dir.pwd
|
|
21
|
+
)
|
|
22
|
+
options = Common::FluentApi::CheckOptions.resolve(options)
|
|
23
|
+
root = Pathname.new(locate_project(locator, working_directory:))
|
|
24
|
+
patterns = resolve_exclude_patterns(exclude_patterns)
|
|
25
|
+
cache_key = graph_cache_key(root, exclude_patterns: patterns, options:)
|
|
26
|
+
|
|
27
|
+
GRAPH_CACHE_MUTEX.synchronize do
|
|
28
|
+
graph_cache.delete(cache_key) if options.clear_cache?
|
|
29
|
+
graph_cache[cache_key] ||= extract_graph_uncached(root, patterns)
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def clear_graph_cache
|
|
34
|
+
GRAPH_CACHE_MUTEX.synchronize { graph_cache.clear }
|
|
35
|
+
nil
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def extract_graph_uncached(root, exclude_patterns)
|
|
39
|
+
source_files = enumerate_source_files(root, exclude_patterns:)
|
|
40
|
+
edges = self_edges(source_files) + extract_dependencies_from(root, source_files)
|
|
41
|
+
|
|
42
|
+
Common::Extraction::Graph.new(merge_parallel_edges(edges))
|
|
43
|
+
end
|
|
44
|
+
private_class_method :extract_graph_uncached
|
|
45
|
+
|
|
46
|
+
def graph_cache_key(project_root, exclude_patterns:, options:)
|
|
47
|
+
[
|
|
48
|
+
project_root.to_s.tr('\\', '/').freeze,
|
|
49
|
+
exclude_patterns.sort.freeze,
|
|
50
|
+
graph_analysis_toggles(options)
|
|
51
|
+
].freeze
|
|
52
|
+
end
|
|
53
|
+
private_class_method :graph_cache_key
|
|
54
|
+
|
|
55
|
+
def graph_analysis_toggles(options)
|
|
56
|
+
options.class.members.filter_map do |member|
|
|
57
|
+
next if GRAPH_INDEPENDENT_CHECK_OPTIONS.include?(member)
|
|
58
|
+
|
|
59
|
+
[member, options.public_send(member)].freeze
|
|
60
|
+
end.freeze
|
|
61
|
+
end
|
|
62
|
+
private_class_method :graph_analysis_toggles
|
|
63
|
+
|
|
64
|
+
def graph_cache
|
|
65
|
+
@graph_cache ||= {}
|
|
66
|
+
end
|
|
67
|
+
private_class_method :graph_cache
|
|
68
|
+
|
|
69
|
+
def self_edges(source_files)
|
|
70
|
+
source_files.map do |source|
|
|
71
|
+
Common::Extraction::Edge.new(source: source, target: source, external: false)
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
private_class_method :self_edges
|
|
75
|
+
|
|
76
|
+
def merge_parallel_edges(edges)
|
|
77
|
+
edges.group_by { |edge| [edge.source, edge.target] }.values.map do |parallel_edges|
|
|
78
|
+
first = parallel_edges.first
|
|
79
|
+
Common::Extraction::Edge.new(
|
|
80
|
+
source: first.source,
|
|
81
|
+
target: first.target,
|
|
82
|
+
external: first.external,
|
|
83
|
+
import_kinds: parallel_edges.flat_map(&:import_kinds)
|
|
84
|
+
)
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
private_class_method :merge_parallel_edges
|
|
88
|
+
end
|
|
89
|
+
end
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'pathname'
|
|
4
|
+
require 'prism'
|
|
5
|
+
require_relative '../error/technical_error'
|
|
6
|
+
require_relative '../error/user_error'
|
|
7
|
+
require_relative 'resolve_import'
|
|
8
|
+
require_relative 'resolved_import'
|
|
9
|
+
|
|
10
|
+
module ArchUnit
|
|
11
|
+
# Parses Ruby source without executing it and resolves literal imports.
|
|
12
|
+
module Extraction
|
|
13
|
+
IGNORE_DIRECTIVE = /\A#\s*archunit:\s*ignore(?:\s+(?<modules>.+?))?\s*\z/
|
|
14
|
+
|
|
15
|
+
# Collects supported dependency calls from a Prism syntax tree.
|
|
16
|
+
class ImportVisitor < Prism::Visitor
|
|
17
|
+
CALL_KINDS = {
|
|
18
|
+
require: Common::Extraction::ImportKind::REQUIRE,
|
|
19
|
+
require_relative: Common::Extraction::ImportKind::REQUIRE_RELATIVE,
|
|
20
|
+
autoload: Common::Extraction::ImportKind::AUTOLOAD,
|
|
21
|
+
load: Common::Extraction::ImportKind::LOAD
|
|
22
|
+
}.freeze
|
|
23
|
+
|
|
24
|
+
attr_reader :imports
|
|
25
|
+
|
|
26
|
+
def initialize
|
|
27
|
+
super
|
|
28
|
+
@imports = []
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def visit_call_node(node)
|
|
32
|
+
record_import(node)
|
|
33
|
+
super
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
def record_import(node)
|
|
39
|
+
import_kind = CALL_KINDS[node.name]
|
|
40
|
+
return unless import_kind && supported_receiver?(node, import_kind)
|
|
41
|
+
|
|
42
|
+
arguments = node.arguments&.arguments || []
|
|
43
|
+
argument = import_argument(arguments, import_kind)
|
|
44
|
+
return unless argument.is_a?(Prism::StringNode)
|
|
45
|
+
return unless valid_module_name?(argument.unescaped)
|
|
46
|
+
|
|
47
|
+
@imports << located_import(argument, import_kind, node)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def located_import(argument, import_kind, node)
|
|
51
|
+
[argument.unescaped, import_kind, node.location.start_line, node.location.end_line]
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def valid_module_name?(module_name)
|
|
55
|
+
!module_name.empty? && !module_name.include?("\0")
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def supported_receiver?(node, import_kind)
|
|
59
|
+
return true if node.receiver.nil?
|
|
60
|
+
if import_kind == Common::Extraction::ImportKind::AUTOLOAD
|
|
61
|
+
return constant_receiver?(node.receiver)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
node.receiver.respond_to?(:full_name) &&
|
|
65
|
+
node.receiver.full_name.delete_prefix('::') == 'Kernel'
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def constant_receiver?(receiver)
|
|
69
|
+
receiver.is_a?(Prism::ConstantReadNode) || receiver.is_a?(Prism::ConstantPathNode)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def import_argument(arguments, import_kind)
|
|
73
|
+
if import_kind == Common::Extraction::ImportKind::AUTOLOAD
|
|
74
|
+
return unless arguments.length == 2
|
|
75
|
+
|
|
76
|
+
arguments[1]
|
|
77
|
+
else
|
|
78
|
+
return unless arguments.length == 1
|
|
79
|
+
|
|
80
|
+
arguments[0]
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
private_constant :ImportVisitor
|
|
85
|
+
|
|
86
|
+
module_function
|
|
87
|
+
|
|
88
|
+
def extract_imports(source_file, project_root:)
|
|
89
|
+
source_path = import_source_path(source_file)
|
|
90
|
+
root = import_project_root(project_root)
|
|
91
|
+
parsed_imports(source_path).map do |located_import|
|
|
92
|
+
resolved_import(located_import, source_path, root)
|
|
93
|
+
end.freeze
|
|
94
|
+
rescue UserError
|
|
95
|
+
raise
|
|
96
|
+
rescue SystemCallError => e
|
|
97
|
+
raise TechnicalError, "could not parse Ruby source file: #{e.message}"
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def parsed_imports(source_path)
|
|
101
|
+
parse_result = Prism.parse_file(source_path.to_s)
|
|
102
|
+
return [] unless parse_result.success?
|
|
103
|
+
|
|
104
|
+
visitor = ImportVisitor.new
|
|
105
|
+
visitor.visit(parse_result.value)
|
|
106
|
+
reject_ignored_imports(visitor.imports, parse_result.comments)
|
|
107
|
+
end
|
|
108
|
+
private_class_method :parsed_imports
|
|
109
|
+
|
|
110
|
+
def reject_ignored_imports(imports, comments)
|
|
111
|
+
directives = ignore_directives(comments)
|
|
112
|
+
imports.reject do |module_name, _import_kind, start_line, end_line|
|
|
113
|
+
directives.any? do |line, trailing, modules|
|
|
114
|
+
directive_applies?(line, trailing, start_line, end_line) &&
|
|
115
|
+
(modules.empty? || modules.any? { |name| ignored_module?(module_name, name) })
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
private_class_method :reject_ignored_imports
|
|
120
|
+
|
|
121
|
+
def ignore_directives(comments)
|
|
122
|
+
comments.filter_map do |comment|
|
|
123
|
+
match = IGNORE_DIRECTIVE.match(comment.location.slice)
|
|
124
|
+
next unless match
|
|
125
|
+
|
|
126
|
+
[comment.location.start_line, comment.trailing?, directive_modules(match[:modules])]
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
private_class_method :ignore_directives
|
|
130
|
+
|
|
131
|
+
def directive_applies?(line, trailing, start_line, end_line)
|
|
132
|
+
return line.between?(start_line, end_line) if trailing
|
|
133
|
+
|
|
134
|
+
start_line == line + 1
|
|
135
|
+
end
|
|
136
|
+
private_class_method :directive_applies?
|
|
137
|
+
|
|
138
|
+
def directive_modules(value)
|
|
139
|
+
return [] if value.nil?
|
|
140
|
+
|
|
141
|
+
value.split(/[\s,]+/).reject(&:empty?)
|
|
142
|
+
end
|
|
143
|
+
private_class_method :directive_modules
|
|
144
|
+
|
|
145
|
+
def ignored_module?(module_name, scoped_name)
|
|
146
|
+
module_name == scoped_name || module_name.start_with?("#{scoped_name}/")
|
|
147
|
+
end
|
|
148
|
+
private_class_method :ignored_module?
|
|
149
|
+
|
|
150
|
+
def resolved_import(located_import, source_path, root)
|
|
151
|
+
module_name, import_kind, line_number, = located_import
|
|
152
|
+
ResolvedImport.new(
|
|
153
|
+
module_name: module_name,
|
|
154
|
+
import_kind: import_kind,
|
|
155
|
+
line_number: line_number,
|
|
156
|
+
resolved_path: resolve_import_for(module_name, import_kind, source_path, root)
|
|
157
|
+
)
|
|
158
|
+
end
|
|
159
|
+
private_class_method :resolved_import
|
|
160
|
+
|
|
161
|
+
def resolve_import_for(module_name, import_kind, source_path, root)
|
|
162
|
+
resolve_import(
|
|
163
|
+
module_name,
|
|
164
|
+
source_file: source_path,
|
|
165
|
+
project_root: root,
|
|
166
|
+
import_kind: import_kind
|
|
167
|
+
)
|
|
168
|
+
end
|
|
169
|
+
private_class_method :resolve_import_for
|
|
170
|
+
|
|
171
|
+
def import_source_path(value)
|
|
172
|
+
path = extraction_path(value, 'source_file')
|
|
173
|
+
raise UserError, 'source_file must be an existing file' unless path.file?
|
|
174
|
+
|
|
175
|
+
path
|
|
176
|
+
end
|
|
177
|
+
private_class_method :import_source_path
|
|
178
|
+
|
|
179
|
+
def import_project_root(value)
|
|
180
|
+
path = extraction_path(value, 'project_root')
|
|
181
|
+
raise UserError, 'project_root must be an existing directory' unless path.directory?
|
|
182
|
+
|
|
183
|
+
path.realpath
|
|
184
|
+
end
|
|
185
|
+
private_class_method :import_project_root
|
|
186
|
+
|
|
187
|
+
def extraction_path(value, attribute)
|
|
188
|
+
value = value.to_path if value.respond_to?(:to_path)
|
|
189
|
+
unless value.is_a?(String) && !value.empty?
|
|
190
|
+
raise UserError, "#{attribute} must be a non-empty path"
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
Pathname.new(value).expand_path
|
|
194
|
+
end
|
|
195
|
+
private_class_method :extraction_path
|
|
196
|
+
end
|
|
197
|
+
end
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'pathname'
|
|
4
|
+
require_relative '../error/technical_error'
|
|
5
|
+
require_relative '../error/user_error'
|
|
6
|
+
|
|
7
|
+
module ArchUnit
|
|
8
|
+
# Ruby-specific project discovery and source extraction.
|
|
9
|
+
module Extraction
|
|
10
|
+
module_function
|
|
11
|
+
|
|
12
|
+
def locate_project(locator = nil, working_directory: Dir.pwd)
|
|
13
|
+
working_root = existing_directory(working_directory, :working_directory)
|
|
14
|
+
root = project_root(locator, working_root)
|
|
15
|
+
normalize_path(root.realpath)
|
|
16
|
+
rescue UserError, TechnicalError
|
|
17
|
+
raise
|
|
18
|
+
rescue SystemCallError => e
|
|
19
|
+
raise TechnicalError, "could not locate project: #{e.message}"
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def project_root(locator, working_root)
|
|
23
|
+
return detected_project_root(working_root) || working_root if locator.nil?
|
|
24
|
+
|
|
25
|
+
explicit_project_root(locator, working_root)
|
|
26
|
+
end
|
|
27
|
+
private_class_method :project_root
|
|
28
|
+
|
|
29
|
+
def explicit_project_root(locator, working_root)
|
|
30
|
+
path = pathname(locator, :locator)
|
|
31
|
+
path = working_root.join(path) unless path.absolute?
|
|
32
|
+
|
|
33
|
+
return path.realpath if path.directory?
|
|
34
|
+
return path.dirname.realpath if path.file? && project_marker_file?(path)
|
|
35
|
+
|
|
36
|
+
raise UserError, 'project locator must be a directory, Gemfile, or .gemspec file'
|
|
37
|
+
end
|
|
38
|
+
private_class_method :explicit_project_root
|
|
39
|
+
|
|
40
|
+
def detected_project_root(working_root)
|
|
41
|
+
working_root.ascend.find { |directory| project_marker?(directory) }
|
|
42
|
+
end
|
|
43
|
+
private_class_method :detected_project_root
|
|
44
|
+
|
|
45
|
+
def project_marker?(directory)
|
|
46
|
+
directory.join('Gemfile').file? || directory.children.any? do |entry|
|
|
47
|
+
entry.file? && entry.extname == '.gemspec'
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
private_class_method :project_marker?
|
|
51
|
+
|
|
52
|
+
def project_marker_file?(path)
|
|
53
|
+
path.basename.to_s == 'Gemfile' || path.extname == '.gemspec'
|
|
54
|
+
end
|
|
55
|
+
private_class_method :project_marker_file?
|
|
56
|
+
|
|
57
|
+
def existing_directory(value, attribute)
|
|
58
|
+
path = pathname(value, attribute).expand_path
|
|
59
|
+
raise UserError, "#{attribute} must be an existing directory" unless path.directory?
|
|
60
|
+
|
|
61
|
+
path.realpath
|
|
62
|
+
end
|
|
63
|
+
private_class_method :existing_directory
|
|
64
|
+
|
|
65
|
+
def pathname(value, attribute)
|
|
66
|
+
value = value.to_path if value.respond_to?(:to_path)
|
|
67
|
+
unless value.is_a?(String) && !value.empty?
|
|
68
|
+
raise UserError, "#{attribute} must be a non-empty path"
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
Pathname.new(value)
|
|
72
|
+
end
|
|
73
|
+
private_class_method :pathname
|
|
74
|
+
|
|
75
|
+
def normalize_path(path)
|
|
76
|
+
path.to_s.tr('\\', '/')
|
|
77
|
+
end
|
|
78
|
+
private_class_method :normalize_path
|
|
79
|
+
end
|
|
80
|
+
end
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'pathname'
|
|
4
|
+
require_relative '../common/extraction/import_kind'
|
|
5
|
+
|
|
6
|
+
module ArchUnit
|
|
7
|
+
# Resolves Ruby dependency names using Ruby's own feature resolver.
|
|
8
|
+
module Extraction
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
def resolve_import(module_name, source_file:, project_root:, import_kind:)
|
|
12
|
+
roots = import_search_roots(source_file, project_root, import_kind)
|
|
13
|
+
resolved = if import_kind == Common::Extraction::ImportKind::LOAD
|
|
14
|
+
resolve_load(module_name, roots, import_kind)
|
|
15
|
+
else
|
|
16
|
+
resolve_feature(module_name, roots, import_kind)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
resolved&.tr('\\', '/')&.freeze
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def import_search_roots(source_file, project_root, import_kind)
|
|
23
|
+
if import_kind == Common::Extraction::ImportKind::REQUIRE_RELATIVE
|
|
24
|
+
[File.dirname(File.expand_path(source_file))]
|
|
25
|
+
else
|
|
26
|
+
root = File.expand_path(project_root)
|
|
27
|
+
[File.join(root, 'lib'), root]
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
private_class_method :import_search_roots
|
|
31
|
+
|
|
32
|
+
def resolve_feature(module_name, roots, import_kind)
|
|
33
|
+
feature_candidates(module_name, roots).each do |candidate|
|
|
34
|
+
resolution = $LOAD_PATH.resolve_feature_path(candidate)
|
|
35
|
+
return resolution.last if resolution
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
return if import_kind == Common::Extraction::ImportKind::REQUIRE_RELATIVE
|
|
39
|
+
|
|
40
|
+
$LOAD_PATH.resolve_feature_path(module_name)&.last
|
|
41
|
+
end
|
|
42
|
+
private_class_method :resolve_feature
|
|
43
|
+
|
|
44
|
+
def resolve_load(module_name, roots, import_kind)
|
|
45
|
+
load_candidates(module_name, roots).find { |candidate| File.file?(candidate) } ||
|
|
46
|
+
resolve_load_from_global_path(module_name, import_kind)
|
|
47
|
+
end
|
|
48
|
+
private_class_method :resolve_load
|
|
49
|
+
|
|
50
|
+
def resolve_load_from_global_path(module_name, import_kind)
|
|
51
|
+
return if import_kind == Common::Extraction::ImportKind::REQUIRE_RELATIVE
|
|
52
|
+
return if Pathname.new(module_name).absolute?
|
|
53
|
+
|
|
54
|
+
$LOAD_PATH.lazy
|
|
55
|
+
.map { |directory| File.expand_path(module_name, directory) }
|
|
56
|
+
.find { |candidate| File.file?(candidate) }
|
|
57
|
+
end
|
|
58
|
+
private_class_method :resolve_load_from_global_path
|
|
59
|
+
|
|
60
|
+
def feature_candidates(module_name, roots)
|
|
61
|
+
path = Pathname.new(module_name)
|
|
62
|
+
return [path.expand_path.to_s] if path.absolute?
|
|
63
|
+
|
|
64
|
+
roots.map { |root| File.expand_path(module_name, root) }.uniq
|
|
65
|
+
end
|
|
66
|
+
private_class_method :feature_candidates
|
|
67
|
+
|
|
68
|
+
def load_candidates(module_name, roots)
|
|
69
|
+
path = Pathname.new(module_name)
|
|
70
|
+
return [path.expand_path.to_s] if path.absolute?
|
|
71
|
+
|
|
72
|
+
roots.map { |root| File.expand_path(module_name, root) }.uniq
|
|
73
|
+
end
|
|
74
|
+
private_class_method :load_candidates
|
|
75
|
+
end
|
|
76
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative '../common/extraction/import_kind'
|
|
4
|
+
|
|
5
|
+
module ArchUnit
|
|
6
|
+
module Extraction
|
|
7
|
+
ResolvedImport = Data.define(:module_name, :import_kind, :line_number, :resolved_path) do
|
|
8
|
+
def initialize(module_name:, import_kind:, line_number:, resolved_path: nil)
|
|
9
|
+
validate_module_name(module_name)
|
|
10
|
+
validate_import_kind(import_kind)
|
|
11
|
+
validate_line_number(line_number)
|
|
12
|
+
validate_resolved_path(resolved_path)
|
|
13
|
+
|
|
14
|
+
module_name = module_name.dup.freeze
|
|
15
|
+
resolved_path = resolved_path&.tr('\\', '/')&.freeze
|
|
16
|
+
super
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
private
|
|
20
|
+
|
|
21
|
+
def validate_module_name(value)
|
|
22
|
+
return if value.is_a?(String) && !value.empty?
|
|
23
|
+
|
|
24
|
+
raise ArgumentError, 'module_name must be a non-empty String'
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def validate_import_kind(value)
|
|
28
|
+
return if Common::Extraction::ImportKind.valid?(value)
|
|
29
|
+
|
|
30
|
+
raise ArgumentError, "unknown import kind: #{value.inspect}"
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def validate_line_number(value)
|
|
34
|
+
return if value.is_a?(Integer) && value.positive?
|
|
35
|
+
|
|
36
|
+
raise ArgumentError, 'line_number must be a positive Integer'
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def validate_resolved_path(value)
|
|
40
|
+
return if value.nil? || (value.is_a?(String) && !value.empty?)
|
|
41
|
+
|
|
42
|
+
raise ArgumentError, 'resolved_path must be nil or a non-empty String'
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative '../../common/assertion/violation'
|
|
4
|
+
require_relative '../extraction/file_info'
|
|
5
|
+
|
|
6
|
+
module ArchUnit
|
|
7
|
+
module Files
|
|
8
|
+
# Pure assertions over user-defined source-file predicates.
|
|
9
|
+
module Assertion
|
|
10
|
+
# Data describing a file that disagrees with a custom predicate.
|
|
11
|
+
class CustomFileViolation < Common::Assertion::Violation
|
|
12
|
+
attr_reader :file_info, :message, :is_negated
|
|
13
|
+
|
|
14
|
+
def initialize(file_info:, message:, is_negated: false)
|
|
15
|
+
@file_info = file_info_value(file_info)
|
|
16
|
+
@message = message_value(message)
|
|
17
|
+
@is_negated = boolean_value(is_negated)
|
|
18
|
+
super()
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def negated?
|
|
22
|
+
is_negated
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def ==(other)
|
|
26
|
+
other.is_a?(self.class) &&
|
|
27
|
+
file_info == other.file_info &&
|
|
28
|
+
message == other.message &&
|
|
29
|
+
is_negated == other.is_negated
|
|
30
|
+
end
|
|
31
|
+
alias eql? ==
|
|
32
|
+
|
|
33
|
+
def hash
|
|
34
|
+
[self.class, file_info, message, is_negated].hash
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
def file_info_value(value)
|
|
40
|
+
return value if value.is_a?(Files::Extraction::FileInfo)
|
|
41
|
+
|
|
42
|
+
raise ArgumentError, 'file_info must be a FileInfo'
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def message_value(value)
|
|
46
|
+
return value.dup.freeze if value.is_a?(String) && !value.strip.empty?
|
|
47
|
+
|
|
48
|
+
raise ArgumentError, 'message must be a non-empty String'
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def boolean_value(value)
|
|
52
|
+
return value if [true, false].include?(value)
|
|
53
|
+
|
|
54
|
+
raise ArgumentError, 'is_negated must be true or false'
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
module_function
|
|
59
|
+
|
|
60
|
+
def gather_custom_file_violations(file_infos, condition, message, is_negated:)
|
|
61
|
+
validate_custom_arguments(file_infos, condition, message, is_negated)
|
|
62
|
+
|
|
63
|
+
file_infos.filter_map do |file_info|
|
|
64
|
+
result = condition.call(file_info)
|
|
65
|
+
unless [true, false].include?(result)
|
|
66
|
+
raise ArgumentError, 'condition must return true or false'
|
|
67
|
+
end
|
|
68
|
+
next unless is_negated ? result : !result
|
|
69
|
+
|
|
70
|
+
CustomFileViolation.new(file_info:, message:, is_negated:)
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def validate_custom_arguments(file_infos, condition, message, is_negated)
|
|
75
|
+
unless file_infos.is_a?(Array) && file_infos.all?(Files::Extraction::FileInfo)
|
|
76
|
+
raise ArgumentError, 'file_infos must be an Array of FileInfo values'
|
|
77
|
+
end
|
|
78
|
+
raise ArgumentError, 'condition must be callable' unless condition.respond_to?(:call)
|
|
79
|
+
unless message.is_a?(String) && !message.strip.empty?
|
|
80
|
+
raise ArgumentError, 'message must be a non-empty String'
|
|
81
|
+
end
|
|
82
|
+
return if [true, false].include?(is_negated)
|
|
83
|
+
|
|
84
|
+
raise ArgumentError, 'is_negated must be true or false'
|
|
85
|
+
end
|
|
86
|
+
private_class_method :validate_custom_arguments
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative '../../common/assertion/violation'
|
|
4
|
+
require_relative '../../common/projection/projected_edge'
|
|
5
|
+
|
|
6
|
+
module ArchUnit
|
|
7
|
+
module Files
|
|
8
|
+
# Pure file-rule assertion functions and violation values.
|
|
9
|
+
module Assertion
|
|
10
|
+
# Data describing one closed circular dependency path.
|
|
11
|
+
class CycleViolation < Common::Assertion::Violation
|
|
12
|
+
attr_reader :cycle, :path
|
|
13
|
+
|
|
14
|
+
def initialize(cycle:)
|
|
15
|
+
@cycle = immutable_cycle(cycle)
|
|
16
|
+
@path = [cycle.first.source_label, *cycle.map(&:target_label)].freeze
|
|
17
|
+
super()
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def ==(other)
|
|
21
|
+
other.is_a?(self.class) && cycle == other.cycle
|
|
22
|
+
end
|
|
23
|
+
alias eql? ==
|
|
24
|
+
|
|
25
|
+
def hash
|
|
26
|
+
[self.class, cycle].hash
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
def immutable_cycle(values)
|
|
32
|
+
unless values.is_a?(Array) && !values.empty? && values.all?(Common::Projection::ProjectedEdge)
|
|
33
|
+
raise ArgumentError, 'cycle must be a non-empty Array of ProjectedEdge values'
|
|
34
|
+
end
|
|
35
|
+
unless closed_cycle?(values)
|
|
36
|
+
raise ArgumentError, 'cycle edges must form a contiguous closed path'
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
values.dup.freeze
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def closed_cycle?(edges)
|
|
43
|
+
edges.each_cons(2).all? do |current, following|
|
|
44
|
+
current.target_label == following.source_label
|
|
45
|
+
end && edges.last.target_label == edges.first.source_label
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
module_function
|
|
50
|
+
|
|
51
|
+
def gather_cycle_violations(cycles)
|
|
52
|
+
raise ArgumentError, 'cycles must be an Array' unless cycles.is_a?(Array)
|
|
53
|
+
|
|
54
|
+
cycles.map { |cycle| CycleViolation.new(cycle:) }
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|