archunit 0.0.1 → 1.0.0

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 (82) hide show
  1. checksums.yaml +4 -4
  2. data/API.md +290 -0
  3. data/CHANGELOG.md +45 -0
  4. data/README.md +389 -90
  5. data/assets/logo-rounded.png +0 -0
  6. data/lib/archunit/common/extraction/dependency_evidence.rb +44 -0
  7. data/lib/archunit/common/extraction/edge.rb +7 -2
  8. data/lib/archunit/common/extraction/import_kind.rb +2 -1
  9. data/lib/archunit/common/extraction/parse_diagnostic.rb +23 -0
  10. data/lib/archunit/common/fluentapi/check_options.rb +43 -2
  11. data/lib/archunit/common/fluentapi/checkable.rb +25 -1
  12. data/lib/archunit/common/logging/check_logger.rb +8 -0
  13. data/lib/archunit/common/logging/inspection.rb +59 -0
  14. data/lib/archunit/common/logging/logging_options.rb +5 -0
  15. data/lib/archunit/common/pattern.rb +18 -8
  16. data/lib/archunit/common/projection/cycles/johnson_cycles.rb +35 -19
  17. data/lib/archunit/common/projection/cycles/tarjan_scc.rb +30 -10
  18. data/lib/archunit/error/source_parse_error.rb +38 -0
  19. data/lib/archunit/extraction/arch_ignore.rb +91 -0
  20. data/lib/archunit/extraction/conservative_local_flow.rb +100 -0
  21. data/lib/archunit/extraction/constant_index.rb +110 -0
  22. data/lib/archunit/extraction/constant_visitor.rb +129 -0
  23. data/lib/archunit/extraction/discover_gemspec_load_paths.rb +96 -0
  24. data/lib/archunit/extraction/enumerate_source_files.rb +23 -8
  25. data/lib/archunit/extraction/extract_dependencies.rb +82 -14
  26. data/lib/archunit/extraction/extract_graph.rb +56 -12
  27. data/lib/archunit/extraction/extract_imports.rb +60 -124
  28. data/lib/archunit/extraction/extraction_profile.rb +105 -0
  29. data/lib/archunit/extraction/import_resolver.rb +56 -0
  30. data/lib/archunit/extraction/import_visitor.rb +104 -0
  31. data/lib/archunit/extraction/local_string_tracker.rb +80 -0
  32. data/lib/archunit/extraction/resolve_import.rb +5 -3
  33. data/lib/archunit/extraction/resolve_load_paths.rb +93 -0
  34. data/lib/archunit/extraction/resolved_import.rb +5 -2
  35. data/lib/archunit/extraction/validate_extraction_paths.rb +37 -0
  36. data/lib/archunit/files/extraction/extract_file_info.rb +14 -5
  37. data/lib/archunit/files/extraction/file_info.rb +1 -2
  38. data/lib/archunit/files/fluentapi/depend_on_external_module_condition.rb +2 -0
  39. data/lib/archunit/files/fluentapi/depend_on_external_module_condition_builder.rb +2 -0
  40. data/lib/archunit/files/fluentapi/depend_on_file_condition.rb +6 -0
  41. data/lib/archunit/files/fluentapi/depend_on_file_condition_builder.rb +6 -0
  42. data/lib/archunit/files/fluentapi/file_condition_builder.rb +12 -0
  43. data/lib/archunit/files/fluentapi/file_rule_support.rb +8 -1
  44. data/lib/archunit/files/fluentapi/files.rb +6 -0
  45. data/lib/archunit/files/fluentapi/match_pattern_file_condition_builder.rb +14 -0
  46. data/lib/archunit/files/fluentapi/positive_match_pattern_file_condition_builder.rb +2 -0
  47. data/lib/archunit/graph/fluentapi/graph.rb +5 -0
  48. data/lib/archunit/graph/fluentapi/project_graph_builder.rb +44 -0
  49. data/lib/archunit/graph/projection/aggregate_edges.rb +4 -2
  50. data/lib/archunit/graph/projection/graph_query_options.rb +1 -2
  51. data/lib/archunit/graph/projection/graph_report_edge.rb +12 -2
  52. data/lib/archunit/graph/rendering/json_renderer.rb +3 -1
  53. data/lib/archunit/layers/fluentapi/layer_definition_builder.rb +4 -0
  54. data/lib/archunit/layers/fluentapi/layer_dependency_rule_builder.rb +4 -0
  55. data/lib/archunit/layers/fluentapi/layered_architecture.rb +5 -0
  56. data/lib/archunit/layers/fluentapi/layers.rb +5 -0
  57. data/lib/archunit/metrics/extraction/extract_distance_info.rb +1 -2
  58. data/lib/archunit/metrics/extraction/extract_project_info.rb +6 -6
  59. data/lib/archunit/metrics/extraction/metric_info.rb +2 -4
  60. data/lib/archunit/metrics/extraction/source_metrics_visitor.rb +2 -4
  61. data/lib/archunit/metrics/fluentapi/custom_metric_builder.rb +2 -0
  62. data/lib/archunit/metrics/fluentapi/custom_metric_condition.rb +4 -1
  63. data/lib/archunit/metrics/fluentapi/logged_metric.rb +23 -0
  64. data/lib/archunit/metrics/fluentapi/metric_predicate_condition.rb +4 -1
  65. data/lib/archunit/metrics/fluentapi/metric_report_builder.rb +2 -0
  66. data/lib/archunit/metrics/fluentapi/metric_selection.rb +18 -2
  67. data/lib/archunit/metrics/fluentapi/metric_threshold_condition.rb +2 -1
  68. data/lib/archunit/metrics/fluentapi/metrics.rb +5 -0
  69. data/lib/archunit/metrics/fluentapi/metrics_builder.rb +15 -0
  70. data/lib/archunit/slices/fluentapi/diagram_slice_condition.rb +1 -0
  71. data/lib/archunit/slices/fluentapi/forbidden_slice_dependency_condition.rb +1 -0
  72. data/lib/archunit/slices/fluentapi/negative_slice_condition_builder.rb +2 -0
  73. data/lib/archunit/slices/fluentapi/positive_slice_condition_builder.rb +8 -0
  74. data/lib/archunit/slices/fluentapi/slice_scope_builder.rb +12 -0
  75. data/lib/archunit/slices/fluentapi/slices.rb +5 -0
  76. data/lib/archunit/testing/assert_passes.rb +4 -0
  77. data/lib/archunit/testing/result_factory.rb +16 -4
  78. data/lib/archunit/testing/rspec_adapter.rb +1 -2
  79. data/lib/archunit/testing.rb +7 -3
  80. data/lib/archunit/version.rb +1 -1
  81. data/lib/archunit.rb +9 -0
  82. metadata +20 -2
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'pathname'
4
+ require_relative '../error/user_error'
5
+ require_relative 'discover_gemspec_load_paths'
6
+
7
+ module ArchUnit
8
+ module Extraction
9
+ # Builds the deterministic feature search path used for static Ruby import resolution.
10
+ module LoadPaths
11
+ module_function
12
+
13
+ def resolve(project_root, explicit_paths: [], source_files: nil)
14
+ root = canonical_root(project_root)
15
+ defaults = [root.join('lib'), root].select(&:directory?)
16
+ explicit = resolve_explicit_paths(root, explicit_paths)
17
+ discovered = GemspecLoadPaths.resolve(root, source_files:)
18
+
19
+ deduplicate(defaults + explicit + discovered)
20
+ end
21
+
22
+ def canonical_root(value)
23
+ path = value.respond_to?(:to_path) ? value.to_path : value
24
+ unless path.is_a?(String) && !path.empty?
25
+ raise UserError, 'project_root must be a non-empty path'
26
+ end
27
+
28
+ root = Pathname.new(path).expand_path
29
+ raise UserError, 'project_root must be an existing directory' unless root.directory?
30
+
31
+ root.realpath
32
+ end
33
+ private_class_method :canonical_root
34
+
35
+ def resolve_explicit_paths(root, paths)
36
+ validate_explicit_paths(paths)
37
+ paths.map { |value| resolve_explicit_path(root, value) }
38
+ end
39
+ private_class_method :resolve_explicit_paths
40
+
41
+ def resolve_explicit_path(root, value)
42
+ candidate = Pathname.new(value.tr('\\', '/'))
43
+ candidate = root.join(candidate) unless candidate.absolute?
44
+ candidate = candidate.expand_path
45
+ validate_explicit_path(candidate, root, value)
46
+ candidate.realpath
47
+ end
48
+ private_class_method :resolve_explicit_path
49
+
50
+ def validate_explicit_path(candidate, root, value)
51
+ unless candidate.directory?
52
+ raise UserError, "load path must be an existing directory: #{value}"
53
+ end
54
+
55
+ canonical = candidate.realpath
56
+ return if within_project?(canonical, root)
57
+
58
+ raise UserError, "load path must stay inside the project root: #{value}"
59
+ end
60
+ private_class_method :validate_explicit_path
61
+
62
+ def validate_explicit_paths(paths)
63
+ valid = paths.is_a?(Array) && paths.all? do |path|
64
+ path.is_a?(String) && !path.empty?
65
+ end
66
+ return if valid
67
+
68
+ raise UserError, 'load_paths must be an Array of non-empty paths'
69
+ end
70
+ private_class_method :validate_explicit_paths
71
+
72
+ def within_project?(path, root)
73
+ path.relative_path_from(root).each_filename.first != '..'
74
+ rescue ArgumentError
75
+ false
76
+ end
77
+ private_class_method :within_project?
78
+
79
+ def deduplicate(paths)
80
+ paths.each_with_object([]) do |path, result|
81
+ value = normalized(path).freeze
82
+ result << value unless result.include?(value)
83
+ end.freeze
84
+ end
85
+ private_class_method :deduplicate
86
+
87
+ def normalized(path)
88
+ path.to_s.tr('\\', '/')
89
+ end
90
+ private_class_method :normalized
91
+ end
92
+ end
93
+ end
@@ -4,11 +4,14 @@ require_relative '../common/extraction/import_kind'
4
4
 
