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,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'metric_info'
|
|
4
|
+
|
|
5
|
+
module ArchUnit
|
|
6
|
+
module Metrics
|
|
7
|
+
module Extraction
|
|
8
|
+
# One source file enriched with project-level dependency coupling.
|
|
9
|
+
DistanceInfo = Data.define(
|
|
10
|
+
:file_info, :afferent_coupling, :efferent_coupling, :project_file_count
|
|
11
|
+
) do
|
|
12
|
+
include ValueValidation
|
|
13
|
+
|
|
14
|
+
def initialize(
|
|
15
|
+
file_info:, afferent_coupling:, efferent_coupling:, project_file_count:
|
|
16
|
+
)
|
|
17
|
+
raise ArgumentError, 'file_info must be a FileInfo value' unless file_info.is_a?(FileInfo)
|
|
18
|
+
|
|
19
|
+
project_file_count = positive_integer(project_file_count, :project_file_count)
|
|
20
|
+
afferent_coupling = coupling_value(
|
|
21
|
+
afferent_coupling, :afferent_coupling, project_file_count
|
|
22
|
+
)
|
|
23
|
+
efferent_coupling = coupling_value(
|
|
24
|
+
efferent_coupling, :efferent_coupling, project_file_count
|
|
25
|
+
)
|
|
26
|
+
super
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def identifier
|
|
30
|
+
file_info.identifier
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def path
|
|
34
|
+
file_info.path
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def type_count
|
|
38
|
+
file_info.type_count
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def abstract_type_count
|
|
42
|
+
file_info.abstract_type_count
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def lines_of_code
|
|
46
|
+
file_info.lines_of_code
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
def coupling_value(value, attribute, file_count)
|
|
52
|
+
value = non_negative_integer(value, attribute)
|
|
53
|
+
return value if value < file_count
|
|
54
|
+
|
|
55
|
+
raise ArgumentError, "#{attribute} cannot exceed the other project files"
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative '../../extraction/extract_graph'
|
|
4
|
+
require_relative 'distance_info'
|
|
5
|
+
require_relative 'extract_project_info'
|
|
6
|
+
|
|
7
|
+
module ArchUnit
|
|
8
|
+
module Metrics
|
|
9
|
+
# Ruby-specific extraction enriched with dependency coupling.
|
|
10
|
+
module Extraction
|
|
11
|
+
module_function
|
|
12
|
+
|
|
13
|
+
# Enriches static file facts with distinct internal incoming/outgoing dependencies.
|
|
14
|
+
def extract_distance_infos(project_locator = nil, options: nil)
|
|
15
|
+
project = extract_project_info(project_locator)
|
|
16
|
+
graph = ArchUnit::Extraction.extract_graph(project.project_root, options:)
|
|
17
|
+
outgoing, incoming = coupling_indexes(graph, project.files.map(&:path))
|
|
18
|
+
build_distance_infos(project, outgoing, incoming)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def build_distance_infos(project, outgoing, incoming)
|
|
22
|
+
project.files.map do |file_info|
|
|
23
|
+
DistanceInfo.new(
|
|
24
|
+
file_info:,
|
|
25
|
+
afferent_coupling: incoming.fetch(file_info.path, []).length,
|
|
26
|
+
efferent_coupling: outgoing.fetch(file_info.path, []).length,
|
|
27
|
+
project_file_count: project.files.length
|
|
28
|
+
)
|
|
29
|
+
end.freeze
|
|
30
|
+
end
|
|
31
|
+
private_class_method :build_distance_infos
|
|
32
|
+
|
|
33
|
+
# rubocop:disable Metrics/AbcSize -- Both directional indexes are built in one graph pass.
|
|
34
|
+
def coupling_indexes(graph, file_paths)
|
|
35
|
+
outgoing = Hash.new { |hash, key| hash[key] = [] }
|
|
36
|
+
incoming = Hash.new { |hash, key| hash[key] = [] }
|
|
37
|
+
graph.each do |edge|
|
|
38
|
+
next if edge.external || edge.source == edge.target
|
|
39
|
+
next unless file_paths.include?(edge.source) && file_paths.include?(edge.target)
|
|
40
|
+
|
|
41
|
+
outgoing[edge.source] |= [edge.target]
|
|
42
|
+
incoming[edge.target] |= [edge.source]
|
|
43
|
+
end
|
|
44
|
+
[outgoing, incoming]
|
|
45
|
+
end
|
|
46
|
+
# rubocop:enable Metrics/AbcSize
|
|
47
|
+
private_class_method :coupling_indexes
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'pathname'
|
|
4
|
+
require 'prism'
|
|
5
|
+
require_relative '../../error/technical_error'
|
|
6
|
+
require_relative '../../extraction/enumerate_source_files'
|
|
7
|
+
require_relative '../../extraction/locate_project'
|
|
8
|
+
require_relative 'metric_info'
|
|
9
|
+
require_relative 'source_metrics_visitor'
|
|
10
|
+
|
|
11
|
+
module ArchUnit
|
|
12
|
+
module Metrics
|
|
13
|
+
# Ruby source extraction for numeric code metrics.
|
|
14
|
+
module Extraction
|
|
15
|
+
module_function
|
|
16
|
+
|
|
17
|
+
def extract_project_info(project_locator = nil, exclude_patterns: nil)
|
|
18
|
+
root = Pathname.new(ArchUnit::Extraction.locate_project(project_locator))
|
|
19
|
+
files = ArchUnit::Extraction.enumerate_source_files(root, exclude_patterns:).map do |path|
|
|
20
|
+
extract_file_info(root.join(path), relative_path: path)
|
|
21
|
+
end
|
|
22
|
+
ProjectInfo.new(project_root: root.to_s, files:)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def extract_file_info(source_file, relative_path: nil)
|
|
26
|
+
path = source_path(source_file)
|
|
27
|
+
identifier = (relative_path || path.basename.to_s).to_s.tr('\\', '/')
|
|
28
|
+
source = path.read
|
|
29
|
+
build_file_info(identifier, source)
|
|
30
|
+
rescue SystemCallError => e
|
|
31
|
+
raise TechnicalError, "could not extract Ruby source metrics: #{e.message}"
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def build_file_info(identifier, source)
|
|
35
|
+
parse_result = Prism.parse(source)
|
|
36
|
+
visitor = SourceMetricsVisitor.new(identifier)
|
|
37
|
+
visitor.visit(parse_result.value) if parse_result.success?
|
|
38
|
+
FileInfo.new(
|
|
39
|
+
path: identifier, lines_of_code: lines_of_code(source), **visitor_counts(visitor)
|
|
40
|
+
)
|
|
41
|
+
end
|
|
42
|
+
private_class_method :build_file_info
|
|
43
|
+
|
|
44
|
+
def visitor_counts(visitor)
|
|
45
|
+
{
|
|
46
|
+
statement_count: visitor.statement_count,
|
|
47
|
+
import_count: visitor.import_count,
|
|
48
|
+
class_count: visitor.class_count,
|
|
49
|
+
function_count: visitor.function_count,
|
|
50
|
+
class_infos: visitor.class_infos,
|
|
51
|
+
type_count: visitor.type_count,
|
|
52
|
+
abstract_type_count: visitor.abstract_type_count
|
|
53
|
+
}
|
|
54
|
+
end
|
|
55
|
+
private_class_method :visitor_counts
|
|
56
|
+
|
|
57
|
+
# rubocop:disable Metrics/MethodLength -- The state tracks Ruby's =begin/=end comments.
|
|
58
|
+
def lines_of_code(source)
|
|
59
|
+
in_block_comment = false
|
|
60
|
+
source.each_line.count do |line|
|
|
61
|
+
stripped = line.strip
|
|
62
|
+
if stripped == '=begin'
|
|
63
|
+
in_block_comment = true
|
|
64
|
+
false
|
|
65
|
+
elsif stripped == '=end' && in_block_comment
|
|
66
|
+
in_block_comment = false
|
|
67
|
+
false
|
|
68
|
+
else
|
|
69
|
+
!in_block_comment && !stripped.empty? && !stripped.start_with?('#')
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
# rubocop:enable Metrics/MethodLength
|
|
74
|
+
private_class_method :lines_of_code
|
|
75
|
+
|
|
76
|
+
def source_path(value)
|
|
77
|
+
value = value.to_path if value.respond_to?(:to_path)
|
|
78
|
+
unless value.is_a?(String) && !value.empty?
|
|
79
|
+
raise ArgumentError, 'source_file must be a non-empty path'
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
path = Pathname.new(value).expand_path
|
|
83
|
+
raise ArgumentError, 'source_file must be an existing file' unless path.file?
|
|
84
|
+
|
|
85
|
+
path
|
|
86
|
+
end
|
|
87
|
+
private_class_method :source_path
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ArchUnit
|
|
4
|
+
module Metrics
|
|
5
|
+
# Immutable Ruby source facts used by metric calculations.
|
|
6
|
+
module Extraction
|
|
7
|
+
# Shared constructor validation for the immutable extraction values.
|
|
8
|
+
module ValueValidation
|
|
9
|
+
private
|
|
10
|
+
|
|
11
|
+
def immutable_string(value, attribute, normalize_path: false)
|
|
12
|
+
valid = value.is_a?(String) && !value.empty?
|
|
13
|
+
raise ArgumentError, "#{attribute} must be a non-empty String" unless valid
|
|
14
|
+
|
|
15
|
+
value = value.tr('\\', '/') if normalize_path
|
|
16
|
+
value.dup.freeze
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def immutable_names(values, attribute)
|
|
20
|
+
names = Array(values).map do |value|
|
|
21
|
+
immutable_string(value, attribute)
|
|
22
|
+
end
|
|
23
|
+
names.uniq.sort.freeze
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def immutable_values(values, type, attribute)
|
|
27
|
+
result = Array(values).dup
|
|
28
|
+
unless result.all?(type)
|
|
29
|
+
type_name = type.name.split('::').last
|
|
30
|
+
raise ArgumentError, "#{attribute} must contain only #{type_name} values"
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
result.freeze
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def non_negative_integer(value, attribute)
|
|
37
|
+
return value if value.is_a?(Integer) && value >= 0
|
|
38
|
+
|
|
39
|
+
raise ArgumentError, "#{attribute} must be a non-negative Integer"
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def positive_integer(value, attribute)
|
|
43
|
+
return value if value.is_a?(Integer) && value.positive?
|
|
44
|
+
|
|
45
|
+
raise ArgumentError, "#{attribute} must be a positive Integer"
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
private_constant :ValueValidation
|
|
49
|
+
|
|
50
|
+
# One method and the instance fields it reads or writes.
|
|
51
|
+
MethodInfo = Data.define(:name, :accessed_fields) do
|
|
52
|
+
include ValueValidation
|
|
53
|
+
|
|
54
|
+
def initialize(name:, accessed_fields: [])
|
|
55
|
+
name = immutable_string(name, :name)
|
|
56
|
+
accessed_fields = immutable_names(accessed_fields, :accessed_fields)
|
|
57
|
+
super
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# One instance field and the methods that read or write it.
|
|
62
|
+
FieldInfo = Data.define(:name, :accessed_by) do
|
|
63
|
+
include ValueValidation
|
|
64
|
+
|
|
65
|
+
def initialize(name:, accessed_by: [])
|
|
66
|
+
name = immutable_string(name, :name)
|
|
67
|
+
accessed_by = immutable_names(accessed_by, :accessed_by)
|
|
68
|
+
super
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Static information about one Ruby class declaration.
|
|
73
|
+
# rubocop:disable Lint/DataDefineOverride -- `methods` is the established metric vocabulary.
|
|
74
|
+
ClassInfo = Data.define(:name, :file_path, :methods, :fields) do
|
|
75
|
+
include ValueValidation
|
|
76
|
+
|
|
77
|
+
def initialize(name:, file_path:, methods: [], fields: [])
|
|
78
|
+
name = immutable_string(name, :name)
|
|
79
|
+
file_path = immutable_string(file_path, :file_path, normalize_path: true)
|
|
80
|
+
methods = immutable_values(methods, MethodInfo, :methods)
|
|
81
|
+
fields = immutable_values(fields, FieldInfo, :fields)
|
|
82
|
+
super
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def identifier
|
|
86
|
+
"#{file_path}:#{name}"
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
# rubocop:enable Lint/DataDefineOverride
|
|
90
|
+
|
|
91
|
+
# Static counts and class declarations extracted from one Ruby source file.
|
|
92
|
+
FileInfo = Data.define(
|
|
93
|
+
:path,
|
|
94
|
+
:lines_of_code,
|
|
95
|
+
:statement_count,
|
|
96
|
+
:import_count,
|
|
97
|
+
:class_count,
|
|
98
|
+
:function_count,
|
|
99
|
+
:class_infos,
|
|
100
|
+
:type_count,
|
|
101
|
+
:abstract_type_count
|
|
102
|
+
) do
|
|
103
|
+
include ValueValidation
|
|
104
|
+
|
|
105
|
+
# rubocop:disable Metrics/MethodLength, Metrics/ParameterLists -- Independent count facts.
|
|
106
|
+
def initialize(
|
|
107
|
+
path:,
|
|
108
|
+
lines_of_code:,
|
|
109
|
+
statement_count:,
|
|
110
|
+
import_count:,
|
|
111
|
+
class_count:,
|
|
112
|
+
function_count:,
|
|
113
|
+
class_infos: [],
|
|
114
|
+
type_count: nil,
|
|
115
|
+
abstract_type_count: 0
|
|
116
|
+
)
|
|
117
|
+
path = immutable_string(path, :path, normalize_path: true)
|
|
118
|
+
lines_of_code = non_negative_integer(lines_of_code, :lines_of_code)
|
|
119
|
+
statement_count = non_negative_integer(statement_count, :statement_count)
|
|
120
|
+
import_count = non_negative_integer(import_count, :import_count)
|
|
121
|
+
class_count = non_negative_integer(class_count, :class_count)
|
|
122
|
+
function_count = non_negative_integer(function_count, :function_count)
|
|
123
|
+
class_infos = immutable_values(class_infos, ClassInfo, :class_infos)
|
|
124
|
+
type_count = non_negative_integer(type_count || class_count, :type_count)
|
|
125
|
+
abstract_type_count = non_negative_integer(
|
|
126
|
+
abstract_type_count, :abstract_type_count
|
|
127
|
+
)
|
|
128
|
+
if abstract_type_count > type_count
|
|
129
|
+
raise ArgumentError, 'abstract_type_count cannot exceed type_count'
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
super
|
|
133
|
+
end
|
|
134
|
+
# rubocop:enable Metrics/MethodLength, Metrics/ParameterLists
|
|
135
|
+
|
|
136
|
+
def identifier
|
|
137
|
+
path
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# Complete immutable metric input for one located Ruby project.
|
|
142
|
+
ProjectInfo = Data.define(:project_root, :files) do
|
|
143
|
+
include ValueValidation
|
|
144
|
+
|
|
145
|
+
def initialize(project_root:, files: [])
|
|
146
|
+
project_root = immutable_string(
|
|
147
|
+
project_root, :project_root, normalize_path: true
|
|
148
|
+
)
|
|
149
|
+
files = immutable_values(files, FileInfo, :files)
|
|
150
|
+
super
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def classes
|
|
154
|
+
files.flat_map(&:class_infos).freeze
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
end
|
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'prism'
|
|
4
|
+
require_relative 'metric_info'
|
|
5
|
+
|
|
6
|
+
module ArchUnit
|
|
7
|
+
module Metrics
|
|
8
|
+
module Extraction
|
|
9
|
+
# Collects Ruby-specific count and class-cohesion facts from a Prism tree.
|
|
10
|
+
# rubocop:disable Metrics/ClassLength -- One visitor owns the state of a single AST walk.
|
|
11
|
+
class SourceMetricsVisitor < Prism::Visitor
|
|
12
|
+
IMPORT_METHODS = %i[autoload load require require_relative].freeze
|
|
13
|
+
ATTRIBUTE_METHODS = %i[attr_accessor attr_reader attr_writer].freeze
|
|
14
|
+
INSTANCE_VARIABLE_NODES = %i[
|
|
15
|
+
visit_instance_variable_and_write_node
|
|
16
|
+
visit_instance_variable_operator_write_node
|
|
17
|
+
visit_instance_variable_or_write_node
|
|
18
|
+
visit_instance_variable_read_node
|
|
19
|
+
visit_instance_variable_target_node
|
|
20
|
+
visit_instance_variable_write_node
|
|
21
|
+
].freeze
|
|
22
|
+
|
|
23
|
+
attr_reader :class_infos, :statement_count, :import_count, :class_count, :function_count,
|
|
24
|
+
:type_count, :abstract_type_count
|
|
25
|
+
|
|
26
|
+
def initialize(file_path)
|
|
27
|
+
super()
|
|
28
|
+
@file_path = file_path
|
|
29
|
+
@class_infos = []
|
|
30
|
+
reset_context
|
|
31
|
+
reset_counts
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def reset_context
|
|
35
|
+
@class_stack = []
|
|
36
|
+
@method_stack = []
|
|
37
|
+
@namespace_stack = []
|
|
38
|
+
@singleton_class_depth = 0
|
|
39
|
+
end
|
|
40
|
+
private :reset_context
|
|
41
|
+
|
|
42
|
+
def reset_counts
|
|
43
|
+
@statement_count = 0
|
|
44
|
+
@import_count = 0
|
|
45
|
+
@class_count = 0
|
|
46
|
+
@function_count = 0
|
|
47
|
+
@type_count = 0
|
|
48
|
+
@abstract_type_count = 0
|
|
49
|
+
end
|
|
50
|
+
private :reset_counts
|
|
51
|
+
|
|
52
|
+
def visit_statements_node(node)
|
|
53
|
+
@statement_count += node.body.length
|
|
54
|
+
super
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def visit_call_node(node)
|
|
58
|
+
@import_count += 1 if import_call?(node)
|
|
59
|
+
record_attribute_methods(node)
|
|
60
|
+
super
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def visit_class_node(node)
|
|
64
|
+
@class_count += 1
|
|
65
|
+
@type_count += 1
|
|
66
|
+
@abstract_type_count += 1 if abstract_class?(node)
|
|
67
|
+
with_class(qualified_name(node.constant_path)) { super }
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def visit_module_node(node)
|
|
71
|
+
if module_type?(node)
|
|
72
|
+
@type_count += 1
|
|
73
|
+
@abstract_type_count += 1
|
|
74
|
+
end
|
|
75
|
+
with_namespace(qualified_name(node.constant_path)) { super }
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def visit_singleton_class_node(node)
|
|
79
|
+
@singleton_class_depth += 1
|
|
80
|
+
super
|
|
81
|
+
ensure
|
|
82
|
+
@singleton_class_depth -= 1
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# rubocop:disable Metrics/MethodLength -- The ensure keeps nested visitor state balanced.
|
|
86
|
+
def visit_def_node(node)
|
|
87
|
+
active_class = active_class_accumulator
|
|
88
|
+
unless active_class
|
|
89
|
+
@function_count += 1 if current_namespace.nil?
|
|
90
|
+
return super
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
method_name = qualified_method_name(node)
|
|
94
|
+
method = active_class[:methods][method_name] ||= Set.new
|
|
95
|
+
@method_stack << [active_class, method]
|
|
96
|
+
super
|
|
97
|
+
ensure
|
|
98
|
+
@method_stack.pop if active_class
|
|
99
|
+
end
|
|
100
|
+
# rubocop:enable Metrics/MethodLength
|
|
101
|
+
|
|
102
|
+
INSTANCE_VARIABLE_NODES.each do |visitor_method|
|
|
103
|
+
define_method(visitor_method) do |node|
|
|
104
|
+
record_field_access(node.name)
|
|
105
|
+
super(node)
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
private
|
|
110
|
+
|
|
111
|
+
def with_class(name)
|
|
112
|
+
accumulator = { name:, methods: Hash.new { |hash, key| hash[key] = Set.new } }
|
|
113
|
+
@class_stack << [name, accumulator]
|
|
114
|
+
@namespace_stack << name
|
|
115
|
+
yield
|
|
116
|
+
@class_infos << build_class_info(accumulator)
|
|
117
|
+
ensure
|
|
118
|
+
@namespace_stack.pop
|
|
119
|
+
@class_stack.pop
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def with_namespace(name)
|
|
123
|
+
@namespace_stack << name
|
|
124
|
+
yield
|
|
125
|
+
ensure
|
|
126
|
+
@namespace_stack.pop
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def current_namespace
|
|
130
|
+
@namespace_stack.last
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def qualified_method_name(node)
|
|
134
|
+
return "self.#{node.name}" if node.receiver || @singleton_class_depth.positive?
|
|
135
|
+
|
|
136
|
+
node.name.to_s
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def active_class_accumulator
|
|
140
|
+
namespace, accumulator = @class_stack.last
|
|
141
|
+
accumulator if namespace == current_namespace
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def qualified_name(constant_path)
|
|
145
|
+
name = if constant_path.respond_to?(:full_name)
|
|
146
|
+
constant_path.full_name
|
|
147
|
+
else
|
|
148
|
+
constant_path.location.slice
|
|
149
|
+
end
|
|
150
|
+
name = name.delete_prefix('::')
|
|
151
|
+
return name if name.include?('::') || current_namespace.nil?
|
|
152
|
+
|
|
153
|
+
"#{current_namespace}::#{name}"
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def import_call?(node)
|
|
157
|
+
return false unless IMPORT_METHODS.include?(node.name)
|
|
158
|
+
return false unless supported_import_receiver?(node)
|
|
159
|
+
|
|
160
|
+
arguments = node.arguments&.arguments || []
|
|
161
|
+
expected_count = node.name == :autoload ? 2 : 1
|
|
162
|
+
arguments.length == expected_count
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def supported_import_receiver?(node)
|
|
166
|
+
return true if node.receiver.nil?
|
|
167
|
+
return constant_receiver?(node.receiver) if node.name == :autoload
|
|
168
|
+
|
|
169
|
+
node.receiver.respond_to?(:full_name) &&
|
|
170
|
+
node.receiver.full_name.delete_prefix('::') == 'Kernel'
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def constant_receiver?(receiver)
|
|
174
|
+
receiver.is_a?(Prism::ConstantReadNode) || receiver.is_a?(Prism::ConstantPathNode)
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def abstract_class?(node)
|
|
178
|
+
direct_statements(node).any? { |statement| abstract_method?(statement) }
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def module_type?(node)
|
|
182
|
+
direct_statements(node).any? do |statement|
|
|
183
|
+
instance_method?(statement) || attribute_declaration?(statement)
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def direct_statements(node)
|
|
188
|
+
body = node.body
|
|
189
|
+
body.is_a?(Prism::StatementsNode) ? body.body : []
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def instance_method?(node)
|
|
193
|
+
node.is_a?(Prism::DefNode) && node.receiver.nil?
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def attribute_declaration?(node)
|
|
197
|
+
node.is_a?(Prism::CallNode) && node.receiver.nil? &&
|
|
198
|
+
ATTRIBUTE_METHODS.include?(node.name)
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def abstract_method?(node)
|
|
202
|
+
return false unless instance_method?(node)
|
|
203
|
+
|
|
204
|
+
body = node.body
|
|
205
|
+
statements = body.is_a?(Prism::StatementsNode) ? body.body : []
|
|
206
|
+
statements.one? && raises_not_implemented?(statements.first)
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def raises_not_implemented?(node)
|
|
210
|
+
return false unless node.is_a?(Prism::CallNode)
|
|
211
|
+
return false unless %i[fail raise].include?(node.name) && node.receiver.nil?
|
|
212
|
+
|
|
213
|
+
error = node.arguments&.arguments&.first
|
|
214
|
+
error.respond_to?(:full_name) &&
|
|
215
|
+
error.full_name.delete_prefix('::') == 'NotImplementedError'
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def record_attribute_methods(node)
|
|
219
|
+
accumulator = active_class_accumulator
|
|
220
|
+
return unless accumulator && @method_stack.empty?
|
|
221
|
+
return unless ATTRIBUTE_METHODS.include?(node.name)
|
|
222
|
+
|
|
223
|
+
attribute_names(node).each do |field_name|
|
|
224
|
+
add_attribute_reader(accumulator, field_name) unless node.name == :attr_writer
|
|
225
|
+
add_attribute_writer(accumulator, field_name) unless node.name == :attr_reader
|
|
226
|
+
end
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
def attribute_names(node)
|
|
230
|
+
(node.arguments&.arguments || []).filter_map do |argument|
|
|
231
|
+
supported = argument.is_a?(Prism::SymbolNode) ||
|
|
232
|
+
argument.is_a?(Prism::StringNode)
|
|
233
|
+
argument.unescaped.to_s if supported
|
|
234
|
+
end
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
def add_attribute_reader(accumulator, field_name)
|
|
238
|
+
accumulator[:methods][field_name] << field_name
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
def add_attribute_writer(accumulator, field_name)
|
|
242
|
+
accumulator[:methods]["#{field_name}="] << field_name
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
def record_field_access(variable_name)
|
|
246
|
+
accumulator, method = @method_stack.last
|
|
247
|
+
return if accumulator.nil? || !accumulator.equal?(active_class_accumulator)
|
|
248
|
+
|
|
249
|
+
method << variable_name.to_s.delete_prefix('@')
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
def build_class_info(accumulator)
|
|
253
|
+
methods = build_method_infos(accumulator)
|
|
254
|
+
fields = build_field_infos(methods)
|
|
255
|
+
ClassInfo.new(name: accumulator[:name], file_path: @file_path, methods:, fields:)
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
def build_method_infos(accumulator)
|
|
259
|
+
accumulator[:methods].sort.map do |name, fields|
|
|
260
|
+
MethodInfo.new(name:, accessed_fields: fields.to_a)
|
|
261
|
+
end
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
def build_field_infos(methods)
|
|
265
|
+
field_accesses = Hash.new { |hash, key| hash[key] = [] }
|
|
266
|
+
methods.each do |method|
|
|
267
|
+
method.accessed_fields.each { |field| field_accesses[field] << method.name }
|
|
268
|
+
end
|
|
269
|
+
field_accesses.sort.map do |name, method_names|
|
|
270
|
+
FieldInfo.new(name:, accessed_by: method_names)
|
|
271
|
+
end
|
|
272
|
+
end
|
|
273
|
+
end
|
|
274
|
+
# rubocop:enable Metrics/ClassLength
|
|
275
|
+
private_constant :SourceMetricsVisitor
|
|
276
|
+
end
|
|
277
|
+
end
|
|
278
|
+
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative '../calculation/count'
|
|
4
|
+
require_relative 'metric_report_builder'
|
|
5
|
+
require_relative 'metric_selection'
|
|
6
|
+
|
|
7
|
+
module ArchUnit
|
|
8
|
+
module Metrics
|
|
9
|
+
module FluentApi
|
|
10
|
+
# Fluent count-metric names for one immutable metrics scope.
|
|
11
|
+
class CountMetricsBuilder
|
|
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::Count::CLASS_METRICS.each_key do |name|
|
|
24
|
+
define_method(name) do
|
|
25
|
+
MetricSelection.new(scope:, metric: Calculation::Count.public_send(name))
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
Calculation::Count::FILE_METRICS.each_key do |name|
|
|
30
|
+
define_method(name) do
|
|
31
|
+
MetricSelection.new(scope:, metric: Calculation::Count.public_send(name))
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
def report_metrics
|
|
38
|
+
class_metrics = Calculation::Count::CLASS_METRICS.each_key.map do |name|
|
|
39
|
+
Calculation::Count.public_send(name)
|
|
40
|
+
end
|
|
41
|
+
file_metrics = Calculation::Count::FILE_METRICS.each_key.map do |name|
|
|
42
|
+
Calculation::Count.public_send(name)
|
|
43
|
+
end
|
|
44
|
+
class_metrics + file_metrics
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|