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,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'mapped_edge'
4
+
5
+ module ArchUnit
6
+ module Common
7
+ # Reusable edge-to-label mapping functions for shared graph projections.
8
+ module Projection
9
+ module_function
10
+
11
+ # Maps every non-self edge without changing its labels.
12
+ def per_edge
13
+ lambda do |edge|
14
+ next if edge.source == edge.target
15
+
16
+ map_identity(edge)
17
+ end
18
+ end
19
+
20
+ # Maps non-self edges whose targets are part of the extracted project.
21
+ def per_internal_edge
22
+ lambda do |edge|
23
+ next if edge.external || edge.source == edge.target
24
+
25
+ map_identity(edge)
26
+ end
27
+ end
28
+
29
+ # Maps non-self edges whose targets are outside the extracted project.
30
+ def per_external_edge
31
+ lambda do |edge|
32
+ next unless edge.external
33
+ next if edge.source == edge.target
34
+
35
+ map_identity(edge)
36
+ end
37
+ end
38
+
39
+ # Maps an edge without filtering, including source-file self-edges.
40
+ def identity
41
+ ->(edge) { map_identity(edge) }
42
+ end
43
+
44
+ def map_identity(edge)
45
+ MappedEdge.new(source_label: edge.source, target_label: edge.target)
46
+ end
47
+ private_class_method :map_identity
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ArchUnit
4
+ module Common
5
+ module Projection
6
+ # The labels produced when a raw dependency edge is mapped into a domain view.
7
+ MappedEdge = Data.define(:source_label, :target_label) do
8
+ def initialize(source_label:, target_label:)
9
+ source_label = immutable_label(source_label, :source_label)
10
+ target_label = immutable_label(target_label, :target_label)
11
+ super
12
+ end
13
+
14
+ private
15
+
16
+ def immutable_label(value, attribute)
17
+ unless value.is_a?(String) && !value.empty?
18
+ raise ArgumentError, "#{attribute} must be a non-empty String"
19
+ end
20
+
21
+ value.dup.freeze
22
+ end
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'cycles/johnson_cycles'
4
+ require_relative 'edge_projections'
5
+ require_relative 'project_edges'
6
+ require_relative 'projected_edge'
7
+
8
+ module ArchUnit
9
+ module Common
10
+ # Cycle projection over immutable, evidence-retaining projected edges.
11
+ module Projection
12
+ module_function
13
+
14
+ def project_internal_cycles(graph)
15
+ project_cycles(project_edges(graph, per_internal_edge))
16
+ end
17
+
18
+ def project_cycles(edges)
19
+ projected_edges = normalize_projected_edges(edges)
20
+ label_ids = label_ids_for(projected_edges)
21
+ edges_by_ids = index_edges(projected_edges, label_ids)
22
+ paths = Cycles::JohnsonCycles.call(adjacency_for(edges_by_ids))
23
+ cycles_from_paths(paths, edges_by_ids)
24
+ end
25
+
26
+ def cycles_from_paths(paths, edges_by_ids)
27
+ paths.map do |path|
28
+ path.each_index.map do |index|
29
+ source = path.fetch(index)
30
+ target = path.fetch((index + 1) % path.length)
31
+ edges_by_ids.fetch([source, target])
32
+ end.freeze
33
+ end.freeze
34
+ end
35
+
36
+ def normalize_projected_edges(edges)
37
+ unless edges.respond_to?(:each)
38
+ raise ArgumentError, 'edges must be an enumerable of ProjectedEdge values'
39
+ end
40
+
41
+ groups = {}
42
+ edges.each { |edge| add_to_projection_groups(groups, edge) }
43
+ projected_edges_from(groups)
44
+ end
45
+ private_class_method :normalize_projected_edges
46
+
47
+ def add_to_projection_groups(groups, edge)
48
+ unless edge.is_a?(ProjectedEdge)
49
+ raise ArgumentError, 'edges must contain only ProjectedEdge values'
50
+ end
51
+ return if edge.source_label == edge.target_label
52
+
53
+ key = [edge.source_label, edge.target_label]
54
+ (groups[key] ||= []).concat(edge.cumulated_edges)
55
+ end
56
+ private_class_method :add_to_projection_groups
57
+
58
+ def projected_edges_from(groups)
59
+ groups.map do |(source_label, target_label), raw_edges|
60
+ ProjectedEdge.new(
61
+ source_label:, target_label:, cumulated_edges: raw_edges.uniq
62
+ )
63
+ end.freeze
64
+ end
65
+ private_class_method :projected_edges_from
66
+
67
+ def label_ids_for(edges)
68
+ labels = edges.flat_map { |edge| [edge.source_label, edge.target_label] }.uniq
69
+ labels.each_with_index.to_h.freeze
70
+ end
71
+ private_class_method :label_ids_for
72
+
73
+ def index_edges(edges, label_ids)
74
+ edges.to_h do |edge|
75
+ key = [label_ids.fetch(edge.source_label), label_ids.fetch(edge.target_label)]
76
+ [key, edge]
77
+ end.freeze
78
+ end
79
+ private_class_method :index_edges
80
+
81
+ def adjacency_for(edges_by_ids)
82
+ adjacency = Hash.new { |hash, vertex| hash[vertex] = [] }
83
+ edges_by_ids.each_key do |source, target|
84
+ adjacency[source] << target
85
+ adjacency[target] ||= []
86
+ end
87
+ adjacency.transform_values { |targets| targets.uniq.freeze }.freeze
88
+ end
89
+ private_class_method :adjacency_for
90
+ end
91
+ end
92
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../extraction/edge'
4
+ require_relative 'mapped_edge'
5
+ require_relative 'projected_edge'
6
+
7
+ module ArchUnit
8
+ module Common
9
+ # Pure functions and immutable values for relabeling dependency graphs.
10
+ module Projection
11
+ module_function
12
+
13
+ # Applies a callable Edge -> MappedEdge/nil hook and cumulates equal labels.
14
+ def project_edges(graph, mapper = nil, &block)
15
+ map_function = resolve_map_function(mapper, block)
16
+ projection_groups(graph, map_function).map do |(source_label, target_label), edges|
17
+ ProjectedEdge.new(source_label:, target_label:, cumulated_edges: edges)
18
+ end.freeze
19
+ end
20
+
21
+ def projection_groups(graph, map_function)
22
+ groups = {}
23
+
24
+ each_edge(graph) do |edge|
25
+ mapped = map_function.call(edge)
26
+ next if mapped.nil?
27
+
28
+ validate_mapped_edge(mapped)
29
+ key = [mapped.source_label, mapped.target_label]
30
+ (groups[key] ||= []) << edge
31
+ end
32
+ groups
33
+ end
34
+ private_class_method :projection_groups
35
+
36
+ def resolve_map_function(mapper, block)
37
+ raise ArgumentError, 'provide a mapper or a block, not both' if mapper && block
38
+
39
+ map_function = mapper || block
40
+ return map_function if map_function.respond_to?(:call)
41
+
42
+ raise ArgumentError, 'mapper must be callable'
43
+ end
44
+ private_class_method :resolve_map_function
45
+
46
+ def each_edge(graph)
47
+ unless graph.respond_to?(:each)
48
+ raise ArgumentError, 'graph must be an enumerable of Edge values'
49
+ end
50
+
51
+ graph.each do |edge|
52
+ unless edge.is_a?(Extraction::Edge)
53
+ raise ArgumentError, 'graph must contain only Edge values'
54
+ end
55
+
56
+ yield edge
57
+ end
58
+ end
59
+ private_class_method :each_edge
60
+
61
+ def validate_mapped_edge(value)
62
+ return if value.is_a?(MappedEdge)
63
+
64
+ raise ArgumentError, 'mapper must return a MappedEdge or nil'
65
+ end
66
+ private_class_method :validate_mapped_edge
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../extraction/edge'
4
+ require_relative 'projected_node'
5
+ require_relative 'project_edges'
6
+
7
+ module ArchUnit
8
+ module Common
9
+ # Pure functions and immutable values for relabeling dependency graphs.
10
+ module Projection
11
+ module_function
12
+
13
+ def project_to_nodes(graph, include_externals: false)
14
+ validate_include_externals(include_externals)
15
+ labels, incoming, outgoing = collect_node_edges(graph, include_externals)
16
+
17
+ labels.keys.sort.map do |label|
18
+ ProjectedNode.new(label:, incoming: incoming[label], outgoing: outgoing[label])
19
+ end.freeze
20
+ end
21
+
22
+ def collect_node_edges(graph, include_externals)
23
+ incoming = Hash.new { |hash, label| hash[label] = [] }
24
+ outgoing = Hash.new { |hash, label| hash[label] = [] }
25
+ labels = {}
26
+
27
+ each_edge(graph) do |edge|
28
+ collect_node_edge(edge, labels, incoming, outgoing, include_externals)
29
+ end
30
+ [labels, incoming, outgoing]
31
+ end
32
+ private_class_method :collect_node_edges
33
+
34
+ def collect_node_edge(edge, labels, incoming, outgoing, include_externals)
35
+ labels[edge.source] = true
36
+ return if self_edge?(edge)
37
+
38
+ outgoing[edge.source] << edge
39
+ return if edge.external && !include_externals
40
+
41
+ labels[edge.target] = true
42
+ incoming[edge.target] << edge
43
+ end
44
+ private_class_method :collect_node_edge
45
+
46
+ def self_edge?(edge)
47
+ edge.source == edge.target
48
+ end
49
+ private_class_method :self_edge?
50
+
51
+ def validate_include_externals(value)
52
+ return if [true, false].include?(value)
53
+
54
+ raise ArgumentError, 'include_externals must be true or false'
55
+ end
56
+ private_class_method :validate_include_externals
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../extraction/edge'
4
+ require_relative 'mapped_edge'
5
+
6
+ module ArchUnit
7
+ module Common
8
+ module Projection
9
+ # A labeled dependency that retains every raw edge collapsed into it.
10
+ ProjectedEdge = Data.define(:source_label, :target_label, :cumulated_edges) do
11
+ def initialize(source_label:, target_label:, cumulated_edges:)
12
+ mapped = MappedEdge.new(source_label:, target_label:)
13
+ cumulated_edges = immutable_edges(cumulated_edges)
14
+
15
+ super(
16
+ source_label: mapped.source_label,
17
+ target_label: mapped.target_label,
18
+ cumulated_edges:
19
+ )
20
+ end
21
+
22
+ private
23
+
24
+ def immutable_edges(values)
25
+ edges = Array(values).dup
26
+ unless edges.any? && edges.all?(Extraction::Edge)
27
+ raise ArgumentError, 'cumulated_edges must contain at least one Edge'
28
+ end
29
+
30
+ edges.freeze
31
+ end
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../extraction/edge'
4
+
5
+ module ArchUnit
6
+ module Common
7
+ module Projection
8
+ # A graph node with its incoming and outgoing raw dependency edges.
9
+ ProjectedNode = Data.define(:label, :incoming, :outgoing) do
10
+ def initialize(label:, incoming: [], outgoing: [])
11
+ label = immutable_label(label)
12
+ incoming = immutable_edges(incoming, :incoming)
13
+ outgoing = immutable_edges(outgoing, :outgoing)
14
+ super
15
+ end
16
+
17
+ private
18
+
19
+ def immutable_label(value)
20
+ return value.dup.freeze if value.is_a?(String) && !value.empty?
21
+
22
+ raise ArgumentError, 'label must be a non-empty String'
23
+ end
24
+
25
+ def immutable_edges(values, attribute)
26
+ edges = Array(values).dup
27
+ unless edges.all?(Extraction::Edge)
28
+ raise ArgumentError, "#{attribute} must contain only Edge values"
29
+ end
30
+
31
+ edges.freeze
32
+ end
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,96 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'filter'
4
+ require_relative 'pattern'
5
+
6
+ module ArchUnit
7
+ module Common
8
+ # The single construction point for user patterns and their matching targets.
9
+ class RegexFactory
10
+ TARGETED_EXCLUSIONS = {
11
+ in_path: :path,
12
+ in_folder: :path_without_filename,
13
+ with_name: :filename,
14
+ for_classes_matching: :classname
15
+ }.freeze
16
+ DEFAULT_EXCLUSION_TARGETS = {
17
+ filename: %i[filename],
18
+ path: %i[path filename],
19
+ path_without_filename: %i[path path_without_filename filename],
20
+ classname: %i[classname]
21
+ }.freeze
22
+
23
+ class << self
24
+ def filename_matcher(pattern, except: nil)
25
+ create_matcher(pattern, :filename, except:)
26
+ end
27
+
28
+ def folder_matcher(pattern, except: nil)
29
+ create_matcher(pattern, :path_without_filename, except:)
30
+ end
31
+
32
+ def path_matcher(pattern, except: nil)
33
+ create_matcher(pattern, :path, except:)
34
+ end
35
+
36
+ def classname_matcher(pattern, except: nil)
37
+ create_matcher(pattern, :classname, except:)
38
+ end
39
+
40
+ def exact_file_matcher(file_path, except: nil)
41
+ unless file_path.is_a?(String) && !file_path.empty?
42
+ raise ArgumentError, 'file_path must be a non-empty String'
43
+ end
44
+
45
+ normalized_path = file_path.tr('\\', '/')
46
+ regexp = Regexp.new("\\A#{Regexp.escape(normalized_path)}\\z")
47
+ exclusions = create_exclusion_filters(:path, except)
48
+ Filter.new(regexp:, target: :path, matching: :exact, exclusions:)
49
+ end
50
+
51
+ private
52
+
53
+ def create_matcher(pattern, target, except:)
54
+ exclusions = create_exclusion_filters(target, except)
55
+ Filter.new(regexp: Pattern.compile(pattern), target:, exclusions:)
56
+ end
57
+
58
+ def create_exclusion_filters(parent_target, exclusion)
59
+ return [] if exclusion.nil?
60
+ return targeted_exclusion_filters(exclusion) if exclusion.is_a?(Hash)
61
+
62
+ exclusion_patterns(exclusion).flat_map do |pattern|
63
+ DEFAULT_EXCLUSION_TARGETS.fetch(parent_target).map do |target|
64
+ simple_filter(pattern, target)
65
+ end
66
+ end
67
+ end
68
+
69
+ def targeted_exclusion_filters(exclusion)
70
+ unknown = exclusion.keys - TARGETED_EXCLUSIONS.keys
71
+ unless unknown.empty?
72
+ raise ArgumentError, "unknown targeted exclusion: #{unknown.first.inspect}"
73
+ end
74
+
75
+ exclusion.flat_map do |name, patterns|
76
+ exclusion_patterns(patterns).map do |pattern|
77
+ simple_filter(pattern, TARGETED_EXCLUSIONS.fetch(name))
78
+ end
79
+ end
80
+ end
81
+
82
+ def exclusion_patterns(value)
83
+ values = value.is_a?(Array) ? value : [value]
84
+ values.each { |pattern| Pattern.compile(pattern) }
85
+ values
86
+ end
87
+
88
+ def simple_filter(pattern, target)
89
+ Filter.new(regexp: Pattern.compile(pattern), target:)
90
+ end
91
+ end
92
+
93
+ private_class_method :new
94
+ end
95
+ end
96
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ArchUnit
4
+ # Raised when the library or its execution environment cannot complete an operation.
5
+ class TechnicalError < StandardError; end
6
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ArchUnit
4
+ # Raised when a caller uses the ArchUnit API incorrectly.
5
+ class UserError < StandardError; end
6
+ end
@@ -0,0 +1,131 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'find'
4
+ require 'pathname'
5
+ require_relative '../error/technical_error'
6
+ require_relative '../error/user_error'
7
+
8
+ module ArchUnit
9
+ # Ruby-specific project discovery and source extraction.
10
+ module Extraction
11
+ DEFAULT_EXCLUDED_DIRECTORIES = %w[
12
+ .bundle
13
+ .cache
14
+ .git
15
+ .hg
16
+ .ruby-lsp
17
+ .svn
18
+ .yardoc
19
+ build
20
+ coverage
21
+ dist
22
+ log
23
+ node_modules
24
+ pkg
25
+ tmp
26
+ vendor
27
+ ].freeze
28
+
29
+ module_function
30
+
31
+ def enumerate_source_files(project_root, exclude_patterns: nil)
32
+ root = source_root(project_root)
33
+ patterns = resolve_exclude_patterns(exclude_patterns)
34
+ source_files_under(root, patterns).sort.freeze
35
+ rescue UserError, TechnicalError
36
+ raise
37
+ rescue SystemCallError => e
38
+ raise TechnicalError, "could not enumerate Ruby source files: #{e.message}"
39
+ end
40
+
41
+ def source_files_under(root, exclude_patterns)
42
+ 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
47
+
48
+ if ruby_source_file?(entry) && !excluded_path?(entry, root, exclude_patterns)
49
+ relative_identifier(entry, root).freeze
50
+ end
51
+ end
52
+ end
53
+ private_class_method :source_files_under
54
+
55
+ def resolve_exclude_patterns(value)
56
+ patterns = value.nil? ? DEFAULT_EXCLUDED_DIRECTORIES : value
57
+ validate_exclude_patterns(patterns)
58
+
59
+ normalized = patterns.map { |pattern| normalize_exclude_pattern(pattern) }
60
+ raise_invalid_exclude_patterns if normalized.any?(&:empty?)
61
+
62
+ normalized.uniq.freeze
63
+ end
64
+ private_class_method :resolve_exclude_patterns
65
+
66
+ def validate_exclude_patterns(patterns)
67
+ return if patterns.is_a?(Array) && patterns.all? { |pattern| valid_exclude_pattern?(pattern) }
68
+
69
+ raise_invalid_exclude_patterns
70
+ end
71
+ private_class_method :validate_exclude_patterns
72
+
73
+ def raise_invalid_exclude_patterns
74
+ raise UserError, 'exclude_patterns must be an Array of non-empty Strings'
75
+ end
76
+ private_class_method :raise_invalid_exclude_patterns
77
+
78
+ def valid_exclude_pattern?(pattern)
79
+ pattern.is_a?(String) && !pattern.empty?
80
+ end
81
+ private_class_method :valid_exclude_pattern?
82
+
83
+ def normalize_exclude_pattern(pattern)
84
+ pattern.tr('\\', '/').delete_prefix('./').delete_suffix('/').freeze
85
+ end
86
+ private_class_method :normalize_exclude_pattern
87
+
88
+ def ruby_source_file?(entry)
89
+ File.file?(entry) && File.extname(entry) == '.rb'
90
+ end
91
+ private_class_method :ruby_source_file?
92
+
93
+ def source_root(value)
94
+ value = value.to_path if value.respond_to?(:to_path)
95
+ unless value.is_a?(String) && !value.empty?
96
+ raise UserError, 'project_root must be a non-empty path'
97
+ end
98
+
99
+ root = Pathname.new(value).expand_path
100
+ raise UserError, 'project_root must be an existing directory' unless root.directory?
101
+
102
+ root.realpath
103
+ end
104
+ private_class_method :source_root
105
+
106
+ def excluded_path?(entry, root, exclude_patterns)
107
+ return false if entry == root.to_s
108
+
109
+ relative = relative_identifier(entry, root)
110
+ exclude_patterns.any? do |pattern|
111
+ exclude_pattern_matches?(pattern, relative, File.basename(entry))
112
+ end
113
+ end
114
+ private_class_method :excluded_path?
115
+
116
+ def exclude_pattern_matches?(pattern, relative, basename)
117
+ flags = File::FNM_DOTMATCH | File::FNM_PATHNAME
118
+ anchored = pattern.start_with?('/')
119
+ candidate = pattern.delete_prefix('/')
120
+ target = anchored || candidate.include?('/') ? relative : basename
121
+
122
+ File.fnmatch?(candidate, target, flags)
123
+ end
124
+ private_class_method :exclude_pattern_matches?
125
+
126
+ def relative_identifier(entry, root)
127
+ Pathname.new(entry).relative_path_from(root).to_s.tr('\\', '/')
128
+ end
129
+ private_class_method :relative_identifier
130
+ end
131
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'pathname'
4
+ require_relative '../common/extraction/edge'
5
+ require_relative 'enumerate_source_files'
6
+ require_relative 'extract_imports'
7
+
8
+ module ArchUnit
9
+ # Turns statically resolvable Ruby imports into dependency edges.
10
+ module Extraction
11
+ module_function
12
+
13
+ def extract_dependencies(project_root, exclude_patterns: nil)
14
+ source_files = enumerate_source_files(project_root, exclude_patterns:)
15
+ root = Pathname.new(path_value(project_root)).expand_path.realpath
16
+ extract_dependencies_from(root, source_files)
17
+ end
18
+
19
+ def extract_dependencies_from(root, source_files)
20
+ internal_targets = internal_target_index(root, source_files)
21
+
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
27
+ end
28
+ private_class_method :extract_dependencies_from
29
+
30
+ def path_value(value)
31
+ value.respond_to?(:to_path) ? value.to_path : value
32
+ end
33
+ private_class_method :path_value
34
+
35
+ def internal_target_index(root, source_files)
36
+ source_files.to_h do |identifier|
37
+ [canonical_path(root.join(identifier)), identifier]
38
+ end
39
+ end
40
+ private_class_method :internal_target_index
41
+
42
+ def dependency_edge(source, import, internal_targets)
43
+ target = import.resolved_path && internal_targets[canonical_path(import.resolved_path)]
44
+
45
+ Common::Extraction::Edge.new(
46
+ source: source,
47
+ target: target || import.module_name,
48
+ external: target.nil?,
49
+ import_kinds: [import.import_kind]
50
+ )
51
+ end
52
+ private_class_method :dependency_edge
53
+
54
+ def canonical_path(path)
55
+ Pathname.new(path).realpath.to_s.tr('\\', '/')
56
+ rescue SystemCallError
57
+ Pathname.new(path).expand_path.to_s.tr('\\', '/')
58
+ end
59
+ private_class_method :canonical_path
60
+ end
61
+ end