5
5
  module ArchUnit
6
6
  module Extraction
7
- ResolvedImport = Data.define(:module_name, :import_kind, :line_number, :resolved_path) do
8
- def initialize(module_name:, import_kind:, line_number:, resolved_path: nil)
7
+ ResolvedImport = Data.define(:module_name, :import_kind, :line_number, :resolved_path,
8
+ :end_line) do
9
+ def initialize(module_name:, import_kind:, line_number:, resolved_path: nil,
10
+ end_line: line_number)
9
11
  validate_module_name(module_name)
10
12
  validate_import_kind(import_kind)
11
13
  validate_line_number(line_number)
14
+ validate_line_number(end_line)
12
15
  validate_resolved_path(resolved_path)
13
16
 
14
17
  module_name = module_name.dup.freeze
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'pathname'
4
+ require_relative '../error/user_error'
5
+
6
+ module ArchUnit
7
+ module Extraction
8
+ # Validates public extractor paths before the trusted internal pipeline runs.
9
+ module ExtractionPaths
10
+ module_function
11
+
12
+ def source_file(value)
13
+ path = extraction_path(value, 'source_file')
14
+ raise UserError, 'source_file must be an existing file' unless path.file?
15
+
16
+ path
17
+ end
18
+
19
+ def project_root(value)
20
+ path = extraction_path(value, 'project_root')
21
+ raise UserError, 'project_root must be an existing directory' unless path.directory?
22
+
23
+ path.realpath
24
+ end
25
+
26
+ def extraction_path(value, attribute)
27
+ value = value.to_path if value.respond_to?(:to_path)
28
+ unless value.is_a?(String) && !value.empty?
29
+ raise UserError, "#{attribute} must be a non-empty path"
30
+ end
31
+
32
+ Pathname.new(value).expand_path
33
+ end
34
+ private_class_method :extraction_path
35
+ end
36
+ end
37
+ end
@@ -63,16 +63,25 @@ module ArchUnit
63
63
 
