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,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ArchUnit
4
+ module Layers
5
+ module FluentApi
6
+ # Validation and defensive copying for immutable layer policy builders.
7
+ module LayeredArchitectureValues
8
+ private
9
+
10
+ def validated_layer_name(value)
11
+ return value.dup.freeze if value.is_a?(String) && !value.strip.empty?
12
+
13
+ raise ArgumentError, 'layer name must be a non-empty String'
14
+ end
15
+
16
+ def immutable_project_locator(locator)
17
+ return if locator.nil?
18
+
19
+ locator = locator.to_path if locator.respond_to?(:to_path)
20
+ return locator.dup.freeze if locator.is_a?(String) && !locator.empty?
21
+
22
+ raise ArgumentError, 'project_locator must be a non-empty path or nil'
23
+ end
24
+
25
+ def immutable_layer_definitions(values)
26
+ definitions = Array(values).dup
27
+ unless definitions.all?(Assertion::LayerDefinition)
28
+ raise ArgumentError, 'layer_definitions must contain only LayerDefinition values'
29
+ end
30
+ unless definitions.map(&:name).uniq.length == definitions.length
31
+ raise ArgumentError, 'layer names must be unique'
32
+ end
33
+
34
+ definitions.freeze
35
+ end
36
+
37
+ def immutable_dependency_map(value)
38
+ raise ArgumentError, 'dependency policies must be Hash values' unless value.is_a?(Hash)
39
+
40
+ value.to_h do |source, targets|
41
+ immutable_source = validated_layer_name(source)
42
+ immutable_targets = Array(targets).map do |name|
43
+ validated_layer_name(name)
44
+ end.freeze
45
+ [immutable_source, immutable_targets]
46
+ end.freeze
47
+ end
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'layered_architecture'
4
+
5
+ # Public ArchUnitRuby entry points for named layer policies.
6
+ module ArchUnit
7
+ module Layers
8
+ # Sentence-like entry points and builders for layer architecture rules.
9
+ module FluentApi
10
+ module_function
11
+
12
+ def project_layers(project_locator = nil)
13
+ LayeredArchitecture.new(project_locator:)
14
+ end
15
+
16
+ class << self
17
+ alias layers project_layers
18
+ end
19
+ end
20
+ end
21
+
22
+ def self.project_layers(project_locator = nil)
23
+ Layers::FluentApi.project_layers(project_locator)
24
+ end
25
+
26
+ class << self
27
+ alias layers project_layers
28
+ end
29
+ end
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../../common/assertion/violation'
4
+ require_relative '../calculation/metric'
5
+ require_relative '../extraction/metric_info'
6
+
7
+ module ArchUnit
8
+ module Metrics
9
+ # Pure assertions and structured custom metric violation data.
10
+ module Assertion
11
+ # A class whose calculated custom value did not satisfy the user's predicate.
12
+ class CustomMetricViolation < Common::Assertion::Violation
13
+ attr_reader :class_info, :metric_name, :description, :value
14
+
15
+ def initialize(class_info:, metric_name:, description:, value:)
16
+ @class_info = class_info_value(class_info)
17
+ @metric_name = immutable_string(metric_name, :metric_name)
18
+ @description = immutable_string(description, :description)
19
+ @value = numeric_value(value)
20
+ super()
21
+ end
22
+
23
+ private
24
+
25
+ def class_info_value(value)
26
+ return value if value.is_a?(Extraction::ClassInfo)
27
+
28
+ raise ArgumentError, 'class_info must be a ClassInfo value'
29
+ end
30
+
31
+ def immutable_string(value, attribute)
32
+ return value.dup.freeze if value.is_a?(String) && !value.empty?
33
+
34
+ raise ArgumentError, "#{attribute} must be a non-empty String"
35
+ end
36
+
37
+ def numeric_value(value)
38
+ return value if value.is_a?(Numeric)
39
+
40
+ raise ArgumentError, 'value must be Numeric'
41
+ end
42
+ end
43
+
44
+ module_function
45
+
46
+ def gather_custom_metric_violations(class_infos, metric, predicate)
47
+ validate_custom_metric_arguments(class_infos, metric, predicate)
48
+ class_infos.filter_map do |class_info|
49
+ value = metric.calculate(class_info)
50
+ next if predicate.call(value, class_info)
51
+
52
+ CustomMetricViolation.new(
53
+ class_info:, metric_name: metric.name,
54
+ description: metric.description, value:
55
+ )
56
+ end
57
+ end
58
+
59
+ def validate_custom_metric_arguments(class_infos, metric, predicate)
60
+ unless class_infos.is_a?(Array) && class_infos.all?(Extraction::ClassInfo)
61
+ raise ArgumentError, 'class_infos must be an Array of ClassInfo values'
62
+ end
63
+ unless metric.is_a?(Calculation::Metric) && metric.name.is_a?(String) && metric.description
64
+ raise ArgumentError, 'metric must be a described custom Metric'
65
+ end
66
+ return if predicate.respond_to?(:call)
67
+
68
+ raise ArgumentError, 'predicate must respond to call'
69
+ end
70
+ private_class_method :validate_custom_metric_arguments
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../../common/assertion/violation'
4
+ require_relative '../calculation/metric'
5
+ require_relative '../calculation/numeric_value'
6
+
7
+ module ArchUnit
8
+ module Metrics
9
+ # Pure arbitrary metric predicates and structured violation data.
10
+ module Assertion
11
+ # One built-in metric value that did not satisfy a user predicate.
12
+ class MetricPredicateViolation < Common::Assertion::Violation
13
+ attr_reader :subject, :metric_name, :value
14
+
15
+ def initialize(subject:, metric_name:, value:)
16
+ unless subject.respond_to?(:identifier)
17
+ raise ArgumentError, 'subject must expose an identifier'
18
+ end
19
+
20
+ @subject = subject
21
+ @metric_name = immutable_metric_name(metric_name)
22
+ @value = Calculation::NumericValue.validate(value)
23
+ super()
24
+ end
25
+
26
+ def identifier
27
+ subject.identifier
28
+ end
29
+
30
+ private
31
+
32
+ def immutable_metric_name(value)
33
+ return value if value.is_a?(Symbol)
34
+ return value.dup.freeze if value.is_a?(String) && !value.empty?
35
+
36
+ raise ArgumentError, 'metric_name must be a Symbol or non-empty String'
37
+ end
38
+ end
39
+
40
+ module_function
41
+
42
+ def gather_metric_predicate_violations(subjects, metric, predicate)
43
+ validate_predicate_assertion(subjects, metric, predicate)
44
+ subjects.filter_map do |subject|
45
+ value = metric.calculate(subject)
46
+ next if predicate.call(value, subject)
47
+
48
+ MetricPredicateViolation.new(subject:, metric_name: metric.name, value:)
49
+ end
50
+ end
51
+
52
+ def validate_predicate_assertion(subjects, metric, predicate)
53
+ raise ArgumentError, 'metric must be a Metric' unless metric.is_a?(Calculation::Metric)
54
+
55
+ unless subjects.is_a?(Array) && subjects.all?(metric.subject_type)
56
+ type_name = metric.subject_type.name.split('::').last
57
+ raise ArgumentError, "subjects must be an Array of #{type_name} values"
58
+ end
59
+ return if predicate.respond_to?(:call)
60
+
61
+ raise ArgumentError, 'predicate must respond to call'
62
+ end
63
+ private_class_method :validate_predicate_assertion
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,99 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../../common/assertion/violation'
4
+ require_relative '../calculation/metric'
5
+ require_relative '../calculation/numeric_value'
6
+
7
+ module ArchUnit
8
+ module Metrics
9
+ # Pure metric threshold assertions and structured violation data.
10
+ module Assertion
11
+ # One metric value that did not meet its numeric threshold.
12
+ class MetricThresholdViolation < Common::Assertion::Violation
13
+ COMPARISONS = %i[below above equal below_or_equal above_or_equal].freeze
14
+
15
+ attr_reader :subject, :metric_name, :value, :threshold, :comparison
16
+
17
+ def initialize(subject:, metric_name:, value:, threshold:, comparison:)
18
+ @subject = validated_subject(subject)
19
+ @metric_name = immutable_metric_name(metric_name)
20
+ @value = Calculation::NumericValue.validate(value)
21
+ @threshold = Calculation::NumericValue.validate(threshold, attribute: 'threshold')
22
+ @comparison = validated_comparison(comparison)
23
+ super()
24
+ end
25
+
26
+ def identifier
27
+ subject.identifier
28
+ end
29
+
30
+ private
31
+
32
+ def validated_subject(value)
33
+ return value if value.respond_to?(:identifier)
34
+
35
+ raise ArgumentError, 'subject must expose an identifier'
36
+ end
37
+
38
+ def validated_comparison(value)
39
+ return value if COMPARISONS.include?(value)
40
+
41
+ raise ArgumentError, "unknown metric comparison: #{value.inspect}"
42
+ end
43
+
44
+ def immutable_metric_name(value)
45
+ return value if value.is_a?(Symbol)
46
+ return value.dup.freeze if value.is_a?(String) && !value.empty?
47
+
48
+ raise ArgumentError, 'metric_name must be a Symbol or non-empty String'
49
+ end
50
+ end
51
+
52
+ module_function
53
+
54
+ def gather_metric_threshold_violations(subjects, metric, comparison, threshold)
55
+ validate_metric_assertion(subjects, metric)
56
+ threshold = validate_threshold(comparison, threshold)
57
+
58
+ subjects.filter_map do |subject|
59
+ value = metric.calculate(subject)
60
+ next if threshold_satisfied?(value, comparison, threshold)
61
+
62
+ MetricThresholdViolation.new(
63
+ subject:, metric_name: metric.name, value:, threshold:, comparison:
64
+ )
65
+ end
66
+ end
67
+
68
+ def validate_threshold(comparison, threshold)
69
+ unless MetricThresholdViolation::COMPARISONS.include?(comparison)
70
+ raise ArgumentError, "unknown metric comparison: #{comparison.inspect}"
71
+ end
72
+
73
+ Calculation::NumericValue.validate(threshold, attribute: 'threshold')
74
+ end
75
+ private_class_method :validate_threshold
76
+
77
+ def threshold_satisfied?(value, comparison, threshold)
78
+ case comparison
79
+ when :below then value < threshold
80
+ when :above then value > threshold
81
+ when :equal then value == threshold
82
+ when :below_or_equal then value <= threshold
83
+ when :above_or_equal then value >= threshold
84
+ end
85
+ end
86
+ private_class_method :threshold_satisfied?
87
+
88
+ def validate_metric_assertion(subjects, metric)
89
+ raise ArgumentError, 'metric must be a Metric' unless metric.is_a?(Calculation::Metric)
90
+
91
+ return if subjects.is_a?(Array) && subjects.all?(metric.subject_type)
92
+
93
+ type_name = metric.subject_type.name.split('::').last
94
+ raise ArgumentError, "subjects must be an Array of #{type_name} values"
95
+ end
96
+ private_class_method :validate_metric_assertion
97
+ end
98
+ end
99
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../../common/assertion/violation'
4
+ require_relative '../calculation/distance'
5
+ require_relative '../extraction/metric_info'
6
+
7
+ module ArchUnit
8
+ module Metrics
9
+ # Pure assertions and structured metric violation data.
10
+ module Assertion
11
+ # A file whose abstractness/instability point falls in a discouraged zone.
12
+ class MetricZoneViolation < Common::Assertion::Violation
13
+ ZONES = %i[pain uselessness].freeze
14
+
15
+ attr_reader :distance_info, :zone
16
+
17
+ def initialize(distance_info:, zone:)
18
+ unless distance_info.is_a?(Extraction::DistanceInfo)
19
+ raise ArgumentError, 'distance_info must be a DistanceInfo value'
20
+ end
21
+ unless ZONES.include?(zone)
22
+ raise ArgumentError, "unknown architectural zone: #{zone.inspect}"
23
+ end
24
+
25
+ @distance_info = distance_info
26
+ @zone = zone
27
+ super()
28
+ end
29
+
30
+ def abstractness
31
+ Calculation::Distance.abstractness.calculate(distance_info)
32
+ end
33
+
34
+ def instability
35
+ Calculation::Distance.instability.calculate(distance_info)
36
+ end
37
+ end
38
+
39
+ module_function
40
+
41
+ def gather_metric_zone_violations(distance_infos, zone)
42
+ unless distance_infos.is_a?(Array) && distance_infos.all?(Extraction::DistanceInfo)
43
+ raise ArgumentError, 'distance_infos must be an Array of DistanceInfo values'
44
+ end
45
+ unless MetricZoneViolation::ZONES.include?(zone)
46
+ raise ArgumentError, "unknown architectural zone: #{zone.inspect}"
47
+ end
48
+
49
+ distance_infos.filter_map do |distance_info|
50
+ next unless Calculation::Distance.in_zone?(distance_info, zone)
51
+
52
+ MetricZoneViolation.new(distance_info:, zone:)
53
+ end
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../extraction/metric_info'
4
+ require_relative 'metric'
5
+
6
+ module ArchUnit
7
+ module Metrics
8
+ module Calculation
9
+ # Pure count metrics over extracted Ruby class and file information.
10
+ module Count
11
+ CLASS_METRICS = {
12
+ method_count: ->(class_info) { class_info.methods.length },
13
+ field_count: ->(class_info) { class_info.fields.length }
14
+ }.freeze
15
+ FILE_METRICS = {
16
+ lines_of_code: :lines_of_code.to_proc,
17
+ statements: :statement_count.to_proc,
18
+ imports: :import_count.to_proc,
19
+ classes: :class_count.to_proc,
20
+ functions: :function_count.to_proc
21
+ }.freeze
22
+
23
+ CLASS_METRICS.each do |name, calculation|
24
+ define_singleton_method(name) do
25
+ Metric.new(
26
+ name:,
27
+ subject_type: Metrics::Extraction::ClassInfo,
28
+ calculation:
29
+ )
30
+ end
31
+ end
32
+
33
+ FILE_METRICS.each do |name, calculation|
34
+ define_singleton_method(name) do
35
+ Metric.new(
36
+ name:,
37
+ subject_type: Metrics::Extraction::FileInfo,
38
+ calculation:
39
+ )
40
+ end
41
+ end
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../extraction/metric_info'
4
+ require_relative 'metric'
5
+
6
+ module ArchUnit
7
+ module Metrics
8
+ module Calculation
9
+ # Robert C. Martin's abstractness, instability, distance, and zone calculations.
10
+ module Distance
11
+ PAIN_LIMIT = 0.3
12
+ USELESSNESS_LIMIT = 0.7
13
+ SIZE_NORMALIZATION_LINES = 100.0
14
+ MAXIMUM_SIZE_DISCOUNT = 0.5
15
+
16
+ CALCULATIONS = {
17
+ abstractness: ->(info) { abstractness_value(info) },
18
+ instability: ->(info) { instability_value(info) },
19
+ distance_from_main_sequence: ->(info) { distance_value(info) },
20
+ coupling_factor: ->(info) { coupling_factor_value(info) },
21
+ normalized_distance: ->(info) { normalized_distance_value(info) }
22
+ }.freeze
23
+
24
+ CALCULATIONS.each do |name, calculation|
25
+ define_singleton_method(name) do
26
+ Metric.new(
27
+ name:, subject_type: Metrics::Extraction::DistanceInfo, calculation:
28
+ )
29
+ end
30
+ end
31
+
32
+ module_function
33
+
34
+ def in_zone?(distance_info, zone)
35
+ abstractness = abstractness_value(distance_info)
36
+ instability = instability_value(distance_info)
37
+ case zone
38
+ when :pain
39
+ abstractness < PAIN_LIMIT && instability < PAIN_LIMIT
40
+ when :uselessness
41
+ abstractness > USELESSNESS_LIMIT && instability > USELESSNESS_LIMIT
42
+ else
43
+ raise ArgumentError, "unknown architectural zone: #{zone.inspect}"
44
+ end
45
+ end
46
+
47
+ def abstractness_value(info)
48
+ return 0.0 if info.type_count.zero?
49
+
50
+ info.abstract_type_count.fdiv(info.type_count)
51
+ end
52
+ private_class_method :abstractness_value
53
+
54
+ def instability_value(info)
55
+ total = info.afferent_coupling + info.efferent_coupling
56
+ return 0.0 if total.zero?
57
+
58
+ info.efferent_coupling.fdiv(total)
59
+ end
60
+ private_class_method :instability_value
61
+
62
+ def distance_value(info)
63
+ (abstractness_value(info) + instability_value(info) - 1.0).abs
64
+ end
65
+ private_class_method :distance_value
66
+
67
+ def coupling_factor_value(info)
68
+ possible = 2 * (info.project_file_count - 1)
69
+ return 0.0 if possible.zero?
70
+
71
+ (info.afferent_coupling + info.efferent_coupling).fdiv(possible)
72
+ end
73
+ private_class_method :coupling_factor_value
74
+
75
+ def normalized_distance_value(info)
76
+ size_ratio = [info.lines_of_code.fdiv(SIZE_NORMALIZATION_LINES), 1.0].min
77
+ discount = size_ratio * MAXIMUM_SIZE_DISCOUNT
78
+ distance_value(info) * (1.0 - discount)
79
+ end
80
+ private_class_method :normalized_distance_value
81
+ end
82
+ end
83
+ end
84
+ end
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../extraction/metric_info'
4
+ require_relative 'metric'
5
+
6
+ module ArchUnit
7
+ module Metrics
8
+ module Calculation
9
+ # Pure lack-of-cohesion calculations over extracted method/field relationships.
10
+ module LCOM
11
+ CALCULATIONS = {
12
+ lcom96a: ->(class_info) { normalized_method_field_distance(class_info) },
13
+ lcom96b: ->(class_info) { method_field_density_complement(class_info) },
14
+ lcom1: ->(class_info) { pair_difference(class_info) },
15
+ lcom2: ->(class_info) { method_field_density_complement(class_info) },
16
+ lcom3: ->(class_info) { normalized_method_field_distance(class_info) },
17
+ lcom4: ->(class_info) { connected_components(class_info) },
18
+ lcom5: ->(class_info) { normalized_method_field_distance(class_info) },
19
+ lcom_star: ->(class_info) { normalized_method_field_distance(class_info) }
20
+ }.freeze
21
+
22
+ CALCULATIONS.each do |name, calculation|
23
+ define_singleton_method(name) do
24
+ Metric.new(
25
+ name:,
26
+ subject_type: Metrics::Extraction::ClassInfo,
27
+ calculation:
28
+ )
29
+ end
30
+ end
31
+
32
+ module_function
33
+
34
+ def normalized_method_field_distance(class_info)
35
+ method_count = class_info.methods.length
36
+ field_count = class_info.fields.length
37
+ return 0.0 if method_count <= 1 || field_count.zero?
38
+
39
+ average_accesses = field_access_count(class_info).fdiv(field_count)
40
+ (method_count - average_accesses).fdiv(method_count - 1)
41
+ end
42
+ private_class_method :normalized_method_field_distance
43
+
44
+ def method_field_density_complement(class_info)
45
+ method_count = class_info.methods.length
46
+ field_count = class_info.fields.length
47
+ return 0.0 if method_count <= 1 || field_count.zero?
48
+
49
+ 1.0 - field_access_count(class_info).fdiv(method_count * field_count)
50
+ end
51
+ private_class_method :method_field_density_complement
52
+
53
+ def field_access_count(class_info)
54
+ class_info.fields.sum { |field| field.accessed_by.length }
55
+ end
56
+ private_class_method :field_access_count
57
+
58
+ def pair_difference(class_info)
59
+ sharing, non_sharing = method_pairs(class_info).partition do |left, right|
60
+ fields_overlap?(left, right)
61
+ end
62
+ [non_sharing.length - sharing.length, 0].max
63
+ end
64
+ private_class_method :pair_difference
65
+
66
+ def method_pairs(class_info)
67
+ class_info.methods.combination(2).to_a
68
+ end
69
+ private_class_method :method_pairs
70
+
71
+ def fields_overlap?(left, right)
72
+ left.accessed_fields.intersect?(right.accessed_fields)
73
+ end
74
+ private_class_method :fields_overlap?
75
+
76
+ def connected_components(class_info)
77
+ methods = class_info.methods
78
+ return 0 if methods.empty?
79
+
80
+ remaining = methods.dup
81
+ components = 0
82
+ until remaining.empty?
83
+ components += 1
84
+ remove_component(remaining)
85
+ end
86
+ components
87
+ end
88
+ private_class_method :connected_components
89
+
90
+ def remove_component(remaining)
91
+ pending = [remaining.first]
92
+ until pending.empty?
93
+ method = pending.pop
94
+ next unless remaining.delete(method)
95
+
96
+ pending.concat(remaining.select { |candidate| fields_overlap?(method, candidate) })
97
+ end
98
+ end
99
+ private_class_method :remove_component
100
+ end
101
+ end
102
+ end
103
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'numeric_value'
4
+
5
+ module ArchUnit
6
+ module Metrics
7
+ module Calculation
8
+ # Immutable named calculation over one extracted subject type.
9
+ Metric = Data.define(:name, :subject_type, :calculation, :description) do
10
+ def initialize(name:, subject_type:, calculation:, description: nil)
11
+ name = immutable_name(name)
12
+ raise ArgumentError, 'subject_type must be a Class' unless subject_type.is_a?(Class)
13
+ unless calculation.respond_to?(:call)
14
+ raise ArgumentError, 'calculation must respond to call'
15
+ end
16
+
17
+ calculation = calculation.dup.freeze
18
+ description = immutable_description(description)
19
+ super
20
+ end
21
+
22
+ def calculate(subject)
23
+ unless subject.is_a?(subject_type)
24
+ raise ArgumentError, "subject must be a #{subject_type.name.split('::').last}"
25
+ end
26
+
27
+ NumericValue.validate(
28
+ calculation.call(subject),
29
+ attribute: 'metric calculations',
30
+ error_class: TypeError
31
+ )
32
+ end
33
+
34
+ private
35
+
36
+ def immutable_name(value)
37
+ return value if value.is_a?(Symbol)
38
+ return value.dup.freeze if value.is_a?(String) && !value.empty?
39
+
40
+ raise ArgumentError, 'name must be a Symbol or non-empty String'
41
+ end
42
+
43
+ def immutable_description(value)
44
+ return if value.nil?
45
+ return value.dup.freeze if value.is_a?(String) && !value.empty?
46
+
47
+ raise ArgumentError, 'description must be a non-empty String or nil'
48
+ end
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ArchUnit
4
+ module Metrics
5
+ module Calculation
6
+ # Validation shared by metric calculations, thresholds, and measurements.
7
+ module NumericValue
8
+ module_function
9
+
10
+ def validate(value, attribute: 'value', error_class: ArgumentError)
11
+ return value if finite_real_number?(value)
12
+
13
+ raise error_class, "#{attribute} must be a finite real Numeric value"
14
+ end
15
+
16
+ def finite_real_number?(value)
17
+ return false unless value.is_a?(Numeric) && value.real?
18
+ return value.finite? if value.respond_to?(:finite?)
19
+
20
+ true
21
+ end
22
+ end
23
+ end
24
+ end
25
+ end