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,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../calculation/metric'
4
+ require_relative '../extraction/metric_info'
5
+ require_relative 'custom_metric_condition'
6
+ require_relative 'metric_selection'
7
+
8
+ module ArchUnit
9
+ module Metrics
10
+ module FluentApi
11
+ # A user-defined class calculation that can be measured or asserted.
12
+ class CustomMetricBuilder < MetricSelection
13
+ def initialize(scope:, name:, description:, calculation:)
14
+ unless name.is_a?(String) && !name.empty?
15
+ raise ArgumentError, 'custom metric name must be a non-empty String'
16
+ end
17
+
18
+ metric = Calculation::Metric.new(
19
+ name:, description:, subject_type: Extraction::ClassInfo, calculation:
20
+ )
21
+ super(scope:, metric:)
22
+ end
23
+
24
+ def should_satisfy(predicate)
25
+ CustomMetricCondition.new(selection: self, predicate:)
26
+ end
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../../common/fluentapi/checkable'
4
+ require_relative '../assertion/custom_metric'
5
+
6
+ module ArchUnit
7
+ module Metrics
8
+ module FluentApi
9
+ # Executable custom class-metric predicate.
10
+ class CustomMetricCondition
11
+ include Common::FluentApi::Checkable
12
+
13
+ attr_reader :selection, :predicate
14
+
15
+ def initialize(selection:, predicate:)
16
+ unless selection.is_a?(CustomMetricBuilder)
17
+ raise ArgumentError, 'selection must be a CustomMetricBuilder'
18
+ end
19
+ raise ArgumentError, 'predicate must respond to call' unless predicate.respond_to?(:call)
20
+
21
+ @selection = selection
22
+ @predicate = predicate.dup.freeze
23
+ freeze
24
+ end
25
+
26
+ private
27
+
28
+ def perform_check(options)
29
+ classes = selection.scope.__send__(:subjects_for, Extraction::ClassInfo, options:)
30
+ empty_test = empty_test_violation(
31
+ classes, filters: selection.scope.filters, negated: false, options:
32
+ )
33
+ return empty_test if empty_test
34
+
35
+ Assertion.gather_custom_metric_violations(classes, selection.metric, predicate)
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../calculation/distance'
4
+ require_relative 'metric_report_builder'
5
+ require_relative 'metric_selection'
6
+ require_relative 'zone_condition'
7
+
8
+ module ArchUnit
9
+ module Metrics
10
+ module FluentApi
11
+ # Fluent distance metric names and architectural zone guards.
12
+ class DistanceMetricsBuilder
13
+ include MetricReportBuilder
14
+
15
+ attr_reader :scope
16
+
17
+ def initialize(scope)
18
+ raise ArgumentError, 'scope must be a MetricsBuilder' unless scope.is_a?(MetricsBuilder)
19
+
20
+ @scope = scope
21
+ freeze
22
+ end
23
+
24
+ Calculation::Distance::CALCULATIONS.each_key do |name|
25
+ define_method(name) do
26
+ MetricSelection.new(scope:, metric: Calculation::Distance.public_send(name))
27
+ end
28
+ end
29
+
30
+ def not_in_zone_of_pain
31
+ ZoneCondition.new(scope:, zone: :pain)
32
+ end
33
+
34
+ def not_in_zone_of_uselessness
35
+ ZoneCondition.new(scope:, zone: :uselessness)
36
+ end
37
+
38
+ private
39
+
40
+ def report_metrics
41
+ Calculation::Distance::CALCULATIONS.each_key.map do |name|
42
+ Calculation::Distance.public_send(name)
43
+ end
44
+ end
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../calculation/lcom'
4
+ require_relative 'metric_report_builder'
5
+ require_relative 'metric_selection'
6
+
7
+ module ArchUnit
8
+ module Metrics
9
+ module FluentApi
10
+ # Fluent LCOM metric names for one immutable metrics scope.
11
+ class LCOMMetricsBuilder
12
+ include MetricReportBuilder
13
+
14
+ attr_reader :scope
15
+
16
+ def initialize(scope)
17
+ raise ArgumentError, 'scope must be a MetricsBuilder' unless scope.is_a?(MetricsBuilder)
18
+
19
+ @scope = scope
20
+ freeze
21
+ end
22
+
23
+ Calculation::LCOM::CALCULATIONS.each_key do |name|
24
+ define_method(name) do
25
+ MetricSelection.new(scope:, metric: Calculation::LCOM.public_send(name))
26
+ end
27
+ end
28
+
29
+ private
30
+
31
+ def report_metrics
32
+ Calculation::LCOM::CALCULATIONS.each_key.map do |name|
33
+ Calculation::LCOM.public_send(name)
34
+ end
35
+ end
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../calculation/numeric_value'
4
+
5
+ module ArchUnit
6
+ module Metrics
7
+ module FluentApi
8
+ # One calculated metric value and the extracted subject it describes.
9
+ MetricMeasurement = Data.define(:subject, :metric_name, :value) do
10
+ def initialize(subject:, metric_name:, value:)
11
+ unless subject.respond_to?(:identifier)
12
+ raise ArgumentError, 'subject must expose an identifier'
13
+ end
14
+
15
+ metric_name = immutable_metric_name(metric_name)
16
+ value = Calculation::NumericValue.validate(value)
17
+
18
+ super
19
+ end
20
+
21
+ def identifier
22
+ subject.identifier
23
+ end
24
+
25
+ private
26
+
27
+ def immutable_metric_name(value)
28
+ return value if value.is_a?(Symbol)
29
+ return value.dup.freeze if value.is_a?(String) && !value.empty?
30
+
31
+ raise ArgumentError, 'metric_name must be a Symbol or non-empty String'
32
+ end
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../../common/fluentapi/checkable'
4
+ require_relative '../assertion/metric_predicate'
5
+
6
+ module ArchUnit
7
+ module Metrics
8
+ module FluentApi
9
+ # Executable arbitrary predicate over one selected built-in metric.
10
+ class MetricPredicateCondition
11
+ include Common::FluentApi::Checkable
12
+
13
+ attr_reader :selection, :predicate
14
+
15
+ def initialize(selection:, predicate:)
16
+ unless selection.is_a?(MetricSelection)
17
+ raise ArgumentError, 'selection must be a MetricSelection'
18
+ end
19
+ raise ArgumentError, 'predicate must respond to call' unless predicate.respond_to?(:call)
20
+
21
+ @selection = selection
22
+ @predicate = predicate.dup.freeze
23
+ freeze
24
+ end
25
+
26
+ private
27
+
28
+ def perform_check(options)
29
+ subjects = selection.scope.__send__(
30
+ :subjects_for, selection.metric.subject_type, options:
31
+ )
32
+ empty_test = empty_test_violation(
33
+ subjects, filters: selection.scope.filters, negated: false, options:
34
+ )
35
+ return empty_test if empty_test
36
+
37
+ Assertion.gather_metric_predicate_violations(subjects, selection.metric, predicate)
38
+ end
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../reporting/metrics_export_options'
4
+ require_relative '../reporting/metrics_exporter'
5
+
6
+ module ArchUnit
7
+ module Metrics
8
+ module FluentApi
9
+ # Shared HTML export terminal for one family of scoped metrics.
10
+ module MetricReportBuilder
11
+ def export_as_html(output_path, options = nil)
12
+ options = Reporting::MetricsExportOptions.resolve(options).with(output_path:)
13
+ Reporting::MetricsExporter.export_as_html(metric_report_data, options)
14
+ nil
15
+ end
16
+
17
+ private
18
+
19
+ def metric_report_data
20
+ report_metrics.group_by(&:subject_type).each_with_object({}) do |(type, metrics), data|
21
+ subjects = scope.__send__(:subjects_for, type)
22
+ metrics.each { |metric| add_metric_values(data, subjects, metric) }
23
+ end.freeze
24
+ end
25
+
26
+ def add_metric_values(data, subjects, metric)
27
+ subjects.each do |subject|
28
+ data["#{metric.name} [#{subject.identifier}]"] = metric.calculate(subject)
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../calculation/metric'
4
+ require_relative 'metric_measurement'
5
+ require_relative 'metric_predicate_condition'
6
+ require_relative 'metric_threshold_condition'
7
+
8
+ module ArchUnit
9
+ module Metrics
10
+ module FluentApi
11
+ # Lazy selection of one metric over a metrics scope.
12
+ class MetricSelection
13
+ THRESHOLD_METHODS = {
14
+ should_be_below: :below,
15
+ should_be_above: :above,
16
+ should_be: :equal,
17
+ should_be_below_or_equal: :below_or_equal,
18
+ should_be_above_or_equal: :above_or_equal
19
+ }.freeze
20
+
21
+ attr_reader :scope, :metric
22
+
23
+ def initialize(scope:, metric:)
24
+ raise ArgumentError, 'scope must be a MetricsBuilder' unless scope.is_a?(MetricsBuilder)
25
+ raise ArgumentError, 'metric must be a Metric' unless metric.is_a?(Calculation::Metric)
26
+
27
+ @scope = scope
28
+ @metric = metric
29
+ freeze
30
+ end
31
+
32
+ def measure
33
+ scope.__send__(:subjects_for, metric.subject_type).map do |subject|
34
+ MetricMeasurement.new(
35
+ subject:,
36
+ metric_name: metric.name,
37
+ value: metric.calculate(subject)
38
+ )
39
+ end.freeze
40
+ end
41
+
42
+ THRESHOLD_METHODS.each do |method_name, comparison|
43
+ define_method(method_name) do |threshold|
44
+ MetricThresholdCondition.new(selection: self, comparison:, threshold:)
45
+ end
46
+ end
47
+
48
+ def should_satisfy(predicate)
49
+ MetricPredicateCondition.new(selection: self, predicate:)
50
+ end
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../../common/fluentapi/checkable'
4
+ require_relative '../assertion/metric_threshold'
5
+
6
+ module ArchUnit
7
+ module Metrics
8
+ module FluentApi
9
+ # Executable numeric threshold over one selected metric.
10
+ class MetricThresholdCondition
11
+ include Common::FluentApi::Checkable
12
+
13
+ attr_reader :selection, :comparison, :threshold
14
+
15
+ def initialize(selection:, comparison:, threshold:)
16
+ unless selection.is_a?(MetricSelection)
17
+ raise ArgumentError, 'selection must be a MetricSelection'
18
+ end
19
+ unless Assertion::MetricThresholdViolation::COMPARISONS.include?(comparison)
20
+ raise ArgumentError, "unknown metric comparison: #{comparison.inspect}"
21
+ end
22
+
23
+ @selection = selection
24
+ @comparison = comparison
25
+ @threshold = Calculation::NumericValue.validate(threshold, attribute: 'threshold')
26
+ freeze
27
+ end
28
+
29
+ private
30
+
31
+ def perform_check(options)
32
+ subjects = selection.scope.__send__(
33
+ :subjects_for, selection.metric.subject_type, options:
34
+ )
35
+ empty_test = empty_test_violation(
36
+ subjects, filters: selection.scope.filters, negated: false, options:
37
+ )
38
+ return empty_test if empty_test
39
+
40
+ Assertion.gather_metric_threshold_violations(
41
+ subjects, selection.metric, comparison, threshold
42
+ )
43
+ end
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'metrics_builder'
4
+
5
+ # Public ArchUnitRuby API.
6
+ module ArchUnit
7
+ module Metrics
8
+ # Public entry point for numeric Ruby source metrics.
9
+ module FluentApi
10
+ module_function
11
+
12
+ def metrics(project_locator = nil)
13
+ MetricsBuilder.new(project_locator:)
14
+ end
15
+ end
16
+ end
17
+
18
+ def self.metrics(project_locator = nil)
19
+ Metrics::FluentApi.metrics(project_locator)
20
+ end
21
+ end
@@ -0,0 +1,136 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../../common/filter'
4
+ require_relative '../../common/pattern_matching'
5
+ require_relative '../../common/regex_factory'
6
+ require_relative '../extraction/extract_project_info'
7
+ require_relative '../extraction/extract_distance_info'
8
+ require_relative 'count_metrics_builder'
9
+ require_relative 'custom_metric_builder'
10
+ require_relative 'distance_metrics_builder'
11
+ require_relative 'lcom_metrics_builder'
12
+
13
+ module ArchUnit
14
+ module Metrics
15
+ module FluentApi
16
+ # Immutable scope builder for numeric metrics over Ruby source.
17
+ class MetricsBuilder
18
+ attr_reader :project_locator, :filters
19
+
20
+ def initialize(project_locator: nil, filters: [])
21
+ @project_locator = immutable_project_locator(project_locator)
22
+ @filters = immutable_filters(filters)
23
+ freeze
24
+ end
25
+
26
+ def with_name(pattern, except: nil)
27
+ with_filter(Common::RegexFactory.filename_matcher(pattern, except:))
28
+ end
29
+
30
+ def in_folder(pattern, except: nil)
31
+ with_filter(Common::RegexFactory.folder_matcher(pattern, except:))
32
+ end
33
+
34
+ def in_path(pattern, except: nil)
35
+ with_filter(Common::RegexFactory.path_matcher(pattern, except:))
36
+ end
37
+
38
+ def for_classes_matching(pattern, except: nil)
39
+ with_filter(Common::RegexFactory.classname_matcher(pattern, except:))
40
+ end
41
+
42
+ def count
43
+ CountMetricsBuilder.new(self)
44
+ end
45
+
46
+ def lcom
47
+ LCOMMetricsBuilder.new(self)
48
+ end
49
+
50
+ def distance
51
+ DistanceMetricsBuilder.new(self)
52
+ end
53
+
54
+ def custom_metric(name, description, calculation)
55
+ CustomMetricBuilder.new(scope: self, name:, description:, calculation:)
56
+ end
57
+
58
+ def analyze
59
+ project = Extraction.extract_project_info(project_locator)
60
+ selected_files = project.files.filter_map { |file| selected_file_info(file) }
61
+ Extraction::ProjectInfo.new(project_root: project.project_root, files: selected_files)
62
+ end
63
+
64
+ private
65
+
66
+ def with_filter(filter)
67
+ self.class.new(project_locator:, filters: [*filters, filter])
68
+ end
69
+
70
+ def subjects_for(subject_type, options: nil)
71
+ return distance_subjects(options:) if subject_type == Extraction::DistanceInfo
72
+
73
+ project = analyze
74
+ return project.files if subject_type == Extraction::FileInfo
75
+ return project.classes if subject_type == Extraction::ClassInfo
76
+
77
+ raise ArgumentError, "unsupported metric subject type: #{subject_type}"
78
+ end
79
+
80
+ def distance_subjects(options: nil)
81
+ Extraction.extract_distance_infos(project_locator, options:).select do |distance_info|
82
+ file_selected?(distance_info.file_info)
83
+ end
84
+ end
85
+
86
+ def file_selected?(file_info)
87
+ !selected_file_info(file_info).nil?
88
+ end
89
+
90
+ def selected_file_info(file_info)
91
+ path_filters, class_filters = partitioned_filters
92
+ matches_path = Common::PatternMatching.matches_all_patterns?(file_info.path, path_filters)
93
+ return unless matches_path
94
+ return file_info if class_filters.empty?
95
+
96
+ selected_classes = matching_classes(file_info, class_filters)
97
+ return if selected_classes.empty?
98
+
99
+ file_info.with(class_infos: selected_classes)
100
+ end
101
+
102
+ def matching_classes(file_info, class_filters)
103
+ file_info.class_infos.select do |class_info|
104
+ Common::PatternMatching.matches_all_patterns?(
105
+ file_info.path, class_filters, class_name: class_info.name
106
+ )
107
+ end
108
+ end
109
+
110
+ def partitioned_filters
111
+ filters.partition { |filter| filter.target != :classname }
112
+ end
113
+
114
+ def immutable_project_locator(locator)
115
+ return if locator.nil?
116
+
117
+ locator = locator.to_path if locator.respond_to?(:to_path)
118
+ unless locator.is_a?(String) && !locator.empty?
119
+ raise ArgumentError, 'project_locator must be a non-empty path or nil'
120
+ end
121
+
122
+ locator.dup.freeze
123
+ end
124
+
125
+ def immutable_filters(values)
126
+ filters = Array(values).dup
127
+ unless filters.all?(Common::Filter)
128
+ raise ArgumentError, 'filters must contain only Filter values'
129
+ end
130
+
131
+ filters.freeze
132
+ end
133
+ end
134
+ end
135
+ end
136
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../../common/fluentapi/checkable'
4
+ require_relative '../assertion/metric_zone'
5
+
6
+ module ArchUnit
7
+ module Metrics
8
+ module FluentApi
9
+ # Executable guard that rejects files in one architectural distance zone.
10
+ class ZoneCondition
11
+ include Common::FluentApi::Checkable
12
+
13
+ attr_reader :scope, :zone
14
+
15
+ def initialize(scope:, zone:)
16
+ raise ArgumentError, 'scope must be a MetricsBuilder' unless scope.is_a?(MetricsBuilder)
17
+ unless Assertion::MetricZoneViolation::ZONES.include?(zone)
18
+ raise ArgumentError, "unknown architectural zone: #{zone.inspect}"
19
+ end
20
+
21
+ @scope = scope
22
+ @zone = zone
23
+ freeze
24
+ end
25
+
26
+ private
27
+
28
+ def perform_check(options)
29
+ distance_infos = scope.__send__(:distance_subjects, options:)
30
+ empty_test = empty_test_violation(
31
+ distance_infos, filters: scope.filters, negated: false, options:
32
+ )
33
+ return empty_test if empty_test
34
+
35
+ Assertion.gather_metric_zone_violations(distance_infos, zone)
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ArchUnit
4
+ module Metrics
5
+ # Self-contained metrics report rendering and export.
6
+ module Reporting
7
+ # Immutable options for one metrics HTML report.
8
+ MetricsExportOptions = Data.define(
9
+ :output_path, :title, :include_timestamp, :custom_css
10
+ ) do
11
+ def self.resolve(value)
12
+ return new if value.nil?
13
+ return value if value.is_a?(self)
14
+
15
+ raise ArgumentError, 'options must be a MetricsExportOptions value or nil'
16
+ end
17
+
18
+ def initialize(
19
+ output_path: nil,
20
+ title: 'ArchUnitRuby Metrics Report',
21
+ include_timestamp: true,
22
+ custom_css: nil
23
+ )
24
+ output_path = optional_path(output_path)
25
+ title = immutable_string(title, :title)
26
+ unless [true, false].include?(include_timestamp)
27
+ raise ArgumentError, 'include_timestamp must be true or false'
28
+ end
29
+
30
+ custom_css = optional_string(custom_css, :custom_css)
31
+ super
32
+ end
33
+
34
+ private
35
+
36
+ def optional_path(value)
37
+ return if value.nil?
38
+
39
+ value = value.to_path if value.respond_to?(:to_path)
40
+ immutable_string(value, :output_path)
41
+ end
42
+
43
+ def optional_string(value, attribute)
44
+ return if value.nil?
45
+
46
+ immutable_string(value, attribute)
47
+ end
48
+
49
+ def immutable_string(value, attribute)
50
+ return value.dup.freeze if value.is_a?(String) && !value.empty?
51
+
52
+ raise ArgumentError, "#{attribute} must be a non-empty String"
53
+ end
54
+ end
55
+ end
56
+ end
57
+ end