64
64
  def source_path_within(root, identifier)
65
65
  path = root.join(identifier).cleanpath
66
- relative = path.relative_path_from(root).to_s.tr('\\', '/')
67
- if relative == '..' || relative.start_with?('../')
68
- raise TechnicalError, 'source file must be inside the project root'
69
- end
66
+ ensure_inside_root(root, path)
70
67
  raise TechnicalError, "source file does not exist: #{identifier}" unless path.file?
71
68
 
72
- path
69
+ resolved = path.realpath
70
+ ensure_inside_root(root, resolved)
71
+ resolved
73
72
  end
74
73
  private_class_method :source_path_within
75
74
 
75
+ def ensure_inside_root(root, path)
76
+ relative = path.relative_path_from(root).to_s.tr('\\', '/')
77
+ return unless relative == '..' || relative.start_with?('../')
78
+
79
+ raise TechnicalError, 'source file must be inside the project root'
80
+ rescue ArgumentError
81
+ raise TechnicalError, 'source file must be inside the project root'
82
+ end
83
+ private_class_method :ensure_inside_root
84
+
76
85
  def directory_of(identifier)
77
86
  return '' unless identifier.include?('/')
78
87
 
@@ -8,7 +8,7 @@ module ArchUnit
8
8
  FileInfo = Data.define(
9
9
  :path, :name, :extension, :directory, :content, :lines_of_code
10
10
  ) do
