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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +13 -0
- data/LICENSE +7 -0
- data/README.md +375 -0
- data/lib/archunit/common/assertion/empty_test_violation.rb +50 -0
- data/lib/archunit/common/assertion/violation.rb +14 -0
- data/lib/archunit/common/extraction/edge.rb +46 -0
- data/lib/archunit/common/extraction/graph.rb +73 -0
- data/lib/archunit/common/extraction/import_kind.rb +21 -0
- data/lib/archunit/common/filter.rb +46 -0
- data/lib/archunit/common/fluentapi/check_options.rb +48 -0
- data/lib/archunit/common/fluentapi/checkable.rb +70 -0
- data/lib/archunit/common/logging/check_logger.rb +124 -0
- data/lib/archunit/common/logging/logging_options.rb +57 -0
- data/lib/archunit/common/pattern.rb +99 -0
- data/lib/archunit/common/pattern_matching.rb +76 -0
- data/lib/archunit/common/projection/cycles/johnson_cycles.rb +103 -0
- data/lib/archunit/common/projection/cycles/tarjan_scc.rb +82 -0
- data/lib/archunit/common/projection/edge_projections.rb +50 -0
- data/lib/archunit/common/projection/mapped_edge.rb +26 -0
- data/lib/archunit/common/projection/project_cycles.rb +92 -0
- data/lib/archunit/common/projection/project_edges.rb +69 -0
- data/lib/archunit/common/projection/project_to_nodes.rb +59 -0
- data/lib/archunit/common/projection/projected_edge.rb +35 -0
- data/lib/archunit/common/projection/projected_node.rb +36 -0
- data/lib/archunit/common/regex_factory.rb +96 -0
- data/lib/archunit/error/technical_error.rb +6 -0
- data/lib/archunit/error/user_error.rb +6 -0
- data/lib/archunit/extraction/enumerate_source_files.rb +131 -0
- data/lib/archunit/extraction/extract_dependencies.rb +61 -0
- data/lib/archunit/extraction/extract_graph.rb +89 -0
- data/lib/archunit/extraction/extract_imports.rb +197 -0
- data/lib/archunit/extraction/locate_project.rb +80 -0
- data/lib/archunit/extraction/resolve_import.rb +76 -0
- data/lib/archunit/extraction/resolved_import.rb +46 -0
- data/lib/archunit/files/assertion/custom_file_condition.rb +89 -0
- data/lib/archunit/files/assertion/cycle_free.rb +58 -0
- data/lib/archunit/files/assertion/depend_on_external_modules.rb +116 -0
- data/lib/archunit/files/assertion/depend_on_files.rb +110 -0
- data/lib/archunit/files/assertion/matching_files.rb +87 -0
- data/lib/archunit/files/extraction/extract_file_info.rb +84 -0
- data/lib/archunit/files/extraction/file_info.rb +46 -0
- data/lib/archunit/files/fluentapi/custom_file_condition.rb +73 -0
- data/lib/archunit/files/fluentapi/cycle_free_file_condition.rb +55 -0
- data/lib/archunit/files/fluentapi/depend_on_external_module_condition.rb +69 -0
- data/lib/archunit/files/fluentapi/depend_on_external_module_condition_builder.rb +36 -0
- data/lib/archunit/files/fluentapi/depend_on_file_condition.rb +80 -0
- data/lib/archunit/files/fluentapi/depend_on_file_condition_builder.rb +49 -0
- data/lib/archunit/files/fluentapi/file_condition_builder.rb +71 -0
- data/lib/archunit/files/fluentapi/file_rule_support.rb +24 -0
- data/lib/archunit/files/fluentapi/files.rb +31 -0
- data/lib/archunit/files/fluentapi/match_pattern_file_condition.rb +59 -0
- data/lib/archunit/files/fluentapi/match_pattern_file_condition_builder.rb +66 -0
- data/lib/archunit/files/fluentapi/negated_match_pattern_file_condition_builder.rb +16 -0
- data/lib/archunit/files/fluentapi/positive_match_pattern_file_condition_builder.rb +21 -0
- data/lib/archunit/graph/fluentapi/graph.rb +29 -0
- data/lib/archunit/graph/fluentapi/project_graph_builder.rb +106 -0
- data/lib/archunit/graph/projection/aggregate_edges.rb +48 -0
- data/lib/archunit/graph/projection/collapse_node.rb +33 -0
- data/lib/archunit/graph/projection/create_snapshot.rb +84 -0
- data/lib/archunit/graph/projection/folder_depth_collapse.rb +18 -0
- data/lib/archunit/graph/projection/graph_query_options.rb +81 -0
- data/lib/archunit/graph/projection/graph_report_edge.rb +50 -0
- data/lib/archunit/graph/projection/graph_report_node.rb +22 -0
- data/lib/archunit/graph/projection/graph_report_snapshot.rb +43 -0
- data/lib/archunit/graph/projection/graph_report_summary.rb +20 -0
- data/lib/archunit/graph/projection/node_selection.rb +111 -0
- data/lib/archunit/graph/projection/pattern_collapse.rb +24 -0
- data/lib/archunit/graph/rendering/csv_renderer.rb +30 -0
- data/lib/archunit/graph/rendering/d2_renderer.rb +30 -0
- data/lib/archunit/graph/rendering/dot_renderer.rb +51 -0
- data/lib/archunit/graph/rendering/escaping.rb +31 -0
- data/lib/archunit/graph/rendering/export_report.rb +29 -0
- data/lib/archunit/graph/rendering/graph_renderer.rb +54 -0
- data/lib/archunit/graph/rendering/html_document.rb +46 -0
- data/lib/archunit/graph/rendering/html_renderer.rb +102 -0
- data/lib/archunit/graph/rendering/json_renderer.rb +38 -0
- data/lib/archunit/graph/rendering/mermaid_renderer.rb +33 -0
- data/lib/archunit/graph/rendering/rendering_support.rb +24 -0
- data/lib/archunit/layers/assertion/layer_definition.rb +56 -0
- data/lib/archunit/layers/assertion/layer_dependencies.rb +91 -0
- data/lib/archunit/layers/assertion/layer_dependency_violation.rb +59 -0
- data/lib/archunit/layers/fluentapi/layer_definition_builder.rb +46 -0
- data/lib/archunit/layers/fluentapi/layer_dependency_rule_builder.rb +44 -0
- data/lib/archunit/layers/fluentapi/layered_architecture.rb +133 -0
- data/lib/archunit/layers/fluentapi/layered_architecture_values.rb +51 -0
- data/lib/archunit/layers/fluentapi/layers.rb +29 -0
- data/lib/archunit/metrics/assertion/custom_metric.rb +73 -0
- data/lib/archunit/metrics/assertion/metric_predicate.rb +66 -0
- data/lib/archunit/metrics/assertion/metric_threshold.rb +99 -0
- data/lib/archunit/metrics/assertion/metric_zone.rb +57 -0
- data/lib/archunit/metrics/calculation/count.rb +45 -0
- data/lib/archunit/metrics/calculation/distance.rb +84 -0
- data/lib/archunit/metrics/calculation/lcom.rb +103 -0
- data/lib/archunit/metrics/calculation/metric.rb +52 -0
- data/lib/archunit/metrics/calculation/numeric_value.rb +25 -0
- data/lib/archunit/metrics/extraction/distance_info.rb +60 -0
- data/lib/archunit/metrics/extraction/extract_distance_info.rb +50 -0
- data/lib/archunit/metrics/extraction/extract_project_info.rb +90 -0
- data/lib/archunit/metrics/extraction/metric_info.rb +159 -0
- data/lib/archunit/metrics/extraction/source_metrics_visitor.rb +278 -0
- data/lib/archunit/metrics/fluentapi/count_metrics_builder.rb +49 -0
- data/lib/archunit/metrics/fluentapi/custom_metric_builder.rb +30 -0
- data/lib/archunit/metrics/fluentapi/custom_metric_condition.rb +40 -0
- data/lib/archunit/metrics/fluentapi/distance_metrics_builder.rb +48 -0
- data/lib/archunit/metrics/fluentapi/lcom_metrics_builder.rb +39 -0
- data/lib/archunit/metrics/fluentapi/metric_measurement.rb +36 -0
- data/lib/archunit/metrics/fluentapi/metric_predicate_condition.rb +42 -0
- data/lib/archunit/metrics/fluentapi/metric_report_builder.rb +34 -0
- data/lib/archunit/metrics/fluentapi/metric_selection.rb +54 -0
- data/lib/archunit/metrics/fluentapi/metric_threshold_condition.rb +47 -0
- data/lib/archunit/metrics/fluentapi/metrics.rb +21 -0
- data/lib/archunit/metrics/fluentapi/metrics_builder.rb +136 -0
- data/lib/archunit/metrics/fluentapi/zone_condition.rb +40 -0
- data/lib/archunit/metrics/reporting/metrics_export_options.rb +57 -0
- data/lib/archunit/metrics/reporting/metrics_exporter.rb +130 -0
- data/lib/archunit/slices/assertion/adhere_to_diagram.rb +57 -0
- data/lib/archunit/slices/assertion/contain_dependency.rb +42 -0
- data/lib/archunit/slices/assertion/diagram_adherence_options.rb +37 -0
- data/lib/archunit/slices/assertion/slice_dependency_violation.rb +70 -0
- data/lib/archunit/slices/fluentapi/diagram_slice_condition.rb +65 -0
- data/lib/archunit/slices/fluentapi/diagram_source.rb +36 -0
- data/lib/archunit/slices/fluentapi/forbidden_slice_dependency_condition.rb +57 -0
- data/lib/archunit/slices/fluentapi/negative_slice_condition_builder.rb +27 -0
- data/lib/archunit/slices/fluentapi/positive_slice_condition_builder.rb +51 -0
- data/lib/archunit/slices/fluentapi/slice_scope_builder.rb +83 -0
- data/lib/archunit/slices/fluentapi/slices.rb +29 -0
- data/lib/archunit/slices/projection/slice_projection.rb +76 -0
- data/lib/archunit/slices/projection/slicing_projections.rb +98 -0
- data/lib/archunit/slices/uml/export_diagram.rb +70 -0
- data/lib/archunit/slices/uml/parse_diagram.rb +53 -0
- data/lib/archunit/slices/uml/plant_uml_dependency.rb +25 -0
- data/lib/archunit/slices/uml/plant_uml_diagram.rb +44 -0
- data/lib/archunit/testing/assert_passes.rb +24 -0
- data/lib/archunit/testing/assertion_failure.rb +22 -0
- data/lib/archunit/testing/color_utils.rb +66 -0
- data/lib/archunit/testing/layer_violation_formatter.rb +26 -0
- data/lib/archunit/testing/metric_violation_formatter.rb +58 -0
- data/lib/archunit/testing/minitest_adapter.rb +33 -0
- data/lib/archunit/testing/result_factory.rb +93 -0
- data/lib/archunit/testing/rspec_adapter.rb +74 -0
- data/lib/archunit/testing/slice_violation_formatter.rb +25 -0
- data/lib/archunit/testing/test_result.rb +26 -0
- data/lib/archunit/testing/test_violation.rb +23 -0
- data/lib/archunit/testing/violation_factory.rb +138 -0
- data/lib/archunit/testing.rb +39 -0
- data/lib/archunit/version.rb +5 -0
- data/lib/archunit.rb +110 -0
- 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
|