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,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../../common/extraction/graph'
4
+ require_relative 'aggregate_edges'
5
+ require_relative 'collapse_node'
6
+ require_relative 'graph_query_options'
7
+ require_relative 'graph_report_snapshot'
8
+ require_relative 'node_selection'
9
+
10
+ module ArchUnit
11
+ module GraphReporting
12
+ module Projection
13
+ DEFAULT_TITLE = 'ArchUnitRuby Dependency Graph'
14
+
15
+ # Builds the one immutable representation consumed by every graph renderer.
16
+ class SnapshotFactory
17
+ class << self
18
+ def create(graph, options = nil)
19
+ validate_graph(graph)
20
+ options = GraphQueryOptions.resolve(options)
21
+ query_edges = external_query_edges(graph, options)
22
+ selected_nodes = NodeSelection.select(query_edges, options)
23
+ raw_edges = selected_edges(query_edges, selected_nodes, options)
24
+ report_edges = aggregate_edges(raw_edges, options)
25
+ report_nodes = build_nodes(selected_nodes, report_edges, options.collapse)
26
+
27
+ build_snapshot(options, report_nodes, report_edges, raw_edges)
28
+ end
29
+
30
+ private
31
+
32
+ def validate_graph(value)
33
+ return if value.is_a?(Common::Extraction::Graph)
34
+
35
+ raise ArgumentError, 'graph must be a Graph value'
36
+ end
37
+
38
+ def external_query_edges(graph, options)
39
+ return graph.to_a if options.include_external_dependencies
40
+
41
+ graph.reject(&:external)
42
+ end
43
+
44
+ def selected_edges(edges, selected_nodes, options)
45
+ edges.select do |edge|
46
+ (options.include_self_dependencies || edge.source != edge.target) &&
47
+ selected_nodes.include?(edge.source) && selected_nodes.include?(edge.target)
48
+ end
49
+ end
50
+
51
+ def aggregate_edges(raw_edges, options)
52
+ AggregateEdges.aggregate(
53
+ raw_edges,
54
+ collapse: options.collapse,
55
+ include_self_dependencies: options.include_self_dependencies
56
+ )
57
+ end
58
+
59
+ def build_nodes(selected_nodes, report_edges, collapse)
60
+ labels = selected_nodes.map { |node| CollapseNode.collapse(node, collapse) }
61
+ labels.concat(report_edges.flat_map { |edge| [edge.source, edge.target] })
62
+ labels.uniq.sort.each_with_index.map do |label, index|
63
+ GraphReportNode.new(id: "n#{index}", label:)
64
+ end.freeze
65
+ end
66
+
67
+ def build_snapshot(options, nodes, edges, raw_edges)
68
+ GraphReportSnapshot.new(
69
+ title: options.title || DEFAULT_TITLE,
70
+ nodes:, edges:,
71
+ summary: GraphReportSummary.new(
72
+ node_count: nodes.length, edge_count: edges.length,
73
+ raw_edge_count: raw_edges.length,
74
+ external_edge_count: raw_edges.count(&:external)
75
+ )
76
+ )
77
+ end
78
+ end
79
+
80
+ private_class_method :new
81
+ end
82
+ end
83
+ end
84
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ArchUnit
4
+ module GraphReporting
5
+ module Projection
6
+ # Collapse file nodes to their folder path at a fixed depth.
7
+ FolderDepthCollapse = Data.define(:depth) do
8
+ def initialize(depth:)
9
+ unless depth.is_a?(Integer) && depth.positive?
10
+ raise ArgumentError, 'depth must be a positive Integer'
11
+ end
12
+
13
+ super
14
+ end
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../../common/filter'
4
+ require_relative 'folder_depth_collapse'
5
+ require_relative 'pattern_collapse'
6
+
7
+ module ArchUnit
8
+ module GraphReporting
9
+ module Projection
10
+ # Immutable selection, collapsing, and presentation options for a graph snapshot.
11
+ GraphQueryOptions = Data.define(
12
+ :include_external_dependencies, :include_self_dependencies,
13
+ :focus, :focus_depth, :reachable_from, :dependents_of,
14
+ :collapse, :title
15
+ ) do
16
+ def self.resolve(value)
17
+ return new if value.nil?
18
+ return value if value.is_a?(self)
19
+
20
+ raise ArgumentError, 'options must be a GraphQueryOptions value or nil'
21
+ end
22
+
23
+ # The options bag deliberately mirrors the eight independent fluent modifiers.
24
+ # rubocop:disable Metrics/ParameterLists
25
+ def initialize(
26
+ include_external_dependencies: false, include_self_dependencies: false,
27
+ focus: nil, focus_depth: 1, reachable_from: nil, dependents_of: nil,
28
+ collapse: nil, title: nil
29
+ )
30
+ validate_boolean(include_external_dependencies, :include_external_dependencies)
31
+ validate_boolean(include_self_dependencies, :include_self_dependencies)
32
+ validate_filter(focus, :focus)
33
+ validate_focus_depth(focus_depth)
34
+ validate_filter(reachable_from, :reachable_from)
35
+ validate_filter(dependents_of, :dependents_of)
36
+ validate_collapse(collapse)
37
+ title = immutable_title(title)
38
+ super
39
+ end
40
+ # rubocop:enable Metrics/ParameterLists
41
+
42
+ def with(**overrides)
43
+ self.class.new(**to_h, **overrides)
44
+ end
45
+
46
+ private
47
+
48
+ def validate_boolean(value, attribute)
49
+ return if [true, false].include?(value)
50
+
51
+ raise ArgumentError, "#{attribute} must be true or false"
52
+ end
53
+
54
+ def validate_filter(value, attribute)
55
+ return if value.nil? || value.is_a?(Common::Filter)
56
+
57
+ raise ArgumentError, "#{attribute} must be a Filter or nil"
58
+ end
59
+
60
+ def validate_focus_depth(value)
61
+ return if value.is_a?(Integer) && !value.negative?
62
+
63
+ raise ArgumentError, 'focus_depth must be a non-negative Integer'
64
+ end
65
+
66
+ def validate_collapse(value)
67
+ return if value.nil? || value.is_a?(FolderDepthCollapse) || value.is_a?(PatternCollapse)
68
+
69
+ raise ArgumentError, 'collapse must be a graph collapse strategy or nil'
70
+ end
71
+
72
+ def immutable_title(value)
73
+ return if value.nil?
74
+ return value.dup.freeze if value.is_a?(String) && !value.strip.empty?
75
+
76
+ raise ArgumentError, 'title must be a non-empty String or nil'
77
+ end
78
+ end
79
+ end
80
+ end
81
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../../common/extraction/import_kind'
4
+
5
+ module ArchUnit
6
+ module GraphReporting
7
+ module Projection
8
+ # Aggregated dependency evidence in a graph report snapshot.
9
+ GraphReportEdge = Data.define(:source, :target, :count, :external, :import_kinds) do
10
+ def initialize(source:, target:, count:, external:, import_kinds: [])
11
+ source = immutable_string(source, :source)
12
+ target = immutable_string(target, :target)
13
+ validate_count(count)
14
+ validate_external(external)
15
+ import_kinds = immutable_import_kinds(import_kinds)
16
+ super
17
+ end
18
+
19
+ private
20
+
21
+ def immutable_string(value, attribute)
22
+ return value.dup.freeze if value.is_a?(String) && !value.empty?
23
+
24
+ raise ArgumentError, "#{attribute} must be a non-empty String"
25
+ end
26
+
27
+ def validate_count(value)
28
+ return if value.is_a?(Integer) && value.positive?
29
+
30
+ raise ArgumentError, 'count must be a positive Integer'
31
+ end
32
+
33
+ def validate_external(value)
34
+ return if [true, false].include?(value)
35
+
36
+ raise ArgumentError, 'external must be true or false'
37
+ end
38
+
39
+ def immutable_import_kinds(values)
40
+ kinds = Array(values).uniq.sort_by(&:to_s)
41
+ unless kinds.all? { |kind| Common::Extraction::ImportKind.valid?(kind) }
42
+ raise ArgumentError, 'import_kinds contains an unknown import kind'
43
+ end
44
+
45
+ kinds.freeze
46
+ end
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ArchUnit
4
+ module GraphReporting
5
+ module Projection
6
+ # Stable node identity and display label in a graph report snapshot.
7
+ GraphReportNode = Data.define(:id, :label) do
8
+ def initialize(id:, label:)
9
+ super(id: immutable_string(id, :id), label: immutable_string(label, :label))
10
+ end
11
+
12
+ private
13
+
14
+ def immutable_string(value, attribute)
15
+ return value.dup.freeze if value.is_a?(String) && !value.empty?
16
+
17
+ raise ArgumentError, "#{attribute} must be a non-empty String"
18
+ end
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'graph_report_edge'
4
+ require_relative 'graph_report_node'
5
+ require_relative 'graph_report_summary'
6
+
7
+ module ArchUnit
8
+ module GraphReporting
9
+ module Projection
10
+ # Immutable graph after filtering, collapsing, aggregation, and counting.
11
+ GraphReportSnapshot = Data.define(:title, :nodes, :edges, :summary) do
12
+ def initialize(title:, nodes:, edges:, summary:)
13
+ title = immutable_title(title)
14
+ nodes = immutable_values(nodes, GraphReportNode, :nodes)
15
+ edges = immutable_values(edges, GraphReportEdge, :edges)
16
+ unless summary.is_a?(GraphReportSummary)
17
+ raise ArgumentError, 'summary must be a GraphReportSummary'
18
+ end
19
+
20
+ super
21
+ end
22
+
23
+ private
24
+
25
+ def immutable_title(value)
26
+ return value.dup.freeze if value.is_a?(String) && !value.strip.empty?
27
+
28
+ raise ArgumentError, 'title must be a non-empty String'
29
+ end
30
+
31
+ def immutable_values(values, type, attribute)
32
+ entries = Array(values).dup
33
+ unless entries.all?(type)
34
+ type_name = type.name.split('::').last
35
+ raise ArgumentError, "#{attribute} must contain only #{type_name} values"
36
+ end
37
+
38
+ entries.freeze
39
+ end
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ArchUnit
4
+ module GraphReporting
5
+ module Projection
6
+ # Counts describing the selected and aggregated graph snapshot.
7
+ GraphReportSummary = Data.define(
8
+ :node_count, :edge_count, :raw_edge_count, :external_edge_count
9
+ ) do
10
+ def initialize(node_count:, edge_count:, raw_edge_count:, external_edge_count:)
11
+ values = { node_count:, edge_count:, raw_edge_count:, external_edge_count: }
12
+ invalid = values.find { |_name, value| !value.is_a?(Integer) || value.negative? }
13
+ raise ArgumentError, "#{invalid.first} must be a non-negative Integer" if invalid
14
+
15
+ super
16
+ end
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,111 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../../common/pattern_matching'
4
+
5
+ module ArchUnit
6
+ module GraphReporting
7
+ module Projection
8
+ # Pure graph traversal for focus, reachability, and dependent queries.
9
+ module NodeSelection
10
+ module_function
11
+
12
+ def select(edges, options)
13
+ return all_nodes(edges) unless query?(options)
14
+
15
+ selected = Set.new
16
+ selected.merge(expand_focus(edges, options.focus, options.focus_depth)) if options.focus
17
+ selected.merge(walk(edges, options.reachable_from, :outgoing)) if options.reachable_from
18
+ selected.merge(walk(edges, options.dependents_of, :incoming)) if options.dependents_of
19
+ selected
20
+ end
21
+
22
+ def query?(options)
23
+ options.focus || options.reachable_from || options.dependents_of
24
+ end
25
+ private_class_method :query?
26
+
27
+ def all_nodes(edges)
28
+ edges.each_with_object(Set.new) do |edge, nodes|
29
+ nodes.add(edge.source).add(edge.target)
30
+ end
31
+ end
32
+ private_class_method :all_nodes
33
+
34
+ def expand_focus(edges, filter, depth)
35
+ selected = matching_nodes(edges, filter)
36
+ queue = selected.map { |node| [node, 0] }
37
+
38
+ until queue.empty?
39
+ current, current_depth = queue.shift
40
+ next if current_depth >= depth
41
+
42
+ add_neighbors(edges, current, current_depth, selected, queue)
43
+ end
44
+ selected
45
+ end
46
+ private_class_method :expand_focus
47
+
48
+ def add_neighbors(edges, current, current_depth, selected, queue)
49
+ neighbors_of(edges, current).each do |neighbor|
50
+ next if selected.include?(neighbor)
51
+
52
+ selected.add(neighbor)
53
+ queue << [neighbor, current_depth + 1]
54
+ end
55
+ end
56
+ private_class_method :add_neighbors
57
+
58
+ def walk(edges, filter, direction)
59
+ selected = matching_nodes(edges, filter)
60
+ queue = selected.to_a
61
+
62
+ until queue.empty?
63
+ current = queue.shift
64
+ add_next_nodes(edges, current, direction, selected, queue)
65
+ end
66
+ selected
67
+ end
68
+ private_class_method :walk
69
+
70
+ def add_next_nodes(edges, current, direction, selected, queue)
71
+ next_nodes(edges, current, direction).each do |node|
72
+ next if selected.include?(node)
73
+
74
+ selected.add(node)
75
+ queue << node
76
+ end
77
+ end
78
+ private_class_method :add_next_nodes
79
+
80
+ def matching_nodes(edges, filter)
81
+ all_nodes(edges).select do |node|
82
+ Common::PatternMatching.matches_pattern?(node, filter)
83
+ end.to_set
84
+ end
85
+ private_class_method :matching_nodes
86
+
87
+ def neighbors_of(edges, node)
88
+ edges.filter_map do |edge|
89
+ if edge.source == node && edge.target != node
90
+ edge.target
91
+ elsif edge.target == node && edge.source != node
92
+ edge.source
93
+ end
94
+ end.uniq
95
+ end
96
+ private_class_method :neighbors_of
97
+
98
+ def next_nodes(edges, node, direction)
99
+ edges.filter_map do |edge|
100
+ if direction == :outgoing && edge.source == node
101
+ edge.target
102
+ elsif direction == :incoming && edge.target == node
103
+ edge.source
104
+ end
105
+ end.uniq
106
+ end
107
+ private_class_method :next_nodes
108
+ end
109
+ end
110
+ end
111
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ArchUnit
4
+ module GraphReporting
5
+ module Projection
6
+ # Collapse nodes by applying a regular-expression replacement.
7
+ PatternCollapse = Data.define(:regexp, :replacement) do
8
+ def initialize(regexp:, replacement: '\\1')
9
+ raise ArgumentError, 'regexp must be a Regexp' unless regexp.is_a?(Regexp)
10
+ raise ArgumentError, 'replacement must be a String' unless replacement.is_a?(String)
11
+
12
+ super(regexp: regexp.dup.freeze, replacement: replacement.dup.freeze)
13
+ end
14
+
15
+ def self.from(pattern, replacement = '\\1')
16
+ regexp = pattern.is_a?(String) ? Regexp.new(pattern) : pattern
17
+ new(regexp:, replacement:)
18
+ rescue RegexpError => e
19
+ raise ArgumentError, "invalid collapse pattern: #{e.message}"
20
+ end
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'csv'
4
+ require_relative 'rendering_support'
5
+
6
+ module ArchUnit
7
+ module GraphReporting
8
+ module Rendering
9
+ # Renders aggregated graph edges as standards-compliant CSV.
10
+ module CsvRenderer
11
+ HEADERS = %w[source target count external import_kinds].freeze
12
+
13
+ module_function
14
+
15
+ def render(snapshot)
16
+ snapshot = RenderingSupport.validate_snapshot(snapshot)
17
+ CSV.generate(row_sep: "\n") do |csv|
18
+ csv << HEADERS
19
+ snapshot.edges.each do |edge|
20
+ csv << [
21
+ edge.source, edge.target, edge.count, edge.external,
22
+ edge.import_kinds.join('|')
23
+ ]
24
+ end
25
+ end.rstrip
26
+ end
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'escaping'
4
+ require_relative 'rendering_support'
5
+
6
+ module ArchUnit
7
+ module GraphReporting
8
+ module Rendering
9
+ # Renders D2 diagram source from a report snapshot.
10
+ module D2Renderer
11
+ module_function
12
+
13
+ def render(snapshot)
14
+ snapshot = RenderingSupport.validate_snapshot(snapshot)
15
+ lines = ["# #{Escaping.single_line(snapshot.title)}"]
16
+ snapshot.nodes.each { |node| lines << Escaping.quoted(node.label) }
17
+ snapshot.edges.each { |edge| lines << edge_line(edge) }
18
+ lines.join("\n")
19
+ end
20
+
21
+ def edge_line(edge)
22
+ label = edge.count > 1 ? ": #{Escaping.quoted(edge.count.to_s)}" : ''
23
+ style = edge.external ? ' { style.stroke-dash: 4 }' : ''
24
+ "#{Escaping.quoted(edge.source)} -> #{Escaping.quoted(edge.target)}#{label}#{style}"
25
+ end
26
+ private_class_method :edge_line
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'escaping'
4
+ require_relative 'rendering_support'
5
+
6
+ module ArchUnit
7
+ module GraphReporting
8
+ module Rendering
9
+ # Renders a Graphviz DOT directed graph from a report snapshot.
10
+ module DotRenderer
11
+ module_function
12
+
13
+ def render(snapshot)
14
+ snapshot = RenderingSupport.validate_snapshot(snapshot)
15
+ lines = header(snapshot)
16
+ snapshot.nodes.each { |node| lines << " #{Escaping.quoted(node.label)};" }
17
+ snapshot.edges.each { |edge| lines << edge_line(edge) }
18
+ lines << '}'
19
+ lines.join("\n")
20
+ end
21
+
22
+ def header(snapshot)
23
+ [
24
+ 'digraph dependencies {',
25
+ ' rankdir=LR;',
26
+ " label=#{Escaping.quoted(snapshot.title)};",
27
+ ' labelloc=t;'
28
+ ]
29
+ end
30
+ private_class_method :header
31
+
32
+ def edge_line(edge)
33
+ attributes = edge_attributes(edge)
34
+ suffix = attributes.empty? ? '' : " [#{attributes.join(', ')}]"
35
+ " #{Escaping.quoted(edge.source)} -> #{Escaping.quoted(edge.target)}#{suffix};"
36
+ end
37
+ private_class_method :edge_line
38
+
39
+ def edge_attributes(edge)
40
+ attributes = []
41
+ attributes << "label=#{Escaping.quoted(edge.count.to_s)}" if edge.count > 1
42
+ attributes << 'style=dashed' if edge.external
43
+ return attributes if edge.import_kinds.empty?
44
+
45
+ attributes << "tooltip=#{Escaping.quoted(edge.import_kinds.join(', '))}"
46
+ end
47
+ private_class_method :edge_attributes
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'cgi/escape'
4
+
5
+ module ArchUnit
6
+ module GraphReporting
7
+ module Rendering
8
+ # Format-specific escaping kept out of the renderers' graph logic.
9
+ module Escaping
10
+ module_function
11
+
12
+ def quoted(value)
13
+ escaped = value.gsub('\\', '\\\\').gsub('"', '\\"').gsub("\n", '\\n')
14
+ "\"#{escaped}\""
15
+ end
16
+
17
+ def mermaid_label(value)
18
+ CGI.escapeHTML(value).gsub("\n", '<br/>')
19
+ end
20
+
21
+ def html(value)
22
+ CGI.escapeHTML(value.to_s)
23
+ end
24
+
25
+ def single_line(value)
26
+ value.gsub(/[\r\n]+/, ' ')
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fileutils'
4
+
5
+ module ArchUnit
6
+ module GraphReporting
7
+ module Rendering
8
+ # UTF-8 file export shared by every graph report format.
9
+ module ExportReport
10
+ module_function
11
+
12
+ def write(output_path, content)
13
+ path = normalized_path(output_path)
14
+ FileUtils.mkdir_p(File.dirname(path)) unless File.dirname(path) == '.'
15
+ File.binwrite(path, content.encode(Encoding::UTF_8))
16
+ nil
17
+ end
18
+
19
+ def normalized_path(value)
20
+ value = value.to_path if value.respond_to?(:to_path)
21
+ return value if value.is_a?(String) && !value.empty?
22
+
23
+ raise ArgumentError, 'output_path must be a non-empty path'
24
+ end
25
+ private_class_method :normalized_path
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'csv_renderer'
4
+ require_relative 'd2_renderer'
5
+ require_relative 'dot_renderer'
6
+ require_relative 'export_report'
7
+ require_relative 'html_renderer'
8
+ require_relative 'json_renderer'
9
+ require_relative 'mermaid_renderer'
10
+
11
+ module ArchUnit
12
+ module GraphReporting
13
+ module Rendering
14
+ # One dispatch point for every renderer consuming the shared snapshot.
15
+ class GraphRenderer
16
+ RENDERERS = {
17
+ dot: DotRenderer, mermaid: MermaidRenderer, d2: D2Renderer,
18
+ csv: CsvRenderer, json: JsonRenderer, html: HtmlRenderer
19
+ }.freeze
20
+
21
+ class << self
22
+ def render(snapshot, format)
23
+ renderer(format).render(snapshot)
24
+ end
25
+
26
+ def export(snapshot, format, output_path)
27
+ ExportReport.write(output_path, render(snapshot, format))
28
+ end
29
+
30
+ RENDERERS.each_key do |format|
31
+ define_method("to_#{format}") do |snapshot|
32
+ render(snapshot, format)
33
+ end
34
+
35
+ define_method("export_as_#{format}") do |snapshot, output_path|
36
+ export(snapshot, format, output_path)
37
+ end
38
+ end
39
+
40
+ private
41
+
42
+ def renderer(format)
43
+ key = format.respond_to?(:to_sym) ? format.to_sym : format
44
+ RENDERERS.fetch(key)
45
+ rescue KeyError
46
+ raise ArgumentError, "unknown graph report format: #{format.inspect}"
47
+ end
48
+ end
49
+
50
+ private_class_method :new
51
+ end
52
+ end
53
+ end
54
+ end