11
- # rubocop:disable Metrics/ParameterLists -- FileInfo has six required public fields.
11
+ # rubocop:disable-next Metrics/ParameterLists -- FileInfo has six required public fields.
12
12
  def initialize(path:, name:, extension:, directory:, content:, lines_of_code:)
13
13
  path = immutable_string(path, :path, allow_empty: false, normalize_path: true)
14
14
  name = immutable_string(name, :name, allow_empty: false)
@@ -20,7 +20,6 @@ module ArchUnit
20
20
  lines_of_code = non_negative_integer(lines_of_code)
21
21
  super
22
22
  end
23
- # rubocop:enable Metrics/ParameterLists
24
23
 
25
24
  private
26
25
 
@@ -34,6 +34,8 @@ module ArchUnit
34
34
  is_negated
35
35
  end
36
36
 
37
+ # Adds another external module pattern using OR semantics.
38
+ # @return [DependOnExternalModuleCondition]
37
39
  def matching(module_name, except: nil)
38
40
  filter = Common::RegexFactory.path_matcher(module_name, except:)
39
41
  self.class.new(builder, module_filters: [*module_filters, filter])
@@ -26,6 +26,8 @@ module ArchUnit
26
26
  is_negated
27
27
  end
28
28
 
29
+ # Selects an allowed or forbidden external module name.
30
+ # @return [DependOnExternalModuleCondition]
29
31
  def matching(module_name, except: nil)
30
32
  filter = Common::RegexFactory.path_matcher(module_name, except:)
31
33
  DependOnExternalModuleCondition.new(self, module_filters: [filter])
@@ -34,14 +34,20 @@ module ArchUnit
34
34
  is_negated
35
35
  end
36
36
 
37
+ # Adds another allowed or forbidden basename using OR semantics.
38
+ # @return [DependOnFileCondition]
37
39
  def with_name(pattern, except: nil)
38
40
  with_filter(Common::RegexFactory.filename_matcher(pattern, except:))
39
41
  end
40
42
 
43
+ # Adds another allowed or forbidden folder using OR semantics.
44
+ # @return [DependOnFileCondition]
41
45
  def in_folder(pattern, except: nil)
42
46
  with_filter(Common::RegexFactory.folder_matcher(pattern, except:))
43
47
  end
44
48
 
49
+ # Adds another allowed or forbidden path using OR semantics.
50
+ # @return [DependOnFileCondition]
45
51
  def in_path(pattern, except: nil)
46
52
  with_filter(Common::RegexFactory.path_matcher(pattern, except:))
47
53
  end
@@ -26,14 +26,20 @@ module ArchUnit
26
26
  is_negated
27
27
  end
28
28
 
29
+ # Selects dependency targets by basename.
30
+ # @return [DependOnFileCondition]
29
31
  def with_name(pattern, except: nil)
30
32
  condition(Common::RegexFactory.filename_matcher(pattern, except:))
31
33
  end
32
34
 
35
+ # Selects dependency targets by containing folder.
36
+ # @return [DependOnFileCondition]
33
37
  def in_folder(pattern, except: nil)
34
38
  condition(Common::RegexFactory.folder_matcher(pattern, except:))
35
39
  end
36
40
 
41
+ # Selects dependency targets by complete path.
42
+ # @return [DependOnFileCondition]
37
43
  def in_path(pattern, except: nil)
38
44
  condition(Common::RegexFactory.path_matcher(pattern, except:))
39
45
  end
@@ -16,26 +16,38 @@ module ArchUnit
16
16
  freeze
17
17
  end
18
18
 
19
+ # Selects files by basename.
20
+ # @return [FileConditionBuilder]
19
21
  def with_name(pattern, except: nil)
