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
|
@@ -4,9 +4,12 @@ require 'pathname'
|
|
|
4
4
|
require_relative '../common/extraction/edge'
|
|
5
5
|
require_relative '../common/extraction/graph'
|
|
6
6
|
require_relative '../common/fluentapi/check_options'
|
|
7
|
+
require_relative '../common/logging/inspection'
|
|
7
8
|
require_relative 'enumerate_source_files'
|
|
8
9
|
require_relative 'extract_dependencies'
|
|
10
|
+
require_relative 'extraction_profile'
|
|
9
11
|
require_relative 'locate_project'
|
|
12
|
+
require_relative 'resolve_load_paths'
|
|
10
13
|
|
|
11
14
|
module ArchUnit
|
|
12
15
|
# Assembles the normalized dependency graph for a Ruby project.
|
|
@@ -17,16 +20,19 @@ module ArchUnit
|
|
|
17
20
|
module_function
|
|
18
21
|
|
|
19
22
|
def extract_graph(
|
|
20
|
-
locator = nil, exclude_patterns: nil, options: nil, working_directory: Dir.pwd
|
|
23
|
+
locator = nil, exclude_patterns: nil, options: nil, working_directory: Dir.pwd,
|
|
24
|
+
profile: nil
|
|
21
25
|
)
|
|
26
|
+
ExtractionProfile.validate(profile)
|
|
22
27
|
options = Common::FluentApi::CheckOptions.resolve(options)
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
28
|
+
ExtractionProfile.measure(profile, :total) do
|
|
29
|
+
root = ExtractionProfile.measure(profile, :project_discovery) do
|
|
30
|
+
Pathname.new(locate_project(locator, working_directory:))
|
|
31
|
+
end
|
|
32
|
+
patterns = resolve_project_exclude_patterns(root, exclude_patterns)
|
|
33
|
+
key = graph_cache_key(root, exclude_patterns: patterns, options:)
|
|
26
34
|
|
|
27
|
-
|
|
28
|
-
graph_cache.delete(cache_key) if options.clear_cache?
|
|
29
|
-
graph_cache[cache_key] ||= extract_graph_uncached(root, patterns)
|
|
35
|
+
Common::Logging::Inspection.graph(fetch_graph(root, patterns, options, key, profile), root:)
|
|
30
36
|
end
|
|
31
37
|
end
|
|
32
38
|
|
|
@@ -35,14 +41,51 @@ module ArchUnit
|
|
|
35
41
|
nil
|
|
36
42
|
end
|
|
37
43
|
|
|
38
|
-
def
|
|
39
|
-
|
|
40
|
-
|
|
44
|
+
def fetch_graph(root, patterns, options, cache_key, profile)
|
|
45
|
+
GRAPH_CACHE_MUTEX.synchronize do
|
|
46
|
+
graph_cache.delete(cache_key) if options.clear_cache?
|
|
47
|
+
cached = graph_cache[cache_key]
|
|
48
|
+
if cached
|
|
49
|
+
profile&.record_cache_hit
|
|
50
|
+
return cached
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
graph_cache[cache_key] = extract_graph_uncached(root, patterns, options, profile)
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
private_class_method :fetch_graph
|
|
41
57
|
|
|
42
|
-
|
|
58
|
+
def extract_graph_uncached(root, exclude_patterns, options, profile)
|
|
59
|
+
source_files = ExtractionProfile.measure(profile, :source_enumeration) do
|
|
60
|
+
enumerate_source_files_with_patterns(root, exclude_patterns)
|
|
61
|
+
end
|
|
62
|
+
profile&.increment(:source_files, source_files.length)
|
|
63
|
+
load_paths = ExtractionProfile.measure(profile, :load_path_discovery) do
|
|
64
|
+
LoadPaths.resolve(root, explicit_paths: options.load_paths, source_files:)
|
|
65
|
+
end
|
|
66
|
+
edges = extraction_edges(root, source_files, load_paths, options, profile)
|
|
67
|
+
graph_from_edges(edges, profile)
|
|
43
68
|
end
|
|
44
69
|
private_class_method :extract_graph_uncached
|
|
45
70
|
|
|
71
|
+
def extraction_edges(root, source_files, load_paths, options, profile)
|
|
72
|
+
edges = self_edges(source_files) + extract_dependencies_from(
|
|
73
|
+
root, source_files, load_paths:, profile:, options:
|
|
74
|
+
)
|
|
75
|
+
profile&.increment(:raw_edges, edges.length)
|
|
76
|
+
edges
|
|
77
|
+
end
|
|
78
|
+
private_class_method :extraction_edges
|
|
79
|
+
|
|
80
|
+
def graph_from_edges(edges, profile)
|
|
81
|
+
merged = ExtractionProfile.measure(profile, :edge_merge) do
|
|
82
|
+
merge_parallel_edges(edges)
|
|
83
|
+
end
|
|
84
|
+
profile&.increment(:merged_edges, merged.length)
|
|
85
|
+
Common::Extraction::Graph.new(merged)
|
|
86
|
+
end
|
|
87
|
+
private_class_method :graph_from_edges
|
|
88
|
+
|
|
46
89
|
def graph_cache_key(project_root, exclude_patterns:, options:)
|
|
47
90
|
[
|
|
48
91
|
project_root.to_s.tr('\\', '/').freeze,
|
|
@@ -80,7 +123,8 @@ module ArchUnit
|
|
|
80
123
|
source: first.source,
|
|
81
124
|
target: first.target,
|
|
82
125
|
external: first.external,
|
|
83
|
-
import_kinds: parallel_edges.flat_map(&:import_kinds)
|
|
126
|
+
import_kinds: parallel_edges.flat_map(&:import_kinds),
|
|
127
|
+
evidence: parallel_edges.flat_map(&:evidence)
|
|
84
128
|
)
|
|
85
129
|
end
|
|
86
130
|
end
|
|
@@ -1,109 +1,80 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require 'pathname'
|
|
4
3
|
require 'prism'
|
|
5
4
|
require_relative '../error/technical_error'
|
|
5
|
+
require_relative '../error/source_parse_error'
|
|
6
6
|
require_relative '../error/user_error'
|
|
7
|
-
require_relative '
|
|
7
|
+
require_relative 'extraction_profile'
|
|
8
|
+
require_relative 'import_resolver'
|
|
9
|
+
require_relative 'import_visitor'
|
|
8
10
|
require_relative 'resolved_import'
|
|
11
|
+
require_relative 'validate_extraction_paths'
|
|
9
12
|
|
|
10
13
|
module ArchUnit
|
|
11
14
|
# Parses Ruby source without executing it and resolves literal imports.
|
|
12
15
|
module Extraction
|
|
13
16
|
IGNORE_DIRECTIVE = /\A#\s*archunit:\s*ignore(?:\s+(?<modules>.+?))?\s*\z/
|
|
14
17
|
|
|
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
18
|
module_function
|
|
87
19
|
|
|
88
|
-
def extract_imports(
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
20
|
+
def extract_imports(
|
|
21
|
+
source_file, project_root:, load_paths: nil, profile: nil, resolution_cache: nil
|
|
22
|
+
)
|
|
23
|
+
source_path = ExtractionPaths.source_file(source_file)
|
|
24
|
+
root = ExtractionPaths.project_root(project_root)
|
|
25
|
+
extract_imports_from(
|
|
26
|
+
source_path, project_root: root, load_paths:, profile:, resolution_cache:
|
|
27
|
+
)
|
|
94
28
|
rescue UserError
|
|
95
29
|
raise
|
|
96
30
|
rescue SystemCallError => e
|
|
97
31
|
raise TechnicalError, "could not parse Ruby source file: #{e.message}"
|
|
98
32
|
end
|
|
99
33
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
34
|
+
# Share the parsed AST with constant analysis.
|
|
35
|
+
def extract_imports_from( # rubocop:disable Metrics/ParameterLists
|
|
36
|
+
source_path, project_root:, load_paths: nil, profile: nil, resolution_cache: nil,
|
|
37
|
+
parse_result: nil
|
|
38
|
+
)
|
|
39
|
+
resolver = build_import_resolver(
|
|
40
|
+
source_path:, project_root:, load_paths:, profile:, cache: resolution_cache
|
|
41
|
+
)
|
|
42
|
+
parse_result ||= parse_source(source_path, profile)
|
|
43
|
+
resolve_imports(parsed_imports(parse_result, profile), resolver)
|
|
44
|
+
rescue SystemCallError => e
|
|
45
|
+
raise TechnicalError, "could not parse Ruby source file: #{e.message}"
|
|
46
|
+
end
|
|
47
|
+
private_class_method :extract_imports_from
|
|
48
|
+
|
|
49
|
+
def build_import_resolver(**)
|
|
50
|
+
ImportResolver.new(**)
|
|
51
|
+
end
|
|
52
|
+
private_class_method :build_import_resolver
|
|
53
|
+
|
|
54
|
+
def resolve_imports(imports, resolver)
|
|
55
|
+
imports.map { |import| resolved_import(import, resolver) }.freeze
|
|
56
|
+
end
|
|
57
|
+
private_class_method :resolve_imports
|
|
103
58
|
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
59
|
+
def parse_source(source_path, profile)
|
|
60
|
+
source = ExtractionProfile.measure(profile, :file_read) { File.binread(source_path) }
|
|
61
|
+
result = ExtractionProfile.measure(profile, :prism_parse) do
|
|
62
|
+
Prism.parse(source, filepath: source_path.to_s)
|
|
63
|
+
end
|
|
64
|
+
profile&.increment(:malformed_files) unless result.success?
|
|
65
|
+
SourceParseError.validate!(result, path: source_path)
|
|
66
|
+
rescue SystemCallError => e
|
|
67
|
+
raise TechnicalError, "could not parse Ruby source file: #{e.message}"
|
|
68
|
+
end
|
|
69
|
+
private_class_method :parse_source
|
|
70
|
+
|
|
71
|
+
def parsed_imports(parse_result, profile)
|
|
72
|
+
ExtractionProfile.measure(profile, :import_extraction) do
|
|
73
|
+
visitor = ImportVisitor.new
|
|
74
|
+
visitor.visit(parse_result.value)
|
|
75
|
+
profile&.increment(:unresolved_imports, visitor.unresolved_imports)
|
|
76
|
+
reject_ignored_imports(visitor.imports, parse_result.comments)
|
|
77
|
+
end
|
|
107
78
|
end
|
|
108
79
|
private_class_method :parsed_imports
|
|
109
80
|
|
|
@@ -147,51 +118,16 @@ module ArchUnit
|
|
|
147
118
|
end
|
|
148
119
|
private_class_method :ignored_module?
|
|
149
120
|
|
|
150
|
-
def resolved_import(located_import,
|
|
151
|
-
module_name, import_kind, line_number, = located_import
|
|
121
|
+
def resolved_import(located_import, resolver)
|
|
122
|
+
module_name, import_kind, line_number, end_line = located_import
|
|
152
123
|
ResolvedImport.new(
|
|
153
124
|
module_name: module_name,
|
|
154
125
|
import_kind: import_kind,
|
|
155
126
|
line_number: line_number,
|
|
156
|
-
|
|
127
|
+
end_line: end_line,
|
|
128
|
+
resolved_path: resolver.resolve(module_name, import_kind)
|
|
157
129
|
)
|
|
158
130
|
end
|
|
159
131
|
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
132
|
end
|
|
197
133
|
end
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ArchUnit
|
|
4
|
+
module Extraction
|
|
5
|
+
# Mutable per-run timing and counter collector for cold/warm extraction benchmarks.
|
|
6
|
+
class ExtractionProfile
|
|
7
|
+
STAGES = %i[
|
|
8
|
+
total
|
|
9
|
+
project_discovery
|
|
10
|
+
source_enumeration
|
|
11
|
+
load_path_discovery
|
|
12
|
+
file_read
|
|
13
|
+
prism_parse
|
|
14
|
+
import_extraction
|
|
15
|
+
constant_indexing
|
|
16
|
+
target_resolution
|
|
17
|
+
target_index
|
|
18
|
+
edge_classification
|
|
19
|
+
edge_merge
|
|
20
|
+
].freeze
|
|
21
|
+
COUNTERS = %i[
|
|
22
|
+
source_files
|
|
23
|
+
malformed_files
|
|
24
|
+
imports
|
|
25
|
+
unresolved_imports
|
|
26
|
+
unresolved_constant_references
|
|
27
|
+
ambiguous_constant_references
|
|
28
|
+
resolution_cache_hits
|
|
29
|
+
resolution_cache_misses
|
|
30
|
+
raw_edges
|
|
31
|
+
merged_edges
|
|
32
|
+
].freeze
|
|
33
|
+
|
|
34
|
+
attr_reader :stage_seconds, :counters
|
|
35
|
+
|
|
36
|
+
def self.measure(profile, stage, &operation)
|
|
37
|
+
return operation.call unless profile
|
|
38
|
+
|
|
39
|
+
profile.measure(stage, &operation)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def self.validate(value)
|
|
43
|
+
return if value.nil? || value.is_a?(self)
|
|
44
|
+
|
|
45
|
+
raise ArgumentError, 'profile must be an ExtractionProfile value or nil'
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def initialize(clock: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) })
|
|
49
|
+
raise ArgumentError, 'clock must respond to call' unless clock.respond_to?(:call)
|
|
50
|
+
|
|
51
|
+
@clock = clock
|
|
52
|
+
@stage_seconds = STAGES.to_h { |stage| [stage, 0.0] }
|
|
53
|
+
@counters = COUNTERS.to_h { |counter| [counter, 0] }
|
|
54
|
+
@cache_hit = false
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def measure(stage)
|
|
58
|
+
validate_stage(stage)
|
|
59
|
+
started_at = @clock.call
|
|
60
|
+
yield
|
|
61
|
+
ensure
|
|
62
|
+
@stage_seconds[stage] += @clock.call - started_at if started_at
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def increment(counter, amount = 1)
|
|
66
|
+
validate_counter(counter)
|
|
67
|
+
unless amount.is_a?(Integer) && !amount.negative?
|
|
68
|
+
raise ArgumentError, 'counter amount must be a non-negative Integer'
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
@counters[counter] += amount
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def record_cache_hit
|
|
75
|
+
@cache_hit = true
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def cache_hit?
|
|
79
|
+
@cache_hit
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def to_h
|
|
83
|
+
{
|
|
84
|
+
cache_hit: cache_hit?,
|
|
85
|
+
stage_seconds: stage_seconds.transform_values { |value| value.round(6) },
|
|
86
|
+
counters: counters.dup
|
|
87
|
+
}
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
private
|
|
91
|
+
|
|
92
|
+
def validate_stage(stage)
|
|
93
|
+
return if STAGES.include?(stage)
|
|
94
|
+
|
|
95
|
+
raise ArgumentError, "unknown extraction stage: #{stage.inspect}"
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def validate_counter(counter)
|
|
99
|
+
return if COUNTERS.include?(counter)
|
|
100
|
+
|
|
101
|
+
raise ArgumentError, "unknown extraction counter: #{counter.inspect}"
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative '../common/extraction/import_kind'
|
|
4
|
+
require_relative 'extraction_profile'
|
|
5
|
+
require_relative 'resolve_import'
|
|
6
|
+
|
|
7
|
+
module ArchUnit
|
|
8
|
+
module Extraction
|
|
9
|
+
# Resolves one source file's imports and reuses context-independent feature lookups.
|
|
10
|
+
class ImportResolver
|
|
11
|
+
def initialize(source_path:, project_root:, load_paths:, profile: nil, cache: nil)
|
|
12
|
+
@source_path = source_path
|
|
13
|
+
@project_root = project_root
|
|
14
|
+
@load_paths = load_paths
|
|
15
|
+
@profile = profile
|
|
16
|
+
@cache = cache
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def resolve(module_name, import_kind)
|
|
20
|
+
key = cache_key(module_name, import_kind)
|
|
21
|
+
return cached_resolution(key) if @cache&.key?(key)
|
|
22
|
+
|
|
23
|
+
@profile&.increment(:resolution_cache_misses) if @cache
|
|
24
|
+
resolution = uncached_resolution(module_name, import_kind)
|
|
25
|
+
@cache[key] = resolution if @cache
|
|
26
|
+
resolution
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
def cached_resolution(key)
|
|
32
|
+
@profile&.increment(:resolution_cache_hits)
|
|
33
|
+
@cache[key]
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def uncached_resolution(module_name, import_kind)
|
|
37
|
+
ExtractionProfile.measure(@profile, :target_resolution) do
|
|
38
|
+
Extraction.resolve_import(
|
|
39
|
+
module_name,
|
|
40
|
+
source_file: @source_path,
|
|
41
|
+
project_root: @project_root,
|
|
42
|
+
import_kind:,
|
|
43
|
+
load_paths: @load_paths
|
|
44
|
+
)
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def cache_key(module_name, import_kind)
|
|
49
|
+
source_context = if import_kind == Common::Extraction::ImportKind::REQUIRE_RELATIVE
|
|
50
|
+
File.dirname(@source_path.to_s)
|
|
51
|
+
end
|
|
52
|
+
[module_name, import_kind, source_context].freeze
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'prism'
|
|
4
|
+
require_relative '../common/extraction/import_kind'
|
|
5
|
+
require_relative 'conservative_local_flow'
|
|
6
|
+
require_relative 'local_string_tracker'
|
|
7
|
+
|
|
8
|
+
module ArchUnit
|
|
9
|
+
module Extraction
|
|
10
|
+
# Collects supported dependency calls from a Prism syntax tree.
|
|
11
|
+
class ImportVisitor < Prism::Visitor
|
|
12
|
+
include ConservativeLocalFlow
|
|
13
|
+
|
|
14
|
+
CALL_KINDS = {
|
|
15
|
+
require: Common::Extraction::ImportKind::REQUIRE,
|
|
16
|
+
require_relative: Common::Extraction::ImportKind::REQUIRE_RELATIVE,
|
|
17
|
+
autoload: Common::Extraction::ImportKind::AUTOLOAD,
|
|
18
|
+
load: Common::Extraction::ImportKind::LOAD
|
|
19
|
+
}.freeze
|
|
20
|
+
|
|
21
|
+
attr_reader :imports, :unresolved_imports
|
|
22
|
+
|
|
23
|
+
def initialize
|
|
24
|
+
super
|
|
25
|
+
@imports = []
|
|
26
|
+
@unresolved_imports = 0
|
|
27
|
+
@local_strings = LocalStringTracker.new
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def visit_call_node(node)
|
|
31
|
+
import_call = supported_import_call?(node)
|
|
32
|
+
record_import(node) if import_call
|
|
33
|
+
super
|
|
34
|
+
invalidate_escaped_locals(node) unless import_call
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def visit_local_variable_write_node(node)
|
|
38
|
+
super
|
|
39
|
+
@local_strings.assign(node.name, node.value)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
private
|
|
43
|
+
|
|
44
|
+
def record_import(node)
|
|
45
|
+
import_kind = CALL_KINDS[node.name]
|
|
46
|
+
arguments = node.arguments&.arguments || []
|
|
47
|
+
argument = import_argument(arguments, import_kind)
|
|
48
|
+
module_name = @local_strings.resolve(argument)
|
|
49
|
+
unless valid_module_name?(module_name)
|
|
50
|
+
@unresolved_imports += 1
|
|
51
|
+
return
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
@imports << located_import(module_name, import_kind, node)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def located_import(module_name, import_kind, node)
|
|
58
|
+
[module_name, import_kind, node.location.start_line, node.location.end_line]
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def valid_module_name?(module_name)
|
|
62
|
+
module_name.is_a?(String) && !module_name.empty? && !module_name.include?("\0")
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def supported_import_call?(node)
|
|
66
|
+
import_kind = CALL_KINDS[node.name]
|
|
67
|
+
import_kind && supported_receiver?(node, import_kind) &&
|
|
68
|
+
!import_argument(node.arguments&.arguments || [], import_kind).nil?
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def supported_receiver?(node, import_kind)
|
|
72
|
+
return true if node.receiver.nil?
|
|
73
|
+
if import_kind == Common::Extraction::ImportKind::AUTOLOAD
|
|
74
|
+
return constant_receiver?(node.receiver)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
node.receiver.respond_to?(:full_name) &&
|
|
78
|
+
node.receiver.full_name.delete_prefix('::') == 'Kernel'
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def constant_receiver?(receiver)
|
|
82
|
+
receiver.is_a?(Prism::ConstantReadNode) || receiver.is_a?(Prism::ConstantPathNode)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def import_argument(arguments, import_kind)
|
|
86
|
+
if import_kind == Common::Extraction::ImportKind::AUTOLOAD
|
|
87
|
+
return unless arguments.length == 2
|
|
88
|
+
|
|
89
|
+
arguments[1]
|
|
90
|
+
else
|
|
91
|
+
return unless arguments.length == 1
|
|
92
|
+
|
|
93
|
+
arguments[0]
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def invalidate_escaped_locals(node)
|
|
98
|
+
@local_strings.invalidate_escaped(node)
|
|
99
|
+
@local_strings.capture_scopes if %i[eval binding local_variable_set].include?(node.name)
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
private_constant :ImportVisitor
|
|
103
|
+
end
|
|
104
|
+
end
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'prism'
|
|
4
|
+
|
|
5
|
+
module ArchUnit
|
|
6
|
+
module Extraction
|
|
7
|
+
# Conservatively tracks local variables whose current value is a literal String.
|
|
8
|
+
class LocalStringTracker
|
|
9
|
+
UNKNOWN = Object.new.freeze
|
|
10
|
+
private_constant :UNKNOWN
|
|
11
|
+
|
|
12
|
+
def initialize
|
|
13
|
+
@scopes = [{}]
|
|
14
|
+
@captured_scopes = [false]
|
|
15
|
+
@uncertain_depth = 0
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def resolve(node)
|
|
19
|
+
return node.unescaped if node.is_a?(Prism::StringNode)
|
|
20
|
+
return UNKNOWN if @uncertain_depth.positive? || @captured_scopes.last
|
|
21
|
+
return scope.fetch(node.name, UNKNOWN) if node.is_a?(Prism::LocalVariableReadNode)
|
|
22
|
+
|
|
23
|
+
UNKNOWN
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def assign(name, value)
|
|
27
|
+
invalidate_escaped(value) if value && !value.is_a?(Prism::StringNode)
|
|
28
|
+
scope[name] = resolve(value)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def invalidate_escaped(node)
|
|
32
|
+
local_reads(node).each { |name| scope[name] = UNKNOWN if scope.key?(name) }
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def isolated_scope
|
|
36
|
+
@scopes << {}
|
|
37
|
+
@captured_scopes << false
|
|
38
|
+
yield
|
|
39
|
+
ensure
|
|
40
|
+
@scopes.pop
|
|
41
|
+
@captured_scopes.pop
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def invalidate_all
|
|
45
|
+
@scopes.each { |values| values.transform_values! { UNKNOWN } }
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def capture_scopes
|
|
49
|
+
@captured_scopes.map! { true }
|
|
50
|
+
invalidate_all
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def uncertain_flow
|
|
54
|
+
invalidate_scope
|
|
55
|
+
@uncertain_depth += 1
|
|
56
|
+
yield
|
|
57
|
+
ensure
|
|
58
|
+
@uncertain_depth -= 1
|
|
59
|
+
invalidate_scope
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
private
|
|
63
|
+
|
|
64
|
+
def local_reads(node)
|
|
65
|
+
return [node.name] if node.is_a?(Prism::LocalVariableReadNode)
|
|
66
|
+
|
|
67
|
+
node.compact_child_nodes.flat_map { |child| local_reads(child) }.uniq
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def invalidate_scope
|
|
71
|
+
scope.transform_values! { UNKNOWN }
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def scope
|
|
75
|
+
@scopes.last
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
private_constant :LocalStringTracker
|
|
79
|
+
end
|
|
80
|
+
end
|
|
@@ -8,8 +8,8 @@ module ArchUnit
|
|
|
8
8
|
module Extraction
|
|
9
9
|
module_function
|
|
10
10
|
|
|
11
|
-
def resolve_import(module_name, source_file:, project_root:, import_kind:)
|
|
12
|
-
roots = import_search_roots(source_file, project_root, import_kind)
|
|
11
|
+
def resolve_import(module_name, source_file:, project_root:, import_kind:, load_paths: nil)
|
|
12
|
+
roots = import_search_roots(source_file, project_root, import_kind, load_paths)
|
|
13
13
|
resolved = if import_kind == Common::Extraction::ImportKind::LOAD
|
|
14
14
|
resolve_load(module_name, roots, import_kind)
|
|
15
15
|
else
|
|
@@ -19,9 +19,11 @@ module ArchUnit
|
|
|
19
19
|
resolved&.tr('\\', '/')&.freeze
|
|
20
20
|
end
|
|
21
21
|
|
|
22
|
-
def import_search_roots(source_file, project_root, import_kind)
|
|
22
|
+
def import_search_roots(source_file, project_root, import_kind, load_paths)
|
|
23
23
|
if import_kind == Common::Extraction::ImportKind::REQUIRE_RELATIVE
|
|
24
24
|
[File.dirname(File.expand_path(source_file))]
|
|
25
|
+
elsif load_paths
|
|
26
|
+
load_paths
|
|
25
27
|
else
|
|
26
28
|
root = File.expand_path(project_root)
|
|
27
29
|
[File.join(root, 'lib'), root]
|