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,100 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ArchUnit
|
|
4
|
+
module Extraction
|
|
5
|
+
# Keeps local-string inference inside straight-line code and lexical scopes.
|
|
6
|
+
module ConservativeLocalFlow
|
|
7
|
+
def visit_def_node(node)
|
|
8
|
+
@local_strings.isolated_scope { super }
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def visit_class_node(node)
|
|
12
|
+
@local_strings.isolated_scope { super }
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def visit_module_node(node)
|
|
16
|
+
@local_strings.isolated_scope { super }
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def visit_singleton_class_node(node)
|
|
20
|
+
@local_strings.isolated_scope { super }
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def visit_block_node(node)
|
|
24
|
+
@local_strings.isolated_scope { super }
|
|
25
|
+
@local_strings.capture_scopes
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def visit_lambda_node(node)
|
|
29
|
+
@local_strings.isolated_scope { super }
|
|
30
|
+
@local_strings.capture_scopes
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def visit_local_variable_operator_write_node(node)
|
|
34
|
+
super
|
|
35
|
+
@local_strings.assign(node.name, nil)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def visit_local_variable_or_write_node(node)
|
|
39
|
+
super
|
|
40
|
+
@local_strings.assign(node.name, nil)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def visit_local_variable_and_write_node(node)
|
|
44
|
+
super
|
|
45
|
+
@local_strings.assign(node.name, nil)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def visit_multi_write_node(node)
|
|
49
|
+
super
|
|
50
|
+
@local_strings.invalidate_all
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def visit_post_execution_node(node)
|
|
54
|
+
@local_strings.uncertain_flow { super }
|
|
55
|
+
@local_strings.capture_scopes
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def visit_match_write_node(node)
|
|
59
|
+
super
|
|
60
|
+
@local_strings.invalidate_all
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def visit_instance_variable_write_node(node)
|
|
64
|
+
super
|
|
65
|
+
@local_strings.invalidate_escaped(node.value)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def visit_global_variable_write_node(node)
|
|
69
|
+
super
|
|
70
|
+
@local_strings.invalidate_escaped(node.value)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def visit_class_variable_write_node(node)
|
|
74
|
+
super
|
|
75
|
+
@local_strings.invalidate_escaped(node.value)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def visit_constant_write_node(node)
|
|
79
|
+
super
|
|
80
|
+
@local_strings.invalidate_escaped(node.value)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def visit_constant_path_write_node(node)
|
|
84
|
+
super
|
|
85
|
+
@local_strings.invalidate_escaped(node.value)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# These constructs require control-flow analysis beyond the supported straight-line subset.
|
|
89
|
+
%i[
|
|
90
|
+
begin defined pre_execution if unless and or case case_match while until for
|
|
91
|
+
rescue rescue_modifier
|
|
92
|
+
].each do |kind|
|
|
93
|
+
define_method("visit_#{kind}_node") do |node|
|
|
94
|
+
@local_strings.uncertain_flow { super(node) }
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
private_constant :ConservativeLocalFlow
|
|
99
|
+
end
|
|
100
|
+
end
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative '../common/extraction/edge'
|
|
4
|
+
require_relative 'constant_visitor'
|
|
5
|
+
|
|
6
|
+
module ArchUnit
|
|
7
|
+
module Extraction
|
|
8
|
+
# Bounded project definition index. Unknown/ambiguous names never invent file targets.
|
|
9
|
+
class ConstantIndex
|
|
10
|
+
Location = Data.define(:source, :line_number)
|
|
11
|
+
MAX_NAMESPACE_PASSES = 8
|
|
12
|
+
|
|
13
|
+
def initialize(analyses, profile: nil)
|
|
14
|
+
@analyses = analyses
|
|
15
|
+
@profile = profile
|
|
16
|
+
@visitors = stabilized_visitors
|
|
17
|
+
@definitions = definition_locations
|
|
18
|
+
@namespaces = namespace_names(@visitors)
|
|
19
|
+
@inherited_namespaces = @visitors.values.flat_map(&:inherited_namespaces).to_set
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def edges
|
|
23
|
+
@visitors.flat_map do |source, visitor|
|
|
24
|
+
visitor.references.filter_map { |reference| resolve_edge(source, reference) }
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
def stabilized_visitors
|
|
31
|
+
visitors = collect
|
|
32
|
+
MAX_NAMESPACE_PASSES.times do
|
|
33
|
+
names = namespace_names(visitors)
|
|
34
|
+
visitors = collect(namespace_names: names)
|
|
35
|
+
return visitors if namespace_names(visitors) == names
|
|
36
|
+
end
|
|
37
|
+
unresolved_count = visitors.values.sum { |visitor| visitor.references.length }
|
|
38
|
+
@profile&.increment(:unresolved_constant_references, unresolved_count)
|
|
39
|
+
{}
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def collect(namespace_names: nil)
|
|
43
|
+
@analyses.to_h do |source, result|
|
|
44
|
+
visitor = ConstantVisitor.new(namespace_names:)
|
|
45
|
+
visitor.visit(result.value)
|
|
46
|
+
[source, visitor]
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def namespace_names(visitors)
|
|
51
|
+
visitors.values.flat_map(&:definitions).each_with_object(Set.new) do |definition, names|
|
|
52
|
+
parts = definition.name.split('::')
|
|
53
|
+
parts.length.times { |index| names << parts.take(index + 1).join('::') }
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def definition_locations
|
|
58
|
+
map = Hash.new { |hash, key| hash[key] = [] }
|
|
59
|
+
@visitors.each do |source, visitor|
|
|
60
|
+
visitor.definitions.each do |definition|
|
|
61
|
+
map[definition.name] << Location.new(source:, line_number: definition.line_number)
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
map
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def resolve_edge(source, reference)
|
|
68
|
+
name = resolve_name(reference)
|
|
69
|
+
locations = name ? @definitions.fetch(name, []) : []
|
|
70
|
+
return unresolved(:unresolved_constant_references) if locations.empty?
|
|
71
|
+
return unresolved(:ambiguous_constant_references) if locations.map(&:source).uniq.length > 1
|
|
72
|
+
|
|
73
|
+
location = locations.first
|
|
74
|
+
return if source == location.source
|
|
75
|
+
|
|
76
|
+
evidence_edge(source, reference, location)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def resolve_name(reference)
|
|
80
|
+
name = reference.name
|
|
81
|
+
return name.delete_prefix('::') if name.start_with?('::')
|
|
82
|
+
|
|
83
|
+
head = name.split('::').first
|
|
84
|
+
owner = reference.nesting.find { |scope| @namespaces.include?("#{scope}::#{head}") }
|
|
85
|
+
return "#{owner}::#{name}" if owner
|
|
86
|
+
return if @inherited_namespaces.include?(reference.inherited_scope)
|
|
87
|
+
|
|
88
|
+
name
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def unresolved(counter)
|
|
92
|
+
@profile&.increment(counter)
|
|
93
|
+
nil
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def evidence_edge(source, reference, location)
|
|
97
|
+
kind = Common::Extraction::ImportKind::CONSTANT_REFERENCE
|
|
98
|
+
evidence = Common::Extraction::DependencyEvidence.new(
|
|
99
|
+
source:, kind:, name: reference.name, line_number: reference.line_number,
|
|
100
|
+
end_line: reference.end_line, target_line_number: location.line_number
|
|
101
|
+
)
|
|
102
|
+
Common::Extraction::Edge.new(
|
|
103
|
+
source:, target: location.source, external: false,
|
|
104
|
+
import_kinds: [kind], evidence: [evidence]
|
|
105
|
+
)
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
private_constant :ConstantIndex
|
|
109
|
+
end
|
|
110
|
+
end
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'prism'
|
|
4
|
+
|
|
5
|
+
module ArchUnit
|
|
6
|
+
module Extraction
|
|
7
|
+
# Collects declarations and uses with Ruby's lexical (not textual path) nesting.
|
|
8
|
+
class ConstantVisitor < Prism::Visitor
|
|
9
|
+
Definition = Data.define(:name, :line_number)
|
|
10
|
+
Reference = Data.define(:name, :nesting, :inherited_scope, :line_number, :end_line)
|
|
11
|
+
|
|
12
|
+
attr_reader :definitions, :references, :inherited_namespaces
|
|
13
|
+
|
|
14
|
+
def initialize(namespace_names: nil)
|
|
15
|
+
super()
|
|
16
|
+
@namespace_names = namespace_names
|
|
17
|
+
@definitions = []
|
|
18
|
+
@references = []
|
|
19
|
+
@nesting = []
|
|
20
|
+
@inherited_namespaces = []
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def visit_class_node(node)
|
|
24
|
+
visit(node.superclass)
|
|
25
|
+
visit_namespace(node, inherited: !node.superclass.nil?)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def visit_module_node(node)
|
|
29
|
+
visit_namespace(node, inherited: false)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def visit_constant_write_node(node)
|
|
33
|
+
define_constant(qualify(node.name.to_s), node)
|
|
34
|
+
visit(node.value)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def visit_constant_path_write_node(node)
|
|
38
|
+
name = static_name(node.target)
|
|
39
|
+
define_constant(declaration_name(name), node) if name
|
|
40
|
+
visit(node.value)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# ||= introduces a possible declaration, like a conditional ordinary assignment.
|
|
44
|
+
# This records ownership, not whether the branch executes or what value it stores.
|
|
45
|
+
alias visit_constant_or_write_node visit_constant_write_node
|
|
46
|
+
alias visit_constant_path_or_write_node visit_constant_path_write_node
|
|
47
|
+
|
|
48
|
+
def visit_constant_read_node(node)
|
|
49
|
+
record_reference(node.name.to_s, node)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def visit_constant_path_node(node)
|
|
53
|
+
name = static_name(node)
|
|
54
|
+
name ? record_reference(name, node) : super
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def visit_call_node(node)
|
|
58
|
+
if %i[include prepend extend const_set remove_const autoload].include?(node.name) &&
|
|
59
|
+
!@nesting.empty?
|
|
60
|
+
@inherited_namespaces << @nesting.first
|
|
61
|
+
end
|
|
62
|
+
super
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def visit_singleton_class_node(node)
|
|
66
|
+
visit(node.expression)
|
|
67
|
+
previous = @dynamic_scope
|
|
68
|
+
@dynamic_scope = true
|
|
69
|
+
visit(node.body)
|
|
70
|
+
ensure
|
|
71
|
+
@dynamic_scope = previous
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
private
|
|
75
|
+
|
|
76
|
+
def visit_namespace(node, inherited:)
|
|
77
|
+
name = static_name(node.constant_path)
|
|
78
|
+
return unless name
|
|
79
|
+
|
|
80
|
+
full_name = declaration_name(name)
|
|
81
|
+
define_constant(full_name, node.constant_path)
|
|
82
|
+
@inherited_namespaces << full_name if inherited
|
|
83
|
+
previous = @nesting
|
|
84
|
+
@nesting = [full_name, *previous]
|
|
85
|
+
visit(node.body)
|
|
86
|
+
ensure
|
|
87
|
+
@nesting = previous if previous
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def declaration_name(name)
|
|
91
|
+
return name.delete_prefix('::') if name.start_with?('::') || @nesting.empty?
|
|
92
|
+
return qualify(name) unless name.include?('::')
|
|
93
|
+
|
|
94
|
+
head = name.split('::').first
|
|
95
|
+
owner = @nesting.find { |scope| @namespace_names&.include?("#{scope}::#{head}") }
|
|
96
|
+
owner ? "#{owner}::#{name}" : name
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def qualify(name)
|
|
100
|
+
@nesting.empty? ? name : "#{@nesting.first}::#{name}"
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def define_constant(name, node)
|
|
104
|
+
return if @dynamic_scope
|
|
105
|
+
|
|
106
|
+
@definitions << Definition.new(name:, line_number: node.location.start_line)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def record_reference(name, node)
|
|
110
|
+
return if @dynamic_scope && !name.start_with?('::')
|
|
111
|
+
|
|
112
|
+
@references << Reference.new(
|
|
113
|
+
name:, nesting: @nesting.dup.freeze, inherited_scope: @nesting.first,
|
|
114
|
+
line_number: node.location.start_line, end_line: node.location.end_line
|
|
115
|
+
)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def static_name(node)
|
|
119
|
+
return node.name.to_s if node.is_a?(Prism::ConstantReadNode)
|
|
120
|
+
return unless node.is_a?(Prism::ConstantPathNode)
|
|
121
|
+
return "::#{node.name}" if node.parent.nil?
|
|
122
|
+
|
|
123
|
+
parent = static_name(node.parent)
|
|
124
|
+
"#{parent}::#{node.name}" if parent
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
private_constant :ConstantVisitor
|
|
128
|
+
end
|
|
129
|
+
end
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'find'
|
|
4
|
+
require 'pathname'
|
|
5
|
+
require_relative 'enumerate_source_files'
|
|
6
|
+
|
|
7
|
+
module ArchUnit
|
|
8
|
+
module Extraction
|
|
9
|
+
# Discovers gem-adjacent lib directories without evaluating gemspecs.
|
|
10
|
+
module GemspecLoadPaths
|
|
11
|
+
module_function
|
|
12
|
+
|
|
13
|
+
def resolve(root, source_files: nil)
|
|
14
|
+
paths = if source_files
|
|
15
|
+
discover_from_sources(root, source_files)
|
|
16
|
+
else
|
|
17
|
+
discover_with_walk(root)
|
|
18
|
+
end
|
|
19
|
+
paths.uniq.sort_by { |path| normalized(path) }
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def discover_from_sources(root, source_files)
|
|
23
|
+
roots = source_files.flat_map { |source| source_lib_roots(root, source) }.uniq
|
|
24
|
+
roots.flat_map do |gem_root|
|
|
25
|
+
gemspecs_in(gem_root).filter_map { |entry| gemspec_lib_path(entry, root) }
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
private_class_method :discover_from_sources
|
|
29
|
+
|
|
30
|
+
def source_lib_roots(root, source)
|
|
31
|
+
segments = source.split('/')
|
|
32
|
+
segments.each_index.filter_map do |index|
|
|
33
|
+
next unless segments[index] == 'lib'
|
|
34
|
+
|
|
35
|
+
index.zero? ? root : root.join(*segments.take(index))
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
private_class_method :source_lib_roots
|
|
39
|
+
|
|
40
|
+
def gemspecs_in(directory)
|
|
41
|
+
directory.children.select do |entry|
|
|
42
|
+
entry.file? && entry.extname == '.gemspec'
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
private_class_method :gemspecs_in
|
|
46
|
+
|
|
47
|
+
def discover_with_walk(root)
|
|
48
|
+
paths = []
|
|
49
|
+
Find.find(root.to_s) do |entry|
|
|
50
|
+
next if directory_entry?(entry, root)
|
|
51
|
+
|
|
52
|
+
lib = gemspec_lib_path(Pathname.new(entry), root)
|
|
53
|
+
paths << lib if lib
|
|
54
|
+
end
|
|
55
|
+
paths
|
|
56
|
+
end
|
|
57
|
+
private_class_method :discover_with_walk
|
|
58
|
+
|
|
59
|
+
def directory_entry?(entry, root)
|
|
60
|
+
return false unless File.directory?(entry)
|
|
61
|
+
|
|
62
|
+
Find.prune if entry != root.to_s && excluded_directory?(entry)
|
|
63
|
+
true
|
|
64
|
+
end
|
|
65
|
+
private_class_method :directory_entry?
|
|
66
|
+
|
|
67
|
+
def gemspec_lib_path(entry, root)
|
|
68
|
+
return unless entry.extname == '.gemspec'
|
|
69
|
+
|
|
70
|
+
lib = entry.dirname.join('lib')
|
|
71
|
+
return unless lib.directory?
|
|
72
|
+
|
|
73
|
+
canonical = lib.realpath
|
|
74
|
+
canonical if within_project?(canonical, root)
|
|
75
|
+
end
|
|
76
|
+
private_class_method :gemspec_lib_path
|
|
77
|
+
|
|
78
|
+
def excluded_directory?(path)
|
|
79
|
+
Extraction::DEFAULT_EXCLUDED_DIRECTORIES.include?(File.basename(path))
|
|
80
|
+
end
|
|
81
|
+
private_class_method :excluded_directory?
|
|
82
|
+
|
|
83
|
+
def within_project?(path, root)
|
|
84
|
+
path.relative_path_from(root).each_filename.first != '..'
|
|
85
|
+
rescue ArgumentError
|
|
86
|
+
false
|
|
87
|
+
end
|
|
88
|
+
private_class_method :within_project?
|
|
89
|
+
|
|
90
|
+
def normalized(path)
|
|
91
|
+
path.to_s.tr('\\', '/')
|
|
92
|
+
end
|
|
93
|
+
private_class_method :normalized
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
@@ -4,9 +4,11 @@ require 'find'
|
|
|
4
4
|
require 'pathname'
|
|
5
5
|
require_relative '../error/technical_error'
|
|
6
6
|
require_relative '../error/user_error'
|
|
7
|
+
require_relative 'arch_ignore'
|
|
7
8
|
|
|
8
9
|
module ArchUnit
|
|
9
10
|
# Ruby-specific project discovery and source extraction.
|
|
11
|
+
# rubocop:disable-next Metrics/ModuleLength -- One source policy keeps graphs and metrics aligned.
|
|
10
12
|
module Extraction
|
|
11
13
|
DEFAULT_EXCLUDED_DIRECTORIES = %w[
|
|
12
14
|
.bundle
|
|
@@ -30,22 +32,33 @@ module ArchUnit
|
|
|
30
32
|
|
|
31
33
|
def enumerate_source_files(project_root, exclude_patterns: nil)
|
|
32
34
|
root = source_root(project_root)
|
|
33
|
-
patterns =
|
|
35
|
+
patterns = resolve_project_exclude_patterns(root, exclude_patterns)
|
|
36
|
+
enumerate_source_files_with_patterns(root, patterns)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def enumerate_source_files_with_patterns(root, patterns)
|
|
34
40
|
source_files_under(root, patterns).sort.freeze
|
|
35
41
|
rescue UserError, TechnicalError
|
|
36
42
|
raise
|
|
37
43
|
rescue SystemCallError => e
|
|
38
44
|
raise TechnicalError, "could not enumerate Ruby source files: #{e.message}"
|
|
39
45
|
end
|
|
46
|
+
private_class_method :enumerate_source_files_with_patterns
|
|
47
|
+
|
|
48
|
+
def resolve_project_exclude_patterns(root, value)
|
|
49
|
+
configured = resolve_exclude_patterns(value)
|
|
50
|
+
resolve_exclude_patterns([*configured, *ArchIgnore.patterns(root)])
|
|
51
|
+
end
|
|
52
|
+
private_class_method :resolve_project_exclude_patterns
|
|
40
53
|
|
|
41
54
|
def source_files_under(root, exclude_patterns)
|
|
42
55
|
Find.find(root.to_s).filter_map do |entry|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
next
|
|
46
|
-
end
|
|
56
|
+
stat = File.lstat(entry)
|
|
57
|
+
next if stat.symlink?
|
|
47
58
|
|
|
48
|
-
if
|
|
59
|
+
if stat.directory?
|
|
60
|
+
Find.prune if excluded_path?(entry, root, exclude_patterns)
|
|
61
|
+
elsif ruby_source_file?(entry, stat) && !excluded_path?(entry, root, exclude_patterns)
|
|
49
62
|
relative_identifier(entry, root).freeze
|
|
50
63
|
end
|
|
51
64
|
end
|
|
@@ -85,8 +98,8 @@ module ArchUnit
|
|
|
85
98
|
end
|
|
86
99
|
private_class_method :normalize_exclude_pattern
|
|
87
100
|
|
|
88
|
-
def ruby_source_file?(entry)
|
|
89
|
-
|
|
101
|
+
def ruby_source_file?(entry, stat)
|
|
102
|
+
stat.file? && File.extname(entry) == '.rb'
|
|
90
103
|
end
|
|
91
104
|
private_class_method :ruby_source_file?
|
|
92
105
|
|
|
@@ -100,6 +113,8 @@ module ArchUnit
|
|
|
100
113
|
raise UserError, 'project_root must be an existing directory' unless root.directory?
|
|
101
114
|
|
|
102
115
|
root.realpath
|
|
116
|
+
rescue SystemCallError => e
|
|
117
|
+
raise TechnicalError, "could not enumerate Ruby source files: #{e.message}"
|
|
103
118
|
end
|
|
104
119
|
private_class_method :source_root
|
|
105
120
|
|
|
@@ -2,31 +2,91 @@
|
|
|
2
2
|
|
|
3
3
|
require 'pathname'
|
|
4
4
|
require_relative '../common/extraction/edge'
|
|
5
|
+
require_relative '../common/fluentapi/check_options'
|
|
5
6
|
require_relative 'enumerate_source_files'
|
|
6
7
|
require_relative 'extract_imports'
|
|
8
|
+
require_relative 'resolve_load_paths'
|
|
9
|
+
require_relative 'constant_index'
|
|
7
10
|
|
|
8
11
|
module ArchUnit
|
|
9
12
|
# Turns statically resolvable Ruby imports into dependency edges.
|
|
10
13
|
module Extraction
|
|
14
|
+
DependencyContext = Data.define(
|
|
15
|
+
:root, :targets, :load_paths, :profile, :cache, :include_evidence
|
|
16
|
+
)
|
|
17
|
+
private_constant :DependencyContext
|
|
18
|
+
|
|
11
19
|
module_function
|
|
12
20
|
|
|
13
|
-
def extract_dependencies(project_root, exclude_patterns: nil)
|
|
21
|
+
def extract_dependencies(project_root, exclude_patterns: nil, options: nil)
|
|
22
|
+
options = Common::FluentApi::CheckOptions.resolve(options)
|
|
14
23
|
source_files = enumerate_source_files(project_root, exclude_patterns:)
|
|
15
24
|
root = Pathname.new(path_value(project_root)).expand_path.realpath
|
|
16
|
-
|
|
25
|
+
load_paths = LoadPaths.resolve(
|
|
26
|
+
root, explicit_paths: options.load_paths, source_files:
|
|
27
|
+
)
|
|
28
|
+
extract_dependencies_from(root, source_files, load_paths:, options:)
|
|
17
29
|
end
|
|
18
30
|
|
|
19
|
-
def extract_dependencies_from(root, source_files)
|
|
20
|
-
|
|
31
|
+
def extract_dependencies_from(root, source_files, load_paths:, options:, profile: nil)
|
|
32
|
+
context = DependencyContext.new(
|
|
33
|
+
root:, targets: profiled_target_index(root, source_files, profile), load_paths:,
|
|
34
|
+
profile:, cache: {}, include_evidence: options.constant_references?
|
|
35
|
+
)
|
|
21
36
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
37
|
+
analyses = options.constant_references? ? parse_sources(root, source_files, profile) : {}
|
|
38
|
+
edges = source_files.flat_map do |source|
|
|
39
|
+
dependencies_for_source(source, context, analyses[source])
|
|
40
|
+
end
|
|
41
|
+
edges.concat(constant_edges(analyses, profile)) if options.constant_references?
|
|
42
|
+
edges.freeze
|
|
27
43
|
end
|
|
28
44
|
private_class_method :extract_dependencies_from
|
|
29
45
|
|
|
46
|
+
def profiled_target_index(root, source_files, profile)
|
|
47
|
+
ExtractionProfile.measure(profile, :target_index) do
|
|
48
|
+
internal_target_index(root, source_files)
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
private_class_method :profiled_target_index
|
|
52
|
+
|
|
53
|
+
def parse_sources(root, sources, profile)
|
|
54
|
+
sources.to_h { |source| [source, parse_source(root.join(source), profile)] }
|
|
55
|
+
end
|
|
56
|
+
private_class_method :parse_sources
|
|
57
|
+
|
|
58
|
+
def constant_edges(analyses, profile)
|
|
59
|
+
ExtractionProfile.measure(profile, :constant_indexing) do
|
|
60
|
+
ConstantIndex.new(analyses, profile:).edges
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
private_class_method :constant_edges
|
|
64
|
+
|
|
65
|
+
def dependencies_for_source(source, context, parse_result)
|
|
66
|
+
imports = extract_imports_from(
|
|
67
|
+
context.root.join(source),
|
|
68
|
+
project_root: context.root,
|
|
69
|
+
load_paths: context.load_paths,
|
|
70
|
+
profile: context.profile,
|
|
71
|
+
resolution_cache: context.cache,
|
|
72
|
+
parse_result:
|
|
73
|
+
)
|
|
74
|
+
context.profile&.increment(:imports, imports.length)
|
|
75
|
+
classify_imports(source, imports, context)
|
|
76
|
+
end
|
|
77
|
+
private_class_method :dependencies_for_source
|
|
78
|
+
|
|
79
|
+
def classify_imports(source, imports, context)
|
|
80
|
+
ExtractionProfile.measure(context.profile, :edge_classification) do
|
|
81
|
+
imports.map do |import|
|
|
82
|
+
dependency_edge(
|
|
83
|
+
source, import, context.targets, include_evidence: context.include_evidence
|
|
84
|
+
)
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
private_class_method :classify_imports
|
|
89
|
+
|
|
30
90
|
def path_value(value)
|
|
31
91
|
value.respond_to?(:to_path) ? value.to_path : value
|
|
32
92
|
end
|
|
@@ -39,22 +99,30 @@ module ArchUnit
|
|
|
39
99
|
end
|
|
40
100
|
private_class_method :internal_target_index
|
|
41
101
|
|
|
42
|
-
def dependency_edge(source, import, internal_targets)
|
|
102
|
+
def dependency_edge(source, import, internal_targets, include_evidence:)
|
|
43
103
|
target = import.resolved_path && internal_targets[canonical_path(import.resolved_path)]
|
|
44
104
|
|
|
45
105
|
Common::Extraction::Edge.new(
|
|
46
106
|
source: source,
|
|
47
107
|
target: target || import.module_name,
|
|
48
108
|
external: target.nil?,
|
|
49
|
-
import_kinds: [import.import_kind]
|
|
109
|
+
import_kinds: [import.import_kind],
|
|
110
|
+
evidence: include_evidence ? [import_evidence(source, import)] : []
|
|
50
111
|
)
|
|
51
112
|
end
|
|
52
113
|
private_class_method :dependency_edge
|
|
53
114
|
|
|
115
|
+
def import_evidence(source, import)
|
|
116
|
+
Common::Extraction::DependencyEvidence.new(
|
|
117
|
+
source:, kind: import.import_kind, name: import.module_name,
|
|
118
|
+
line_number: import.line_number, end_line: import.end_line
|
|
119
|
+
)
|
|
120
|
+
end
|
|
121
|
+
private_class_method :import_evidence
|
|
122
|
+
|
|
54
123
|
def canonical_path(path)
|
|
55
|
-
Pathname.new(path).
|
|
56
|
-
|
|
57
|
-
Pathname.new(path).expand_path.to_s.tr('\\', '/')
|
|
124
|
+
normalized = Pathname.new(path).expand_path.cleanpath.to_s.tr('\\', '/')
|
|
125
|
+
File::FNM_SYSCASE.zero? ? normalized : normalized.downcase
|
|
58
126
|
end
|
|
59
127
|
private_class_method :canonical_path
|
|
60
128
|
end
|