20
22
  with_filter(Common::RegexFactory.filename_matcher(pattern, except:))
21
23
  end
22
24
 
25
+ # Selects files by project-relative containing folder.
26
+ # @return [FileConditionBuilder]
23
27
  def in_folder(pattern, except: nil)
24
28
  with_filter(Common::RegexFactory.folder_matcher(pattern, except:))
25
29
  end
26
30
 
31
+ # Selects files by complete project-relative path.
32
+ # @return [FileConditionBuilder]
27
33
  def in_path(pattern, except: nil)
28
34
  with_filter(Common::RegexFactory.path_matcher(pattern, except:))
29
35
  end
30
36
 
37
+ # Selects one exact project-relative file.
38
+ # @return [FileConditionBuilder]
31
39
  def in_file(file_path, except: nil)
32
40
  with_filter(Common::RegexFactory.exact_file_matcher(file_path, except:))
33
41
  end
34
42
 
43
+ # Enters the positive rule mood.
44
+ # @return [PositiveMatchPatternFileConditionBuilder]
35
45
  def should
36
46
  PositiveMatchPatternFileConditionBuilder.new(self)
37
47
  end
38
48
 
49
+ # Enters the negated rule mood.
50
+ # @return [NegatedMatchPatternFileConditionBuilder]
39
51
  def should_not
40
52
  NegatedMatchPatternFileConditionBuilder.new(self)
41
53
  end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative '../../common/pattern_matching'
4
+ require_relative '../../common/logging/inspection'
4
5
  require_relative '../../common/projection/project_to_nodes'
5
6
 
6
7
  module ArchUnit
@@ -12,12 +13,18 @@ module ArchUnit
12
13
 
13
14
  def selected_nodes(graph, filters)
14
15
  nodes = Common::Projection.project_to_nodes(graph)
15
- return nodes if filters.empty?
16
+ selected = filters.empty? ? nodes : matching_nodes(nodes, filters)
17
+ Common::Logging::Inspection.selection(
18
+ 'selected file', selected, &:label
19
+ )
20
+ end
16
21
 
22
+ def matching_nodes(nodes, filters)
17
23
  nodes.select do |node|
18
24
  Common::PatternMatching.matches_all_patterns?(node.label, filters)
19
25
  end
20
26
  end
27
+ private_class_method :matching_nodes
21
28
  end
22
29
  end
23
30
  end
@@ -11,6 +11,9 @@ module ArchUnit
11
11
  module FluentApi
12
12
  module_function
13
13
 
14
+ # Starts an immutable file-rule sentence.
15
+ # @param project_locator [String, Pathname, nil] project directory, Gemfile, or gemspec
16
+ # @return [FileConditionBuilder]
14
17
  def project_files(project_locator = nil)
15
18
  FileConditionBuilder.new(project_locator:)
16
19
  end
@@ -21,6 +24,9 @@ module ArchUnit
21
24
  end
22
25
  end
23
26
 
27
+ # Starts an immutable file-rule sentence.
28
+ # @param project_locator [String, Pathname, nil] project directory, Gemfile, or gemspec
29
+ # @return [Files::FluentApi::FileConditionBuilder]
24
30
  def self.project_files(project_locator = nil)
25
31
  Files::FluentApi.project_files(project_locator)
26
32
  end
@@ -31,26 +31,40 @@ module ArchUnit
31
31
  @negated
32
32
  end
33
33
 
34
+ # Requires selected files to match a basename pattern.
35
+ # @return [MatchPatternFileCondition]
34
36
  def have_name(pattern, except: nil)
35
37
  matching(Common::RegexFactory.filename_matcher(pattern, except:))
36
38
  end
37
39
 
40
+ # Requires selected files to match a containing-folder pattern.
41
+ # @return [MatchPatternFileCondition]
38
42
  def be_in_folder(pattern, except: nil)
39
43
  matching(Common::RegexFactory.folder_matcher(pattern, except:))
40
44
  end
41
45
 
46
+ # Requires selected files to match a complete-path pattern.
47
+ # @return [MatchPatternFileCondition]
42
48
  def be_in_path(pattern, except: nil)
43
49
  matching(Common::RegexFactory.path_matcher(pattern, except:))
44
50
  end
45
51
 
