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,116 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../../common/assertion/violation'
4
+ require_relative '../../common/filter'
5
+ require_relative '../../common/pattern_matching'
6
+ require_relative '../../common/projection/projected_edge'
7
+
8
+ module ArchUnit
9
+ module Files
10
+ # Pure assertions over dependencies from project files to external modules.
11
+ module Assertion
12
+ # Data describing an external module dependency that disagrees with a rule.
13
+ class ExternalModuleDependencyViolation < Common::Assertion::Violation
14
+ attr_reader :dependency, :is_negated
15
+
16
+ def initialize(dependency:, is_negated: false)
17
+ @dependency = projected_edge(dependency)
18
+ @is_negated = boolean_value(is_negated)
19
+ super()
20
+ end
21
+
22
+ def negated?
23
+ is_negated
24
+ end
25
+
26
+ def ==(other)
27
+ other.is_a?(self.class) &&
28
+ dependency == other.dependency &&
29
+ is_negated == other.is_negated
30
+ end
31
+ alias eql? ==
32
+
33
+ def hash
34
+ [self.class, dependency, is_negated].hash
35
+ end
36
+
37
+ private
38
+
39
+ def projected_edge(value)
40
+ return value if value.is_a?(Common::Projection::ProjectedEdge)
41
+
42
+ raise ArgumentError, 'dependency must be a ProjectedEdge'
43
+ end
44
+
45
+ def boolean_value(value)
46
+ return value if [true, false].include?(value)
47
+
48
+ raise ArgumentError, 'is_negated must be true or false'
49
+ end
50
+ end
51
+
52
+ module_function
53
+
54
+ def gather_external_module_dependency_violations(
55
+ edges, subject_filters, module_filters, is_negated:
56
+ )
57
+ validate_external_dependency_arguments(
58
+ edges, subject_filters, module_filters, is_negated
59
+ )
60
+
61
+ edges.filter_map do |edge|
62
+ external_dependency_violation(
63
+ edge, subject_filters, module_filters, is_negated
64
+ )
65
+ end
66
+ end
67
+
68
+ def external_dependency_violation(edge, subject_filters, module_filters, is_negated)
69
+ return unless Common::PatternMatching.matches_all_patterns?(
70
+ edge.source_label, subject_filters
71
+ )
72
+
73
+ target_matches = Common::PatternMatching.matches_any_pattern?(
74
+ edge.target_label, module_filters
75
+ )
76
+ return unless is_negated ? target_matches : !target_matches
77
+
78
+ ExternalModuleDependencyViolation.new(dependency: edge, is_negated:)
79
+ end
80
+ private_class_method :external_dependency_violation
81
+
82
+ def validate_external_dependency_arguments(
83
+ edges, subject_filters, module_filters, is_negated
84
+ )
85
+ validate_external_projected_edges(edges)
86
+ validate_external_filters(subject_filters, :subject_filters, allow_empty: true)
87
+ validate_external_filters(module_filters, :module_filters, allow_empty: false)
88
+ return if [true, false].include?(is_negated)
89
+
90
+ raise ArgumentError, 'is_negated must be true or false'
91
+ end
92
+ private_class_method :validate_external_dependency_arguments
93
+
94
+ def validate_external_projected_edges(values)
95
+ return if values.is_a?(Array) && values.all?(Common::Projection::ProjectedEdge)
96
+
97
+ raise ArgumentError, 'edges must be an Array of ProjectedEdge values'
98
+ end
99
+ private_class_method :validate_external_projected_edges
100
+
101
+ def validate_external_filters(values, name, allow_empty:)
102
+ valid = values.is_a?(Array) && values.all?(Common::Filter)
103
+ valid &&= !values.empty? unless allow_empty
104
+ return if valid
105
+
106
+ requirement = if allow_empty
107
+ 'an Array of Filter values'
108
+ else
109
+ 'a non-empty Array of Filter values'
110
+ end
111
+ raise ArgumentError, "#{name} must be #{requirement}"
112
+ end
113
+ private_class_method :validate_external_filters
114
+ end
115
+ end
116
+ end
@@ -0,0 +1,110 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../../common/assertion/violation'
4
+ require_relative '../../common/filter'
5
+ require_relative '../../common/pattern_matching'
6
+ require_relative '../../common/projection/projected_edge'
7
+
8
+ module ArchUnit
9
+ module Files
10
+ # Pure assertions over dependencies between project files.
11
+ module Assertion
12
+ # Data describing an internal file dependency that disagrees with a rule.
13
+ class FileDependencyViolation < Common::Assertion::Violation
14
+ attr_reader :dependency, :is_negated
15
+
16
+ def initialize(dependency:, is_negated: false)
17
+ @dependency = projected_edge(dependency)
18
+ @is_negated = boolean_value(is_negated)
19
+ super()
20
+ end
21
+
22
+ def negated?
23
+ is_negated
24
+ end
25
+
26
+ def ==(other)
27
+ other.is_a?(self.class) &&
28
+ dependency == other.dependency &&
29
+ is_negated == other.is_negated
30
+ end
31
+ alias eql? ==
32
+
33
+ def hash
34
+ [self.class, dependency, is_negated].hash
35
+ end
36
+
37
+ private
38
+
39
+ def projected_edge(value)
40
+ return value if value.is_a?(Common::Projection::ProjectedEdge)
41
+
42
+ raise ArgumentError, 'dependency must be a ProjectedEdge'
43
+ end
44
+
45
+ def boolean_value(value)
46
+ return value if [true, false].include?(value)
47
+
48
+ raise ArgumentError, 'is_negated must be true or false'
49
+ end
50
+ end
51
+
52
+ module_function
53
+
54
+ def gather_file_dependency_violations(
55
+ edges, subject_filters, object_filters, is_negated:
56
+ )
57
+ validate_dependency_arguments(edges, subject_filters, object_filters, is_negated)
58
+
59
+ edges.filter_map do |edge|
60
+ dependency_violation(edge, subject_filters, object_filters, is_negated)
61
+ end
62
+ end
63
+
64
+ def dependency_violation(edge, subject_filters, object_filters, is_negated)
65
+ return unless Common::PatternMatching.matches_all_patterns?(
66
+ edge.source_label, subject_filters
67
+ )
68
+
69
+ target_matches = Common::PatternMatching.matches_all_patterns?(
70
+ edge.target_label, object_filters
71
+ )
72
+ return unless is_negated ? target_matches : !target_matches
73
+
74
+ FileDependencyViolation.new(dependency: edge, is_negated:)
75
+ end
76
+ private_class_method :dependency_violation
77
+
78
+ def validate_dependency_arguments(edges, subject_filters, object_filters, is_negated)
79
+ validate_projected_edges(edges)
80
+ validate_filters(subject_filters, :subject_filters, allow_empty: true)
81
+ validate_filters(object_filters, :object_filters, allow_empty: false)
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_dependency_arguments
87
+
88
+ def validate_projected_edges(values)
89
+ return if values.is_a?(Array) && values.all?(Common::Projection::ProjectedEdge)
90
+
91
+ raise ArgumentError, 'edges must be an Array of ProjectedEdge values'
92
+ end
93
+ private_class_method :validate_projected_edges
94
+
95
+ def validate_filters(values, name, allow_empty:)
96
+ valid = values.is_a?(Array) && values.all?(Common::Filter)
97
+ valid &&= !values.empty? unless allow_empty
98
+ return if valid
99
+
100
+ requirement = if allow_empty
101
+ 'an Array of Filter values'
102
+ else
103
+ 'a non-empty Array of Filter values'
104
+ end
105
+ raise ArgumentError, "#{name} must be #{requirement}"
106
+ end
107
+ private_class_method :validate_filters
108
+ end
109
+ end
110
+ end
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../../common/assertion/violation'
4
+ require_relative '../../common/filter'
5
+ require_relative '../../common/pattern_matching'
6
+ require_relative '../../common/projection/projected_node'
7
+
8
+ module ArchUnit
9
+ module Files
10
+ # Pure file-pattern assertion functions and violation values.
11
+ module Assertion
12
+ # Data describing a selected file that disagrees with a pattern predicate.
13
+ class FilePatternViolation < Common::Assertion::Violation
14
+ attr_reader :check_filter, :projected_node, :is_negated
15
+
16
+ def initialize(check_filter:, projected_node:, is_negated: false)
17
+ @check_filter = filter_value(check_filter)
18
+ @projected_node = node_value(projected_node)
19
+ @is_negated = boolean_value(is_negated)
20
+ super()
21
+ end
22
+
23
+ def negated?
24
+ is_negated
25
+ end
26
+
27
+ def ==(other)
28
+ other.is_a?(self.class) &&
29
+ check_filter == other.check_filter &&
30
+ projected_node == other.projected_node &&
31
+ is_negated == other.is_negated
32
+ end
33
+ alias eql? ==
34
+
35
+ def hash
36
+ [self.class, check_filter, projected_node, is_negated].hash
37
+ end
38
+
39
+ private
40
+
41
+ def filter_value(value)
42
+ return value if value.is_a?(Common::Filter)
43
+
44
+ raise ArgumentError, 'check_filter must be a Filter'
45
+ end
46
+
47
+ def node_value(value)
48
+ return value if value.is_a?(Common::Projection::ProjectedNode)
49
+
50
+ raise ArgumentError, 'projected_node must be a ProjectedNode'
51
+ end
52
+
53
+ def boolean_value(value)
54
+ return value if [true, false].include?(value)
55
+
56
+ raise ArgumentError, 'is_negated must be true or false'
57
+ end
58
+ end
59
+
60
+ module_function
61
+
62
+ def gather_matching_file_violations(nodes, check_filter, is_negated:)
63
+ validate_matching_arguments(nodes, check_filter, is_negated)
64
+
65
+ nodes.filter_map do |node|
66
+ matches = Common::PatternMatching.matches_pattern?(node.label, check_filter)
67
+ next unless is_negated ? matches : !matches
68
+
69
+ FilePatternViolation.new(check_filter:, projected_node: node, is_negated:)
70
+ end
71
+ end
72
+
73
+ def validate_matching_arguments(nodes, check_filter, is_negated)
74
+ unless nodes.is_a?(Array) && nodes.all?(Common::Projection::ProjectedNode)
75
+ raise ArgumentError, 'nodes must be an Array of ProjectedNode values'
76
+ end
77
+ unless check_filter.is_a?(Common::Filter)
78
+ raise ArgumentError, 'check_filter must be a Filter'
79
+ end
80
+ return if [true, false].include?(is_negated)
81
+
82
+ raise ArgumentError, 'is_negated must be true or false'
83
+ end
84
+ private_class_method :validate_matching_arguments
85
+ end
86
+ end
87
+ end
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'pathname'
4
+ require_relative '../../error/technical_error'
5
+ require_relative 'file_info'
6
+
7
+ module ArchUnit
8
+ module Files
9
+ # Ruby-specific descriptive data extraction for file rules.
10
+ module Extraction
11
+ module_function
12
+
13
+ def extract_file_info(project_root, relative_path)
14
+ root = project_directory(project_root)
15
+ identifier = normalized_identifier(relative_path)
16
+ source_path = source_path_within(root, identifier)
17
+ build_file_info(identifier, read_source(source_path))
18
+ rescue TechnicalError
19
+ raise
20
+ rescue SystemCallError => e
21
+ raise TechnicalError, "could not extract file information: #{e.message}"
22
+ end
23
+
24
+ def read_source(path)
25
+ File.binread(path).force_encoding(Encoding::UTF_8).scrub
26
+ end
27
+ private_class_method :read_source
28
+
29
+ def build_file_info(identifier, content)
30
+ extension = File.extname(identifier)
31
+ FileInfo.new(
32
+ path: identifier,
33
+ name: File.basename(identifier, extension),
34
+ extension:,
35
+ directory: directory_of(identifier),
36
+ content:,
37
+ lines_of_code: content.lines.count { |line| !line.strip.empty? }
38
+ )
39
+ end
40
+ private_class_method :build_file_info
41
+
42
+ def project_directory(value)
43
+ value = value.to_path if value.respond_to?(:to_path)
44
+ unless value.is_a?(String) && !value.empty?
45
+ raise ArgumentError, 'project_root must be a non-empty path'
46
+ end
47
+
48
+ root = Pathname.new(value).expand_path
49
+ raise TechnicalError, 'project_root must be an existing directory' unless root.directory?
50
+
51
+ root.realpath
52
+ end
53
+ private_class_method :project_directory
54
+
55
+ def normalized_identifier(value)
56
+ unless value.is_a?(String) && !value.empty?
57
+ raise ArgumentError, 'relative_path must be a non-empty String'
58
+ end
59
+
60
+ value.tr('\\', '/').freeze
61
+ end
62
+ private_class_method :normalized_identifier
63
+
64
+ def source_path_within(root, identifier)
65
+ path = root.join(identifier).cleanpath
66
+ relative = path.relative_path_from(root).to_s.tr('\\', '/')
67
+ if relative == '..' || relative.start_with?('../')
68
+ raise TechnicalError, 'source file must be inside the project root'
69
+ end
70
+ raise TechnicalError, "source file does not exist: #{identifier}" unless path.file?
71
+
72
+ path
73
+ end
74
+ private_class_method :source_path_within
75
+
76
+ def directory_of(identifier)
77
+ return '' unless identifier.include?('/')
78
+
79
+ File.dirname(identifier).tr('\\', '/').freeze
80
+ end
81
+ private_class_method :directory_of
82
+ end
83
+ end
84
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ArchUnit
4
+ module Files
5
+ # Ruby-specific descriptive data extraction for file rules.
6
+ module Extraction
7
+ # Immutable source-file information exposed to custom predicates.
8
+ FileInfo = Data.define(
9
+ :path, :name, :extension, :directory, :content, :lines_of_code
10
+ ) do
11
+ # rubocop:disable Metrics/ParameterLists -- FileInfo has six required public fields.
12
+ def initialize(path:, name:, extension:, directory:, content:, lines_of_code:)
13
+ path = immutable_string(path, :path, allow_empty: false, normalize_path: true)
14
+ name = immutable_string(name, :name, allow_empty: false)
15
+ extension = immutable_string(extension, :extension, allow_empty: true)
16
+ directory = immutable_string(
17
+ directory, :directory, allow_empty: true, normalize_path: true
18
+ )
19
+ content = immutable_string(content, :content, allow_empty: true)
20
+ lines_of_code = non_negative_integer(lines_of_code)
21
+ super
22
+ end
23
+ # rubocop:enable Metrics/ParameterLists
24
+
25
+ private
26
+
27
+ def immutable_string(value, attribute, allow_empty:, normalize_path: false)
28
+ valid = value.is_a?(String) && (allow_empty || !value.empty?)
29
+ unless valid
30
+ requirement = allow_empty ? 'a String' : 'a non-empty String'
31
+ raise ArgumentError, "#{attribute} must be #{requirement}"
32
+ end
33
+
34
+ value = value.tr('\\', '/') if normalize_path
35
+ value.dup.freeze
36
+ end
37
+
38
+ def non_negative_integer(value)
39
+ return value if value.is_a?(Integer) && value >= 0
40
+
41
+ raise ArgumentError, 'lines_of_code must be a non-negative Integer'
42
+ end
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../../common/fluentapi/checkable'
4
+ require_relative '../../extraction/extract_graph'
5
+ require_relative '../../extraction/locate_project'
6
+ require_relative '../assertion/custom_file_condition'
7
+ require_relative '../extraction/extract_file_info'
8
+ require_relative 'file_rule_support'
9
+
10
+ module ArchUnit
11
+ module Files
12
+ module FluentApi
13
+ # Executable custom predicate evaluated against immutable FileInfo values.
14
+ class CustomFileCondition
15
+ include Common::FluentApi::Checkable
16
+
17
+ attr_reader :project_locator, :subject_filters, :condition, :message, :is_negated
18
+
19
+ def initialize(mood, condition:, message:)
20
+ validate_mood(mood)
21
+ @project_locator = mood.project_locator
22
+ @subject_filters = mood.filters
23
+ @condition = callable_value(condition)
24
+ @message = message_value(message)
25
+ @is_negated = mood.negated?
26
+ freeze
27
+ end
28
+
29
+ def negated?
30
+ is_negated
31
+ end
32
+
33
+ private
34
+
35
+ def validate_mood(value)
36
+ return if value.is_a?(MatchPatternFileConditionBuilder)
37
+
38
+ raise ArgumentError, 'mood must be a file condition builder'
39
+ end
40
+
41
+ def callable_value(value)
42
+ return value if value.respond_to?(:call)
43
+
44
+ raise ArgumentError, 'condition must be callable'
45
+ end
46
+
47
+ def message_value(value)
48
+ return value.dup.freeze if value.is_a?(String) && !value.strip.empty?
49
+
50
+ raise ArgumentError, 'message must be a non-empty String'
51
+ end
52
+
53
+ def perform_check(options)
54
+ graph = ArchUnit::Extraction.extract_graph(project_locator, options:)
55
+ nodes = FileRuleSupport.selected_nodes(graph, subject_filters)
56
+ empty_test = empty_test_violation(
57
+ nodes, filters: subject_filters, negated: is_negated, options:
58
+ )
59
+ return empty_test if empty_test
60
+
61
+ Assertion.gather_custom_file_violations(
62
+ extract_file_infos(nodes), condition, message, is_negated:
63
+ )
64
+ end
65
+
66
+ def extract_file_infos(nodes)
67
+ root = ArchUnit::Extraction.locate_project(project_locator)
68
+ nodes.map { |node| Files::Extraction.extract_file_info(root, node.label) }
69
+ end
70
+ end
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../../common/fluentapi/checkable'
4
+ require_relative '../../common/projection/edge_projections'
5
+ require_relative '../../common/projection/project_cycles'
6
+ require_relative '../../common/projection/project_edges'
7
+ require_relative '../../extraction/extract_graph'
8
+ require_relative '../assertion/cycle_free'
9
+ require_relative 'file_rule_support'
10
+
11
+ module ArchUnit
12
+ module Files
13
+ module FluentApi
14
+ # Executable positive rule requiring the selected file graph to be acyclic.
15
+ class CycleFreeFileCondition
16
+ include Common::FluentApi::Checkable
17
+
18
+ attr_reader :project_locator, :filters
19
+
20
+ def initialize(mood)
21
+ unless mood.is_a?(PositiveMatchPatternFileConditionBuilder)
22
+ raise ArgumentError, 'mood must be a positive file condition builder'
23
+ end
24
+
25
+ @project_locator = mood.project_locator
26
+ @filters = mood.filters
27
+ freeze
28
+ end
29
+
30
+ private
31
+
32
+ def perform_check(options)
33
+ graph = ArchUnit::Extraction.extract_graph(project_locator, options:)
34
+ nodes = FileRuleSupport.selected_nodes(graph, filters)
35
+ empty_test = empty_test_violation(
36
+ nodes, filters:, negated: false, options:
37
+ )
38
+ return empty_test if empty_test
39
+
40
+ cycles = cycles_within(graph, nodes)
41
+ Assertion.gather_cycle_violations(cycles)
42
+ end
43
+
44
+ def cycles_within(graph, nodes)
45
+ selected_labels = nodes.to_h { |node| [node.label, true] }
46
+ edges = Common::Projection.project_edges(graph, Common::Projection.per_internal_edge)
47
+ .select do |edge|
48
+ selected_labels[edge.source_label] && selected_labels[edge.target_label]
49
+ end
50
+ Common::Projection.project_cycles(edges)
51
+ end
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../../common/fluentapi/checkable'
4
+ require_relative '../../common/projection/edge_projections'
5
+ require_relative '../../common/projection/project_edges'
6
+ require_relative '../../common/regex_factory'
7
+ require_relative '../../extraction/extract_graph'
8
+ require_relative '../assertion/depend_on_external_modules'
9
+ require_relative 'file_rule_support'
10
+
11
+ module ArchUnit
12
+ module Files
13
+ module FluentApi
14
+ # Executable external-module rule with OR-combined module selectors.
15
+ class DependOnExternalModuleCondition
16
+ include Common::FluentApi::Checkable
17
+
18
+ attr_reader :builder, :project_locator, :subject_filters, :module_filters, :is_negated
19
+
20
+ def initialize(builder, module_filters:)
21
+ unless builder.is_a?(DependOnExternalModuleConditionBuilder)
22
+ raise ArgumentError, 'builder must be a DependOnExternalModuleConditionBuilder'
23
+ end
24
+
25
+ @builder = builder
26
+ @project_locator = builder.project_locator
27
+ @subject_filters = builder.subject_filters
28
+ @module_filters = immutable_module_filters(module_filters)
29
+ @is_negated = builder.negated?
30
+ freeze
31
+ end
32
+
33
+ def negated?
34
+ is_negated
35
+ end
36
+
37
+ def matching(module_name, except: nil)
38
+ filter = Common::RegexFactory.path_matcher(module_name, except:)
39
+ self.class.new(builder, module_filters: [*module_filters, filter])
40
+ end
41
+
42
+ private
43
+
44
+ def immutable_module_filters(values)
45
+ filters = Array(values).dup
46
+ unless !filters.empty? && filters.all?(Common::Filter)
47
+ raise ArgumentError, 'module_filters must contain at least one Filter'
48
+ end
49
+
50
+ filters.freeze
51
+ end
52
+
53
+ def perform_check(options)
54
+ graph = ArchUnit::Extraction.extract_graph(project_locator, options:)
55
+ nodes = FileRuleSupport.selected_nodes(graph, subject_filters)
56
+ empty_test = empty_test_violation(
57
+ nodes, filters: subject_filters, negated: is_negated, options:
58
+ )
59
+ return empty_test if empty_test
60
+
61
+ edges = Common::Projection.project_edges(graph, Common::Projection.per_external_edge)
62
+ Assertion.gather_external_module_dependency_violations(
63
+ edges, subject_filters, module_filters, is_negated:
64
+ )
65
+ end
66
+ end
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../../common/regex_factory'
4
+ require_relative 'depend_on_external_module_condition'
5
+
6
+ module ArchUnit
7
+ module Files
8
+ module FluentApi
9
+ # Immutable object stage selecting external dependency names.
10
+ class DependOnExternalModuleConditionBuilder
11
+ attr_reader :mood, :project_locator, :subject_filters, :is_negated
12
+
13
+ def initialize(mood)
14
+ unless mood.is_a?(MatchPatternFileConditionBuilder)
15
+ raise ArgumentError, 'mood must be a file condition builder'
16
+ end
17
+
18
+ @mood = mood
19
+ @project_locator = mood.project_locator
20
+ @subject_filters = mood.filters
21
+ @is_negated = mood.negated?
22
+ freeze
23
+ end
24
+
25
+ def negated?
26
+ is_negated
27
+ end
28
+
29
+ def matching(module_name, except: nil)
30
+ filter = Common::RegexFactory.path_matcher(module_name, except:)
31
+ DependOnExternalModuleCondition.new(self, module_filters: [filter])
32
+ end
33
+ end
34
+ end
35
+ end
36
+ end