archunit 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (149) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +13 -0
  3. data/LICENSE +7 -0
  4. data/README.md +375 -0
  5. data/lib/archunit/common/assertion/empty_test_violation.rb +50 -0
  6. data/lib/archunit/common/assertion/violation.rb +14 -0
  7. data/lib/archunit/common/extraction/edge.rb +46 -0
  8. data/lib/archunit/common/extraction/graph.rb +73 -0
  9. data/lib/archunit/common/extraction/import_kind.rb +21 -0
  10. data/lib/archunit/common/filter.rb +46 -0
  11. data/lib/archunit/common/fluentapi/check_options.rb +48 -0
  12. data/lib/archunit/common/fluentapi/checkable.rb +70 -0
  13. data/lib/archunit/common/logging/check_logger.rb +124 -0
  14. data/lib/archunit/common/logging/logging_options.rb +57 -0
  15. data/lib/archunit/common/pattern.rb +99 -0
  16. data/lib/archunit/common/pattern_matching.rb +76 -0
  17. data/lib/archunit/common/projection/cycles/johnson_cycles.rb +103 -0
  18. data/lib/archunit/common/projection/cycles/tarjan_scc.rb +82 -0
  19. data/lib/archunit/common/projection/edge_projections.rb +50 -0
  20. data/lib/archunit/common/projection/mapped_edge.rb +26 -0
  21. data/lib/archunit/common/projection/project_cycles.rb +92 -0
  22. data/lib/archunit/common/projection/project_edges.rb +69 -0
  23. data/lib/archunit/common/projection/project_to_nodes.rb +59 -0
  24. data/lib/archunit/common/projection/projected_edge.rb +35 -0
  25. data/lib/archunit/common/projection/projected_node.rb +36 -0
  26. data/lib/archunit/common/regex_factory.rb +96 -0
  27. data/lib/archunit/error/technical_error.rb +6 -0
  28. data/lib/archunit/error/user_error.rb +6 -0
  29. data/lib/archunit/extraction/enumerate_source_files.rb +131 -0
  30. data/lib/archunit/extraction/extract_dependencies.rb +61 -0
  31. data/lib/archunit/extraction/extract_graph.rb +89 -0
  32. data/lib/archunit/extraction/extract_imports.rb +197 -0
  33. data/lib/archunit/extraction/locate_project.rb +80 -0
  34. data/lib/archunit/extraction/resolve_import.rb +76 -0
  35. data/lib/archunit/extraction/resolved_import.rb +46 -0
  36. data/lib/archunit/files/assertion/custom_file_condition.rb +89 -0
  37. data/lib/archunit/files/assertion/cycle_free.rb +58 -0
  38. data/lib/archunit/files/assertion/depend_on_external_modules.rb +116 -0
  39. data/lib/archunit/files/assertion/depend_on_files.rb +110 -0
  40. data/lib/archunit/files/assertion/matching_files.rb +87 -0
  41. data/lib/archunit/files/extraction/extract_file_info.rb +84 -0
  42. data/lib/archunit/files/extraction/file_info.rb +46 -0
  43. data/lib/archunit/files/fluentapi/custom_file_condition.rb +73 -0
  44. data/lib/archunit/files/fluentapi/cycle_free_file_condition.rb +55 -0
  45. data/lib/archunit/files/fluentapi/depend_on_external_module_condition.rb +69 -0
  46. data/lib/archunit/files/fluentapi/depend_on_external_module_condition_builder.rb +36 -0
  47. data/lib/archunit/files/fluentapi/depend_on_file_condition.rb +80 -0
  48. data/lib/archunit/files/fluentapi/depend_on_file_condition_builder.rb +49 -0
  49. data/lib/archunit/files/fluentapi/file_condition_builder.rb +71 -0
  50. data/lib/archunit/files/fluentapi/file_rule_support.rb +24 -0
  51. data/lib/archunit/files/fluentapi/files.rb +31 -0
  52. data/lib/archunit/files/fluentapi/match_pattern_file_condition.rb +59 -0
  53. data/lib/archunit/files/fluentapi/match_pattern_file_condition_builder.rb +66 -0
  54. data/lib/archunit/files/fluentapi/negated_match_pattern_file_condition_builder.rb +16 -0
  55. data/lib/archunit/files/fluentapi/positive_match_pattern_file_condition_builder.rb +21 -0
  56. data/lib/archunit/graph/fluentapi/graph.rb +29 -0
  57. data/lib/archunit/graph/fluentapi/project_graph_builder.rb +106 -0
  58. data/lib/archunit/graph/projection/aggregate_edges.rb +48 -0
  59. data/lib/archunit/graph/projection/collapse_node.rb +33 -0
  60. data/lib/archunit/graph/projection/create_snapshot.rb +84 -0
  61. data/lib/archunit/graph/projection/folder_depth_collapse.rb +18 -0
  62. data/lib/archunit/graph/projection/graph_query_options.rb +81 -0
  63. data/lib/archunit/graph/projection/graph_report_edge.rb +50 -0
  64. data/lib/archunit/graph/projection/graph_report_node.rb +22 -0
  65. data/lib/archunit/graph/projection/graph_report_snapshot.rb +43 -0
  66. data/lib/archunit/graph/projection/graph_report_summary.rb +20 -0
  67. data/lib/archunit/graph/projection/node_selection.rb +111 -0
  68. data/lib/archunit/graph/projection/pattern_collapse.rb +24 -0
  69. data/lib/archunit/graph/rendering/csv_renderer.rb +30 -0
  70. data/lib/archunit/graph/rendering/d2_renderer.rb +30 -0
  71. data/lib/archunit/graph/rendering/dot_renderer.rb +51 -0
  72. data/lib/archunit/graph/rendering/escaping.rb +31 -0
  73. data/lib/archunit/graph/rendering/export_report.rb +29 -0
  74. data/lib/archunit/graph/rendering/graph_renderer.rb +54 -0
  75. data/lib/archunit/graph/rendering/html_document.rb +46 -0
  76. data/lib/archunit/graph/rendering/html_renderer.rb +102 -0
  77. data/lib/archunit/graph/rendering/json_renderer.rb +38 -0
  78. data/lib/archunit/graph/rendering/mermaid_renderer.rb +33 -0
  79. data/lib/archunit/graph/rendering/rendering_support.rb +24 -0
  80. data/lib/archunit/layers/assertion/layer_definition.rb +56 -0
  81. data/lib/archunit/layers/assertion/layer_dependencies.rb +91 -0
  82. data/lib/archunit/layers/assertion/layer_dependency_violation.rb +59 -0
  83. data/lib/archunit/layers/fluentapi/layer_definition_builder.rb +46 -0
  84. data/lib/archunit/layers/fluentapi/layer_dependency_rule_builder.rb +44 -0
  85. data/lib/archunit/layers/fluentapi/layered_architecture.rb +133 -0
  86. data/lib/archunit/layers/fluentapi/layered_architecture_values.rb +51 -0
  87. data/lib/archunit/layers/fluentapi/layers.rb +29 -0
  88. data/lib/archunit/metrics/assertion/custom_metric.rb +73 -0
  89. data/lib/archunit/metrics/assertion/metric_predicate.rb +66 -0
  90. data/lib/archunit/metrics/assertion/metric_threshold.rb +99 -0
  91. data/lib/archunit/metrics/assertion/metric_zone.rb +57 -0
  92. data/lib/archunit/metrics/calculation/count.rb +45 -0
  93. data/lib/archunit/metrics/calculation/distance.rb +84 -0
  94. data/lib/archunit/metrics/calculation/lcom.rb +103 -0
  95. data/lib/archunit/metrics/calculation/metric.rb +52 -0
  96. data/lib/archunit/metrics/calculation/numeric_value.rb +25 -0
  97. data/lib/archunit/metrics/extraction/distance_info.rb +60 -0
  98. data/lib/archunit/metrics/extraction/extract_distance_info.rb +50 -0
  99. data/lib/archunit/metrics/extraction/extract_project_info.rb +90 -0
  100. data/lib/archunit/metrics/extraction/metric_info.rb +159 -0
  101. data/lib/archunit/metrics/extraction/source_metrics_visitor.rb +278 -0
  102. data/lib/archunit/metrics/fluentapi/count_metrics_builder.rb +49 -0
  103. data/lib/archunit/metrics/fluentapi/custom_metric_builder.rb +30 -0
  104. data/lib/archunit/metrics/fluentapi/custom_metric_condition.rb +40 -0
  105. data/lib/archunit/metrics/fluentapi/distance_metrics_builder.rb +48 -0
  106. data/lib/archunit/metrics/fluentapi/lcom_metrics_builder.rb +39 -0
  107. data/lib/archunit/metrics/fluentapi/metric_measurement.rb +36 -0
  108. data/lib/archunit/metrics/fluentapi/metric_predicate_condition.rb +42 -0
  109. data/lib/archunit/metrics/fluentapi/metric_report_builder.rb +34 -0
  110. data/lib/archunit/metrics/fluentapi/metric_selection.rb +54 -0
  111. data/lib/archunit/metrics/fluentapi/metric_threshold_condition.rb +47 -0
  112. data/lib/archunit/metrics/fluentapi/metrics.rb +21 -0
  113. data/lib/archunit/metrics/fluentapi/metrics_builder.rb +136 -0
  114. data/lib/archunit/metrics/fluentapi/zone_condition.rb +40 -0
  115. data/lib/archunit/metrics/reporting/metrics_export_options.rb +57 -0
  116. data/lib/archunit/metrics/reporting/metrics_exporter.rb +130 -0
  117. data/lib/archunit/slices/assertion/adhere_to_diagram.rb +57 -0
  118. data/lib/archunit/slices/assertion/contain_dependency.rb +42 -0
  119. data/lib/archunit/slices/assertion/diagram_adherence_options.rb +37 -0
  120. data/lib/archunit/slices/assertion/slice_dependency_violation.rb +70 -0
  121. data/lib/archunit/slices/fluentapi/diagram_slice_condition.rb +65 -0
  122. data/lib/archunit/slices/fluentapi/diagram_source.rb +36 -0
  123. data/lib/archunit/slices/fluentapi/forbidden_slice_dependency_condition.rb +57 -0
  124. data/lib/archunit/slices/fluentapi/negative_slice_condition_builder.rb +27 -0
  125. data/lib/archunit/slices/fluentapi/positive_slice_condition_builder.rb +51 -0
  126. data/lib/archunit/slices/fluentapi/slice_scope_builder.rb +83 -0
  127. data/lib/archunit/slices/fluentapi/slices.rb +29 -0
  128. data/lib/archunit/slices/projection/slice_projection.rb +76 -0
  129. data/lib/archunit/slices/projection/slicing_projections.rb +98 -0
  130. data/lib/archunit/slices/uml/export_diagram.rb +70 -0
  131. data/lib/archunit/slices/uml/parse_diagram.rb +53 -0
  132. data/lib/archunit/slices/uml/plant_uml_dependency.rb +25 -0
  133. data/lib/archunit/slices/uml/plant_uml_diagram.rb +44 -0
  134. data/lib/archunit/testing/assert_passes.rb +24 -0
  135. data/lib/archunit/testing/assertion_failure.rb +22 -0
  136. data/lib/archunit/testing/color_utils.rb +66 -0
  137. data/lib/archunit/testing/layer_violation_formatter.rb +26 -0
  138. data/lib/archunit/testing/metric_violation_formatter.rb +58 -0
  139. data/lib/archunit/testing/minitest_adapter.rb +33 -0
  140. data/lib/archunit/testing/result_factory.rb +93 -0
  141. data/lib/archunit/testing/rspec_adapter.rb +74 -0
  142. data/lib/archunit/testing/slice_violation_formatter.rb +25 -0
  143. data/lib/archunit/testing/test_result.rb +26 -0
  144. data/lib/archunit/testing/test_violation.rb +23 -0
  145. data/lib/archunit/testing/violation_factory.rb +138 -0
  146. data/lib/archunit/testing.rb +39 -0
  147. data/lib/archunit/version.rb +5 -0
  148. data/lib/archunit.rb +110 -0
  149. metadata +251 -0
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'pathname'
4
+ require_relative '../common/extraction/edge'
5
+ require_relative '../common/extraction/graph'
6
+ require_relative '../common/fluentapi/check_options'
7
+ require_relative 'enumerate_source_files'
8
+ require_relative 'extract_dependencies'
9
+ require_relative 'locate_project'
10
+
11
+ module ArchUnit
12
+ # Assembles the normalized dependency graph for a Ruby project.
13
+ module Extraction
14
+ GRAPH_CACHE_MUTEX = Mutex.new
15
+ GRAPH_INDEPENDENT_CHECK_OPTIONS = %i[allow_empty_tests logging clear_cache].freeze
16
+
17
+ module_function
18
+
19
+ def extract_graph(
20
+ locator = nil, exclude_patterns: nil, options: nil, working_directory: Dir.pwd
21
+ )
22
+ options = Common::FluentApi::CheckOptions.resolve(options)
23
+ root = Pathname.new(locate_project(locator, working_directory:))
24
+ patterns = resolve_exclude_patterns(exclude_patterns)
25
+ cache_key = graph_cache_key(root, exclude_patterns: patterns, options:)
26
+
27
+ GRAPH_CACHE_MUTEX.synchronize do
28
+ graph_cache.delete(cache_key) if options.clear_cache?
29
+ graph_cache[cache_key] ||= extract_graph_uncached(root, patterns)
30
+ end
31
+ end
32
+
33
+ def clear_graph_cache
34
+ GRAPH_CACHE_MUTEX.synchronize { graph_cache.clear }
35
+ nil
36
+ end
37
+
38
+ def extract_graph_uncached(root, exclude_patterns)
39
+ source_files = enumerate_source_files(root, exclude_patterns:)
40
+ edges = self_edges(source_files) + extract_dependencies_from(root, source_files)
41
+
42
+ Common::Extraction::Graph.new(merge_parallel_edges(edges))
43
+ end
44
+ private_class_method :extract_graph_uncached
45
+
46
+ def graph_cache_key(project_root, exclude_patterns:, options:)
47
+ [
48
+ project_root.to_s.tr('\\', '/').freeze,
49
+ exclude_patterns.sort.freeze,
50
+ graph_analysis_toggles(options)
51
+ ].freeze
52
+ end
53
+ private_class_method :graph_cache_key
54
+
55
+ def graph_analysis_toggles(options)
56
+ options.class.members.filter_map do |member|
57
+ next if GRAPH_INDEPENDENT_CHECK_OPTIONS.include?(member)
58
+
59
+ [member, options.public_send(member)].freeze
60
+ end.freeze
61
+ end
62
+ private_class_method :graph_analysis_toggles
63
+
64
+ def graph_cache
65
+ @graph_cache ||= {}
66
+ end
67
+ private_class_method :graph_cache
68
+
69
+ def self_edges(source_files)
70
+ source_files.map do |source|
71
+ Common::Extraction::Edge.new(source: source, target: source, external: false)
72
+ end
73
+ end
74
+ private_class_method :self_edges
75
+
76
+ def merge_parallel_edges(edges)
77
+ edges.group_by { |edge| [edge.source, edge.target] }.values.map do |parallel_edges|
78
+ first = parallel_edges.first
79
+ Common::Extraction::Edge.new(
80
+ source: first.source,
81
+ target: first.target,
82
+ external: first.external,
83
+ import_kinds: parallel_edges.flat_map(&:import_kinds)
84
+ )
85
+ end
86
+ end
87
+ private_class_method :merge_parallel_edges
88
+ end
89
+ end
@@ -0,0 +1,197 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'pathname'
4
+ require 'prism'
5
+ require_relative '../error/technical_error'
6
+ require_relative '../error/user_error'
7
+ require_relative 'resolve_import'
8
+ require_relative 'resolved_import'
9
+
10
+ module ArchUnit
11
+ # Parses Ruby source without executing it and resolves literal imports.
12
+ module Extraction
13
+ IGNORE_DIRECTIVE = /\A#\s*archunit:\s*ignore(?:\s+(?<modules>.+?))?\s*\z/
14
+
15
+ # Collects supported dependency calls from a Prism syntax tree.
16
+ class ImportVisitor < Prism::Visitor
17
+ CALL_KINDS = {
18
+ require: Common::Extraction::ImportKind::REQUIRE,
19
+ require_relative: Common::Extraction::ImportKind::REQUIRE_RELATIVE,
20
+ autoload: Common::Extraction::ImportKind::AUTOLOAD,
21
+ load: Common::Extraction::ImportKind::LOAD
22
+ }.freeze
23
+
24
+ attr_reader :imports
25
+
26
+ def initialize
27
+ super
28
+ @imports = []
29
+ end
30
+
31
+ def visit_call_node(node)
32
+ record_import(node)
33
+ super
34
+ end
35
+
36
+ private
37
+
38
+ def record_import(node)
39
+ import_kind = CALL_KINDS[node.name]
40
+ return unless import_kind && supported_receiver?(node, import_kind)
41
+
42
+ arguments = node.arguments&.arguments || []
43
+ argument = import_argument(arguments, import_kind)
44
+ return unless argument.is_a?(Prism::StringNode)
45
+ return unless valid_module_name?(argument.unescaped)
46
+
47
+ @imports << located_import(argument, import_kind, node)
48
+ end
49
+
50
+ def located_import(argument, import_kind, node)
51
+ [argument.unescaped, import_kind, node.location.start_line, node.location.end_line]
52
+ end
53
+
54
+ def valid_module_name?(module_name)
55
+ !module_name.empty? && !module_name.include?("\0")
56
+ end
57
+
58
+ def supported_receiver?(node, import_kind)
59
+ return true if node.receiver.nil?
60
+ if import_kind == Common::Extraction::ImportKind::AUTOLOAD
61
+ return constant_receiver?(node.receiver)
62
+ end
63
+
64
+ node.receiver.respond_to?(:full_name) &&
65
+ node.receiver.full_name.delete_prefix('::') == 'Kernel'
66
+ end
67
+
68
+ def constant_receiver?(receiver)
69
+ receiver.is_a?(Prism::ConstantReadNode) || receiver.is_a?(Prism::ConstantPathNode)
70
+ end
71
+
72
+ def import_argument(arguments, import_kind)
73
+ if import_kind == Common::Extraction::ImportKind::AUTOLOAD
74
+ return unless arguments.length == 2
75
+
76
+ arguments[1]
77
+ else
78
+ return unless arguments.length == 1
79
+
80
+ arguments[0]
81
+ end
82
+ end
83
+ end
84
+ private_constant :ImportVisitor
85
+
86
+ module_function
87
+
88
+ def extract_imports(source_file, project_root:)
89
+ source_path = import_source_path(source_file)
90
+ root = import_project_root(project_root)
91
+ parsed_imports(source_path).map do |located_import|
92
+ resolved_import(located_import, source_path, root)
93
+ end.freeze
94
+ rescue UserError
95
+ raise
96
+ rescue SystemCallError => e
97
+ raise TechnicalError, "could not parse Ruby source file: #{e.message}"
98
+ end
99
+
100
+ def parsed_imports(source_path)
101
+ parse_result = Prism.parse_file(source_path.to_s)
102
+ return [] unless parse_result.success?
103
+
104
+ visitor = ImportVisitor.new
105
+ visitor.visit(parse_result.value)
106
+ reject_ignored_imports(visitor.imports, parse_result.comments)
107
+ end
108
+ private_class_method :parsed_imports
109
+
110
+ def reject_ignored_imports(imports, comments)
111
+ directives = ignore_directives(comments)
112
+ imports.reject do |module_name, _import_kind, start_line, end_line|
113
+ directives.any? do |line, trailing, modules|
114
+ directive_applies?(line, trailing, start_line, end_line) &&
115
+ (modules.empty? || modules.any? { |name| ignored_module?(module_name, name) })
116
+ end
117
+ end
118
+ end
119
+ private_class_method :reject_ignored_imports
120
+
121
+ def ignore_directives(comments)
122
+ comments.filter_map do |comment|
123
+ match = IGNORE_DIRECTIVE.match(comment.location.slice)
124
+ next unless match
125
+
126
+ [comment.location.start_line, comment.trailing?, directive_modules(match[:modules])]
127
+ end
128
+ end
129
+ private_class_method :ignore_directives
130
+
131
+ def directive_applies?(line, trailing, start_line, end_line)
132
+ return line.between?(start_line, end_line) if trailing
133
+
134
+ start_line == line + 1
135
+ end
136
+ private_class_method :directive_applies?
137
+
138
+ def directive_modules(value)
139
+ return [] if value.nil?
140
+
141
+ value.split(/[\s,]+/).reject(&:empty?)
142
+ end
143
+ private_class_method :directive_modules
144
+
145
+ def ignored_module?(module_name, scoped_name)
146
+ module_name == scoped_name || module_name.start_with?("#{scoped_name}/")
147
+ end
148
+ private_class_method :ignored_module?
149
+
150
+ def resolved_import(located_import, source_path, root)
151
+ module_name, import_kind, line_number, = located_import
152
+ ResolvedImport.new(
153
+ module_name: module_name,
154
+ import_kind: import_kind,
155
+ line_number: line_number,
156
+ resolved_path: resolve_import_for(module_name, import_kind, source_path, root)
157
+ )
158
+ end
159
+ private_class_method :resolved_import
160
+
161
+ def resolve_import_for(module_name, import_kind, source_path, root)
162
+ resolve_import(
163
+ module_name,
164
+ source_file: source_path,
165
+ project_root: root,
166
+ import_kind: import_kind
167
+ )
168
+ end
169
+ private_class_method :resolve_import_for
170
+
171
+ def import_source_path(value)
172
+ path = extraction_path(value, 'source_file')
173
+ raise UserError, 'source_file must be an existing file' unless path.file?
174
+
175
+ path
176
+ end
177
+ private_class_method :import_source_path
178
+
179
+ def import_project_root(value)
180
+ path = extraction_path(value, 'project_root')
181
+ raise UserError, 'project_root must be an existing directory' unless path.directory?
182
+
183
+ path.realpath
184
+ end
185
+ private_class_method :import_project_root
186
+
187
+ def extraction_path(value, attribute)
188
+ value = value.to_path if value.respond_to?(:to_path)
189
+ unless value.is_a?(String) && !value.empty?
190
+ raise UserError, "#{attribute} must be a non-empty path"
191
+ end
192
+
193
+ Pathname.new(value).expand_path
194
+ end
195
+ private_class_method :extraction_path
196
+ end
197
+ end
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'pathname'
4
+ require_relative '../error/technical_error'
5
+ require_relative '../error/user_error'
6
+
7
+ module ArchUnit
8
+ # Ruby-specific project discovery and source extraction.
9
+ module Extraction
10
+ module_function
11
+
12
+ def locate_project(locator = nil, working_directory: Dir.pwd)
13
+ working_root = existing_directory(working_directory, :working_directory)
14
+ root = project_root(locator, working_root)
15
+ normalize_path(root.realpath)
16
+ rescue UserError, TechnicalError
17
+ raise
18
+ rescue SystemCallError => e
19
+ raise TechnicalError, "could not locate project: #{e.message}"
20
+ end
21
+
22
+ def project_root(locator, working_root)
23
+ return detected_project_root(working_root) || working_root if locator.nil?
24
+
25
+ explicit_project_root(locator, working_root)
26
+ end
27
+ private_class_method :project_root
28
+
29
+ def explicit_project_root(locator, working_root)
30
+ path = pathname(locator, :locator)
31
+ path = working_root.join(path) unless path.absolute?
32
+
33
+ return path.realpath if path.directory?
34
+ return path.dirname.realpath if path.file? && project_marker_file?(path)
35
+
36
+ raise UserError, 'project locator must be a directory, Gemfile, or .gemspec file'
37
+ end
38
+ private_class_method :explicit_project_root
39
+
40
+ def detected_project_root(working_root)
41
+ working_root.ascend.find { |directory| project_marker?(directory) }
42
+ end
43
+ private_class_method :detected_project_root
44
+
45
+ def project_marker?(directory)
46
+ directory.join('Gemfile').file? || directory.children.any? do |entry|
47
+ entry.file? && entry.extname == '.gemspec'
48
+ end
49
+ end
50
+ private_class_method :project_marker?
51
+
52
+ def project_marker_file?(path)
53
+ path.basename.to_s == 'Gemfile' || path.extname == '.gemspec'
54
+ end
55
+ private_class_method :project_marker_file?
56
+
57
+ def existing_directory(value, attribute)
58
+ path = pathname(value, attribute).expand_path
59
+ raise UserError, "#{attribute} must be an existing directory" unless path.directory?
60
+
61
+ path.realpath
62
+ end
63
+ private_class_method :existing_directory
64
+
65
+ def pathname(value, attribute)
66
+ value = value.to_path if value.respond_to?(:to_path)
67
+ unless value.is_a?(String) && !value.empty?
68
+ raise UserError, "#{attribute} must be a non-empty path"
69
+ end
70
+
71
+ Pathname.new(value)
72
+ end
73
+ private_class_method :pathname
74
+
75
+ def normalize_path(path)
76
+ path.to_s.tr('\\', '/')
77
+ end
78
+ private_class_method :normalize_path
79
+ end
80
+ end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'pathname'
4
+ require_relative '../common/extraction/import_kind'
5
+
6
+ module ArchUnit
7
+ # Resolves Ruby dependency names using Ruby's own feature resolver.
8
+ module Extraction
9
+ module_function
10
+
11
+ def resolve_import(module_name, source_file:, project_root:, import_kind:)
12
+ roots = import_search_roots(source_file, project_root, import_kind)
13
+ resolved = if import_kind == Common::Extraction::ImportKind::LOAD
14
+ resolve_load(module_name, roots, import_kind)
15
+ else
16
+ resolve_feature(module_name, roots, import_kind)
17
+ end
18
+
19
+ resolved&.tr('\\', '/')&.freeze
20
+ end
21
+
22
+ def import_search_roots(source_file, project_root, import_kind)
23
+ if import_kind == Common::Extraction::ImportKind::REQUIRE_RELATIVE
24
+ [File.dirname(File.expand_path(source_file))]
25
+ else
26
+ root = File.expand_path(project_root)
27
+ [File.join(root, 'lib'), root]
28
+ end
29
+ end
30
+ private_class_method :import_search_roots
31
+
32
+ def resolve_feature(module_name, roots, import_kind)
33
+ feature_candidates(module_name, roots).each do |candidate|
34
+ resolution = $LOAD_PATH.resolve_feature_path(candidate)
35
+ return resolution.last if resolution
36
+ end
37
+
38
+ return if import_kind == Common::Extraction::ImportKind::REQUIRE_RELATIVE
39
+
40
+ $LOAD_PATH.resolve_feature_path(module_name)&.last
41
+ end
42
+ private_class_method :resolve_feature
43
+
44
+ def resolve_load(module_name, roots, import_kind)
45
+ load_candidates(module_name, roots).find { |candidate| File.file?(candidate) } ||
46
+ resolve_load_from_global_path(module_name, import_kind)
47
+ end
48
+ private_class_method :resolve_load
49
+
50
+ def resolve_load_from_global_path(module_name, import_kind)
51
+ return if import_kind == Common::Extraction::ImportKind::REQUIRE_RELATIVE
52
+ return if Pathname.new(module_name).absolute?
53
+
54
+ $LOAD_PATH.lazy
55
+ .map { |directory| File.expand_path(module_name, directory) }
56
+ .find { |candidate| File.file?(candidate) }
57
+ end
58
+ private_class_method :resolve_load_from_global_path
59
+
60
+ def feature_candidates(module_name, roots)
61
+ path = Pathname.new(module_name)
62
+ return [path.expand_path.to_s] if path.absolute?
63
+
64
+ roots.map { |root| File.expand_path(module_name, root) }.uniq
65
+ end
66
+ private_class_method :feature_candidates
67
+
68
+ def load_candidates(module_name, roots)
69
+ path = Pathname.new(module_name)
70
+ return [path.expand_path.to_s] if path.absolute?
71
+
72
+ roots.map { |root| File.expand_path(module_name, root) }.uniq
73
+ end
74
+ private_class_method :load_candidates
75
+ end
76
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../common/extraction/import_kind'
4
+
5
+ module ArchUnit
6
+ module Extraction
7
+ ResolvedImport = Data.define(:module_name, :import_kind, :line_number, :resolved_path) do
8
+ def initialize(module_name:, import_kind:, line_number:, resolved_path: nil)
9
+ validate_module_name(module_name)
10
+ validate_import_kind(import_kind)
11
+ validate_line_number(line_number)
12
+ validate_resolved_path(resolved_path)
13
+
14
+ module_name = module_name.dup.freeze
15
+ resolved_path = resolved_path&.tr('\\', '/')&.freeze
16
+ super
17
+ end
18
+
19
+ private
20
+
21
+ def validate_module_name(value)
22
+ return if value.is_a?(String) && !value.empty?
23
+
24
+ raise ArgumentError, 'module_name must be a non-empty String'
25
+ end
26
+
27
+ def validate_import_kind(value)
28
+ return if Common::Extraction::ImportKind.valid?(value)
29
+
30
+ raise ArgumentError, "unknown import kind: #{value.inspect}"
31
+ end
32
+
33
+ def validate_line_number(value)
34
+ return if value.is_a?(Integer) && value.positive?
35
+
36
+ raise ArgumentError, 'line_number must be a positive Integer'
37
+ end
38
+
39
+ def validate_resolved_path(value)
40
+ return if value.nil? || (value.is_a?(String) && !value.empty?)
41
+
42
+ raise ArgumentError, 'resolved_path must be nil or a non-empty String'
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../../common/assertion/violation'
4
+ require_relative '../extraction/file_info'
5
+
6
+ module ArchUnit
7
+ module Files
8
+ # Pure assertions over user-defined source-file predicates.
9
+ module Assertion
10
+ # Data describing a file that disagrees with a custom predicate.
11
+ class CustomFileViolation < Common::Assertion::Violation
12
+ attr_reader :file_info, :message, :is_negated
13
+
14
+ def initialize(file_info:, message:, is_negated: false)
15
+ @file_info = file_info_value(file_info)
16
+ @message = message_value(message)
17
+ @is_negated = boolean_value(is_negated)
18
+ super()
19
+ end
20
+
21
+ def negated?
22
+ is_negated
23
+ end
24
+
25
+ def ==(other)
26
+ other.is_a?(self.class) &&
27
+ file_info == other.file_info &&
28
+ message == other.message &&
29
+ is_negated == other.is_negated
30
+ end
31
+ alias eql? ==
32
+
33
+ def hash
34
+ [self.class, file_info, message, is_negated].hash
35
+ end
36
+
37
+ private
38
+
39
+ def file_info_value(value)
40
+ return value if value.is_a?(Files::Extraction::FileInfo)
41
+
42
+ raise ArgumentError, 'file_info must be a FileInfo'
43
+ end
44
+
45
+ def message_value(value)
46
+ return value.dup.freeze if value.is_a?(String) && !value.strip.empty?
47
+
48
+ raise ArgumentError, 'message must be a non-empty String'
49
+ end
50
+
51
+ def boolean_value(value)
52
+ return value if [true, false].include?(value)
53
+
54
+ raise ArgumentError, 'is_negated must be true or false'
55
+ end
56
+ end
57
+
58
+ module_function
59
+
60
+ def gather_custom_file_violations(file_infos, condition, message, is_negated:)
61
+ validate_custom_arguments(file_infos, condition, message, is_negated)
62
+
63
+ file_infos.filter_map do |file_info|
64
+ result = condition.call(file_info)
65
+ unless [true, false].include?(result)
66
+ raise ArgumentError, 'condition must return true or false'
67
+ end
68
+ next unless is_negated ? result : !result
69
+
70
+ CustomFileViolation.new(file_info:, message:, is_negated:)
71
+ end
72
+ end
73
+
74
+ def validate_custom_arguments(file_infos, condition, message, is_negated)
75
+ unless file_infos.is_a?(Array) && file_infos.all?(Files::Extraction::FileInfo)
76
+ raise ArgumentError, 'file_infos must be an Array of FileInfo values'
77
+ end
78
+ raise ArgumentError, 'condition must be callable' unless condition.respond_to?(:call)
79
+ unless message.is_a?(String) && !message.strip.empty?
80
+ raise ArgumentError, 'message must be a non-empty String'
81
+ end
82
+ return if [true, false].include?(is_negated)
83
+
84
+ raise ArgumentError, 'is_negated must be true or false'
85
+ end
86
+ private_class_method :validate_custom_arguments
87
+ end
88
+ end
89
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../../common/assertion/violation'
4
+ require_relative '../../common/projection/projected_edge'
5
+
6
+ module ArchUnit
7
+ module Files
8
+ # Pure file-rule assertion functions and violation values.
9
+ module Assertion
10
+ # Data describing one closed circular dependency path.
11
+ class CycleViolation < Common::Assertion::Violation
12
+ attr_reader :cycle, :path
13
+
14
+ def initialize(cycle:)
15
+ @cycle = immutable_cycle(cycle)
16
+ @path = [cycle.first.source_label, *cycle.map(&:target_label)].freeze
17
+ super()
18
+ end
19
+
20
+ def ==(other)
21
+ other.is_a?(self.class) && cycle == other.cycle
22
+ end
23
+ alias eql? ==
24
+
25
+ def hash
26
+ [self.class, cycle].hash
27
+ end
28
+
29
+ private
30
+
31
+ def immutable_cycle(values)
32
+ unless values.is_a?(Array) && !values.empty? && values.all?(Common::Projection::ProjectedEdge)
33
+ raise ArgumentError, 'cycle must be a non-empty Array of ProjectedEdge values'
34
+ end
35
+ unless closed_cycle?(values)
36
+ raise ArgumentError, 'cycle edges must form a contiguous closed path'
37
+ end
38
+
39
+ values.dup.freeze
40
+ end
41
+
42
+ def closed_cycle?(edges)
43
+ edges.each_cons(2).all? do |current, following|
44
+ current.target_label == following.source_label
45
+ end && edges.last.target_label == edges.first.source_label
46
+ end
47
+ end
48
+
49
+ module_function
50
+
51
+ def gather_cycle_violations(cycles)
52
+ raise ArgumentError, 'cycles must be an Array' unless cycles.is_a?(Array)
53
+
54
+ cycles.map { |cycle| CycleViolation.new(cycle:) }
55
+ end
56
+ end
57
+ end
58
+ end