52
+ # Starts the object stage for internal file dependencies.
53
+ # @return [DependOnFileConditionBuilder]
46
54
  def depend_on_files
47
55
  DependOnFileConditionBuilder.new(self)
48
56
  end
49
57
 
58
+ # Starts the object stage for standard-library and gem imports.
59
+ # @return [DependOnExternalModuleConditionBuilder]
50
60
  def depend_on_external_modules
51
61
  DependOnExternalModuleConditionBuilder.new(self)
52
62
  end
53
63
 
64
+ # Applies a custom predicate to each selected FileInfo.
65
+ # @param condition [#call] predicate receiving one FileInfo
66
+ # @param message [String] explanation used for violations
67
+ # @return [CustomFileCondition]
54
68
  def adhere_to(condition, message)
55
69
  CustomFileCondition.new(self, condition:, message:)
56
70
  end
@@ -12,6 +12,8 @@ module ArchUnit
12
12
  super(scope, negated: false)
13
13
  end
14
14
 
15
+ # Requires the selected internal dependency graph to be cycle-free.
16
+ # @return [CycleFreeFileCondition]
15
17
  def have_no_cycles
16
18
  CycleFreeFileCondition.new(self)
17
19
  end
@@ -9,6 +9,8 @@ module ArchUnit
9
9
  module FluentApi
10
10
  module_function
11
11
 
12
+ # Starts an immutable dependency-graph report.
13
+ # @return [ProjectGraphBuilder]
12
14
  def project_graph(project_locator = nil)
13
15
  ProjectGraphBuilder.new(project_locator:)
14
16
  end
@@ -19,6 +21,9 @@ module ArchUnit
19
21
  end
20
22
  end
21
23
 
24
+ # Starts an immutable dependency-graph report.
25
+ # @param project_locator [String, Pathname, nil] project directory, Gemfile, or gemspec
26
+ # @return [GraphReporting::FluentApi::ProjectGraphBuilder]
22
27
  def self.project_graph(project_locator = nil)
23
28
  GraphReporting::FluentApi.project_graph(project_locator)
24
29
  end
@@ -10,6 +10,30 @@ module ArchUnit
10
10
  module GraphReporting
11
11
  module FluentApi
12
12
  # Immutable query builder for dependency graph snapshots and reports.
13
+ # @!method to_dot
14
+ # @return [String] the current graph report as Graphviz DOT
15
+ # @!method to_mermaid
16
+ # @return [String] the current graph report as Mermaid
17
+ # @!method to_d2
18
+ # @return [String] the current graph report as D2
19
+ # @!method to_csv
20
+ # @return [String] the current graph report as CSV
21
+ # @!method to_json
22
+ # @return [String] the current graph report as JSON
23
+ # @!method to_html
24
+ # @return [String] the current graph report as self-contained HTML
25
+ # @!method export_as_dot(output_path)
26
+ # @return [nil] writes Graphviz DOT to output_path
27
+ # @!method export_as_mermaid(output_path)
28
+ # @return [nil] writes Mermaid to output_path
29
+ # @!method export_as_d2(output_path)
30
+ # @return [nil] writes D2 to output_path
31
+ # @!method export_as_csv(output_path)
32
+ # @return [nil] writes CSV to output_path
33
+ # @!method export_as_json(output_path)
34
+ # @return [nil] writes JSON to output_path
35
+ # @!method export_as_html(output_path)
36
+ # @return [nil] writes self-contained HTML to output_path
13
37
  class ProjectGraphBuilder
14
38
  attr_reader :project_locator, :options, :check_options
15
39
 
@@ -20,51 +44,71 @@ module ArchUnit
20
44
  freeze
21
45
  end
22
46
 
47
+ # Includes standard-library and gem imports in the report.
48
+ # @return [ProjectGraphBuilder]
23
49
  def include_external_dependencies
24
50
  with_options(options.with(include_external_dependencies: true))
25
51
  end
26
52
 
53
+ # Includes the self-edges that represent dependency-free source files.
54
+ # @return [ProjectGraphBuilder]
27
55
  def include_self_dependencies
28
56
  with_options(options.with(include_self_dependencies: true))
29
57
  end
30
58
 
59
+ # Keeps matching nodes and neighbors within depth hops.
60
+ # @return [ProjectGraphBuilder]
31
61
  def focus_on(pattern, depth = 1, except: nil)
