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.
Files changed (82) hide show
  1. checksums.yaml +4 -4
  2. data/API.md +290 -0
  3. data/CHANGELOG.md +45 -0
  4. data/README.md +389 -90
  5. data/assets/logo-rounded.png +0 -0
  6. data/lib/archunit/common/extraction/dependency_evidence.rb +44 -0
  7. data/lib/archunit/common/extraction/edge.rb +7 -2
  8. data/lib/archunit/common/extraction/import_kind.rb +2 -1
  9. data/lib/archunit/common/extraction/parse_diagnostic.rb +23 -0
  10. data/lib/archunit/common/fluentapi/check_options.rb +43 -2
  11. data/lib/archunit/common/fluentapi/checkable.rb +25 -1
  12. data/lib/archunit/common/logging/check_logger.rb +8 -0
  13. data/lib/archunit/common/logging/inspection.rb +59 -0
  14. data/lib/archunit/common/logging/logging_options.rb +5 -0
  15. data/lib/archunit/common/pattern.rb +18 -8
  16. data/lib/archunit/common/projection/cycles/johnson_cycles.rb +35 -19
  17. data/lib/archunit/common/projection/cycles/tarjan_scc.rb +30 -10
  18. data/lib/archunit/error/source_parse_error.rb +38 -0
  19. data/lib/archunit/extraction/arch_ignore.rb +91 -0
  20. data/lib/archunit/extraction/conservative_local_flow.rb +100 -0
  21. data/lib/archunit/extraction/constant_index.rb +110 -0
  22. data/lib/archunit/extraction/constant_visitor.rb +129 -0
  23. data/lib/archunit/extraction/discover_gemspec_load_paths.rb +96 -0
  24. data/lib/archunit/extraction/enumerate_source_files.rb +23 -8
  25. data/lib/archunit/extraction/extract_dependencies.rb +82 -14
  26. data/lib/archunit/extraction/extract_graph.rb +56 -12
  27. data/lib/archunit/extraction/extract_imports.rb +60 -124
  28. data/lib/archunit/extraction/extraction_profile.rb +105 -0
  29. data/lib/archunit/extraction/import_resolver.rb +56 -0
  30. data/lib/archunit/extraction/import_visitor.rb +104 -0
  31. data/lib/archunit/extraction/local_string_tracker.rb +80 -0
  32. data/lib/archunit/extraction/resolve_import.rb +5 -3
  33. data/lib/archunit/extraction/resolve_load_paths.rb +93 -0
  34. data/lib/archunit/extraction/resolved_import.rb +5 -2
  35. data/lib/archunit/extraction/validate_extraction_paths.rb +37 -0
  36. data/lib/archunit/files/extraction/extract_file_info.rb +14 -5
  37. data/lib/archunit/files/extraction/file_info.rb +1 -2
  38. data/lib/archunit/files/fluentapi/depend_on_external_module_condition.rb +2 -0
  39. data/lib/archunit/files/fluentapi/depend_on_external_module_condition_builder.rb +2 -0
  40. data/lib/archunit/files/fluentapi/depend_on_file_condition.rb +6 -0
  41. data/lib/archunit/files/fluentapi/depend_on_file_condition_builder.rb +6 -0
  42. data/lib/archunit/files/fluentapi/file_condition_builder.rb +12 -0
  43. data/lib/archunit/files/fluentapi/file_rule_support.rb +8 -1
  44. data/lib/archunit/files/fluentapi/files.rb +6 -0
  45. data/lib/archunit/files/fluentapi/match_pattern_file_condition_builder.rb +14 -0
  46. data/lib/archunit/files/fluentapi/positive_match_pattern_file_condition_builder.rb +2 -0
  47. data/lib/archunit/graph/fluentapi/graph.rb +5 -0
  48. data/lib/archunit/graph/fluentapi/project_graph_builder.rb +44 -0
  49. data/lib/archunit/graph/projection/aggregate_edges.rb +4 -2
  50. data/lib/archunit/graph/projection/graph_query_options.rb +1 -2
  51. data/lib/archunit/graph/projection/graph_report_edge.rb +12 -2
  52. data/lib/archunit/graph/rendering/json_renderer.rb +3 -1
  53. data/lib/archunit/layers/fluentapi/layer_definition_builder.rb +4 -0
  54. data/lib/archunit/layers/fluentapi/layer_dependency_rule_builder.rb +4 -0
  55. data/lib/archunit/layers/fluentapi/layered_architecture.rb +5 -0
  56. data/lib/archunit/layers/fluentapi/layers.rb +5 -0
  57. data/lib/archunit/metrics/extraction/extract_distance_info.rb +1 -2
  58. data/lib/archunit/metrics/extraction/extract_project_info.rb +6 -6
  59. data/lib/archunit/metrics/extraction/metric_info.rb +2 -4
  60. data/lib/archunit/metrics/extraction/source_metrics_visitor.rb +2 -4
  61. data/lib/archunit/metrics/fluentapi/custom_metric_builder.rb +2 -0
  62. data/lib/archunit/metrics/fluentapi/custom_metric_condition.rb +4 -1
  63. data/lib/archunit/metrics/fluentapi/logged_metric.rb +23 -0
  64. data/lib/archunit/metrics/fluentapi/metric_predicate_condition.rb +4 -1
  65. data/lib/archunit/metrics/fluentapi/metric_report_builder.rb +2 -0
  66. data/lib/archunit/metrics/fluentapi/metric_selection.rb +18 -2
  67. data/lib/archunit/metrics/fluentapi/metric_threshold_condition.rb +2 -1
  68. data/lib/archunit/metrics/fluentapi/metrics.rb +5 -0
  69. data/lib/archunit/metrics/fluentapi/metrics_builder.rb +15 -0
  70. data/lib/archunit/slices/fluentapi/diagram_slice_condition.rb +1 -0
  71. data/lib/archunit/slices/fluentapi/forbidden_slice_dependency_condition.rb +1 -0
  72. data/lib/archunit/slices/fluentapi/negative_slice_condition_builder.rb +2 -0
  73. data/lib/archunit/slices/fluentapi/positive_slice_condition_builder.rb +8 -0
  74. data/lib/archunit/slices/fluentapi/slice_scope_builder.rb +12 -0
  75. data/lib/archunit/slices/fluentapi/slices.rb +5 -0
  76. data/lib/archunit/testing/assert_passes.rb +4 -0
  77. data/lib/archunit/testing/result_factory.rb +16 -4
  78. data/lib/archunit/testing/rspec_adapter.rb +1 -2
  79. data/lib/archunit/testing.rb +7 -3
  80. data/lib/archunit/version.rb +1 -1
  81. data/lib/archunit.rb +9 -0
  82. 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 = resolve_exclude_patterns(exclude_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
- if File.directory?(entry)
44
- Find.prune if excluded_path?(entry, root, exclude_patterns)
45
- next
46
- end
56
+ stat = File.lstat(entry)
57
+ next if stat.symlink?
47
58
 
48
- if ruby_source_file?(entry) && !excluded_path?(entry, root, exclude_patterns)
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
- File.file?(entry) && File.extname(entry) == '.rb'
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
- extract_dependencies_from(root, source_files)
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
- internal_targets = internal_target_index(root, source_files)
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
- source_files.flat_map do |source|
23
- extract_imports(root.join(source), project_root: root).map do |import|
24
- dependency_edge(source, import, internal_targets)
25
- end
26
- end.freeze
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).realpath.to_s.tr('\\', '/')
56
- rescue SystemCallError
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