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,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