32
62
  filter = Common::RegexFactory.path_matcher(pattern, except:)
33
63
  with_options(options.with(focus: filter, focus_depth: depth))
34
64
  end
35
65
 
66
+ # Keeps nodes reachable from matching starting nodes.
67
+ # @return [ProjectGraphBuilder]
36
68
  def reachable_from(pattern, except: nil)
37
69
  filter = Common::RegexFactory.path_matcher(pattern, except:)
38
70
  with_options(options.with(reachable_from: filter))
39
71
  end
40
72
 
73
+ # Keeps matching nodes and everything that depends on them.
74
+ # @return [ProjectGraphBuilder]
41
75
  def dependents_of(pattern, except: nil)
42
76
  filter = Common::RegexFactory.path_matcher(pattern, except:)
43
77
  with_options(options.with(dependents_of: filter))
44
78
  end
45
79
 
80
+ # Groups file nodes by the requested project-relative folder depth.
81
+ # @return [ProjectGraphBuilder]
46
82
  def collapse_to_folder_depth(depth)
47
83
  with_options(options.with(collapse: Projection::FolderDepthCollapse.new(depth:)))
48
84
  end
49
85
 
86
+ # Groups node names through a regular-expression replacement.
87
+ # @return [ProjectGraphBuilder]
50
88
  def collapse_by_pattern(pattern, replacement = '\\1')
51
89
  collapse = Projection::PatternCollapse.from(pattern, replacement)
52
90
  with_options(options.with(collapse:))
53
91
  end
54
92
 
93
+ # Sets the report title.
94
+ # @return [ProjectGraphBuilder]
55
95
  def titled(title)
56
96
  with_options(options.with(title:))
57
97
  end
58
98
 
99
+ # Attaches extraction-related CheckOptions to this report.
100
+ # @return [ProjectGraphBuilder]
59
101
  def with_check_options(value)
60
102
  self.class.new(project_locator:, options:, check_options: value)
61
103
  end
62
104
 
105
+ # @return [GraphReportSnapshot] the immutable queried graph
63
106
  def snapshot
64
107
  graph = ArchUnit::Extraction.extract_graph(project_locator, options: check_options)
65
108
  Projection::SnapshotFactory.create(graph, options)
66
109
  end
67
110
 
111
+ # @return [GraphReportSummary] counts for the current snapshot
68
112
  def summary
69
113
  snapshot.summary
70
114
  end
@@ -12,7 +12,7 @@ module ArchUnit
12
12
 
13
13
  def aggregate(edges, collapse:, include_self_dependencies:)
14
14
  groups = Hash.new do |hash, key|
15
- hash[key] = { count: 0, external: false, import_kinds: [] }
15
+ hash[key] = { count: 0, external: false, import_kinds: [], evidence: [] }
16
16
  end
17
17
 
18
18
  edges.each do |edge|
@@ -30,6 +30,7 @@ module ArchUnit
30
30
  group[:count] += 1
31
31
  group[:external] ||= edge.external
32
32
  group[:import_kinds] |= edge.import_kinds
33
+ group[:evidence] |= edge.evidence
33
34
  end
34
35
  private_class_method :accumulate
35
36
 
@@ -37,7 +38,8 @@ module ArchUnit
37
38
  groups.sort_by { |(source, target), _group| [source, target] }.map do |key, group|
38
39
  GraphReportEdge.new(
39
40
  source: key.first, target: key.last, count: group[:count],
40
- external: group[:external], import_kinds: group[:import_kinds]
41
+ external: group[:external], import_kinds: group[:import_kinds],
42
+ evidence: group[:evidence]
41
43
  )
42
44
  end.freeze
43
45
  end
@@ -21,7 +21,7 @@ module ArchUnit
21
21
  end
22
22
 
23
23
  # The options bag deliberately mirrors the eight independent fluent modifiers.
