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
@@ -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
- 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:)
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
- 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)
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 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)
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
- Common::Extraction::Graph.new(merge_parallel_edges(edges))
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 'resolve_import'
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(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
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
- def parsed_imports(source_path)
101
- parse_result = Prism.parse_file(source_path.to_s)
102
- return [] unless parse_result.success?
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
- visitor = ImportVisitor.new
105
- visitor.visit(parse_result.value)
106
- reject_ignored_imports(visitor.imports, parse_result.comments)
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, source_path, root)
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
- resolved_path: resolve_import_for(module_name, import_kind, source_path, root)
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]