24
- # rubocop:disable Metrics/ParameterLists
24
+ # rubocop:disable-next Metrics/ParameterLists
25
25
  def initialize(
26
26
  include_external_dependencies: false, include_self_dependencies: false,
27
27
  focus: nil, focus_depth: 1, reachable_from: nil, dependents_of: nil,
@@ -37,7 +37,6 @@ module ArchUnit
37
37
  title = immutable_title(title)
38
38
  super
39
39
  end
40
- # rubocop:enable Metrics/ParameterLists
41
40
 
42
41
  def with(**overrides)
43
42
  self.class.new(**to_h, **overrides)
@@ -1,18 +1,28 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative '../../common/extraction/import_kind'
4
+ require_relative '../../common/extraction/dependency_evidence'
4
5
 
5
6
  module ArchUnit
6
7
  module GraphReporting
7
8
  module Projection
8
9
  # 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: [])
10
+ GraphReportEdge = Data.define(:source, :target, :count, :external, :import_kinds,
11
+ :evidence) do
12
+ # Keywords mirror this immutable report value.
13
+ def initialize( # rubocop:disable Metrics/ParameterLists
14
+ source:, target:, count:, external:, import_kinds: [], evidence: []
15
+ )
11
16
  source = immutable_string(source, :source)
12
17
  target = immutable_string(target, :target)
13
18
  validate_count(count)
14
19
  validate_external(external)
15
20
  import_kinds = immutable_import_kinds(import_kinds)
21
+ evidence = Array(evidence).uniq.freeze
22
+ unless evidence.all?(Common::Extraction::DependencyEvidence)
23
+ raise ArgumentError, 'evidence must contain DependencyEvidence values'
24
+ end
25
+
16
26
  super
17
27
  end
18
28
 
@@ -26,10 +26,12 @@ module ArchUnit
26
26
  private_class_method :snapshot_hash
27
27
 
28
28
  def edge_hash(edge)
29
- {
29
+ result = {
30
30
  source: edge.source, target: edge.target, count: edge.count,
31
31
  external: edge.external, import_kinds: edge.import_kinds
32
32
  }
33
+ result[:evidence] = edge.evidence.map(&:to_h) unless edge.evidence.empty?
34
+ result
33
35
  end
34
36
  private_class_method :edge_hash
35
37
  end
@@ -15,10 +15,14 @@ module ArchUnit
15
15
  freeze
16
16
  end
17
17
 
18
+ # Adds a full-path selector to this layer definition.
19
+ # @return [LayeredArchitecture]
18
20
  def defined_by(pattern, except: nil)
19
21
  add_filter(Common::RegexFactory.path_matcher(pattern, except:))
20
22
  end
21
23
 
24
+ # Adds a containing-folder selector to this layer definition.
25
+ # @return [LayeredArchitecture]
22
26
  def defined_by_folder(pattern, except: nil)
23
27
  add_filter(Common::RegexFactory.folder_matcher(pattern, except:))
24
28
  end
@@ -13,10 +13,14 @@ module ArchUnit
13
13
  freeze
14
14
  end
15
15
 
16
+ # Allows only the listed target layers; no targets seals the source layer.
17
+ # @return [LayeredArchitecture]
16
18
  def may_only_depend_on_layers(*layer_names)
17
19
  architecture.__send__(:with_allowed_dependencies, layer_name, layer_names)
18
20
  end
19
21
 
22
+ # Forbids dependencies on the listed target layers.
23
+ # @return [LayeredArchitecture]
20
24
  def may_not_depend_on_layers(*layer_names)
21
25
  if layer_names.empty?
22
26
  raise ArgumentError, 'may_not_depend_on_layers requires at least one layer name'
@@ -34,10 +34,14 @@ module ArchUnit
34
34
  freeze
35
35
  end
36
36
 
37
+ # Starts or extends the definition of a named layer.
38
+ # @return [LayerDefinitionBuilder]
37
39
  def layer(name)
38
40
  LayerDefinitionBuilder.new(self, validated_layer_name(name))
39
41
  end
40
42
 
43
+ # Selects a defined source layer for a dependency policy.
44
+ # @return [LayerDependencyRuleBuilder]
41
45
  def where_layer(name)
42
46
  name = validated_layer_name(name)
43
47
  ensure_defined_layer!(name)
@@ -71,6 +75,7 @@ module ArchUnit
71
75
  graph = ArchUnit::Extraction.extract_graph(project_locator, options:)
72
76
  nodes = Common::Projection.project_to_nodes(graph)
73
77
  edges = Common::Projection.project_edges(graph, Common::Projection.per_internal_edge)
78
+ Common::Logging::Inspection.projection(edges)
74
79
 
75
80
  empty_policy_violations(nodes, options) +
76
81
  Assertion.gather_layer_dependency_violations(