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
@@ -9,6 +9,8 @@ module ArchUnit
9
9
  module FluentApi
10
10
  module_function
11
11
 
12
+ # Starts an immutable named-layer policy.
13
+ # @return [LayeredArchitecture]
12
14
  def project_layers(project_locator = nil)
13
15
  LayeredArchitecture.new(project_locator:)
14
16
  end
@@ -19,6 +21,9 @@ module ArchUnit
19
21
  end
20
22
  end
21
23
 
24
+ # Starts an immutable named-layer policy.
25
+ # @param project_locator [String, Pathname, nil] project directory, Gemfile, or gemspec
26
+ # @return [Layers::FluentApi::LayeredArchitecture]
22
27
  def self.project_layers(project_locator = nil)
23
28
  Layers::FluentApi.project_layers(project_locator)
24
29
  end
@@ -30,7 +30,7 @@ module ArchUnit
30
30
  end
31
31
  private_class_method :build_distance_infos
32
32
 
33
- # rubocop:disable Metrics/AbcSize -- Both directional indexes are built in one graph pass.
33
+ # rubocop:disable-next Metrics/AbcSize -- Both directional indexes are built in one graph pass.
34
34
  def coupling_indexes(graph, file_paths)
35
35
  outgoing = Hash.new { |hash, key| hash[key] = [] }
36
36
  incoming = Hash.new { |hash, key| hash[key] = [] }
@@ -43,7 +43,6 @@ module ArchUnit
43
43
  end
44
44
  [outgoing, incoming]
45
45
  end
46
- # rubocop:enable Metrics/AbcSize
47
46
  private_class_method :coupling_indexes
48
47
  end
49
48
  end
@@ -3,6 +3,7 @@
3
3
  require 'pathname'
4
4
  require 'prism'
5
5
  require_relative '../../error/technical_error'
6
+ require_relative '../../error/source_parse_error'
6
7
  require_relative '../../extraction/enumerate_source_files'
7
8
  require_relative '../../extraction/locate_project'
8
9
  require_relative 'metric_info'
@@ -26,15 +27,15 @@ module ArchUnit
26
27
  path = source_path(source_file)
27
28
  identifier = (relative_path || path.basename.to_s).to_s.tr('\\', '/')
28
29
  source = path.read
29
- build_file_info(identifier, source)
30
+ result = SourceParseError.validate!(Prism.parse(source, filepath: path.to_s), path:)
31
+ build_file_info(identifier, source, result)
30
32
  rescue SystemCallError => e
31
33
  raise TechnicalError, "could not extract Ruby source metrics: #{e.message}"
32
34
  end
33
35
 
34
- def build_file_info(identifier, source)
35
- parse_result = Prism.parse(source)
36
+ def build_file_info(identifier, source, parse_result)
36
37
  visitor = SourceMetricsVisitor.new(identifier)
37
- visitor.visit(parse_result.value) if parse_result.success?
38
+ visitor.visit(parse_result.value)
38
39
  FileInfo.new(
39
40
  path: identifier, lines_of_code: lines_of_code(source), **visitor_counts(visitor)
40
41
  )
@@ -54,7 +55,7 @@ module ArchUnit
54
55
  end
55
56
  private_class_method :visitor_counts
56
57
 
57
- # rubocop:disable Metrics/MethodLength -- The state tracks Ruby's =begin/=end comments.
58
+ # rubocop:disable-next Metrics/MethodLength -- The state tracks Ruby's =begin/=end comments.
58
59
  def lines_of_code(source)
59
60
  in_block_comment = false
60
61
  source.each_line.count do |line|
@@ -70,7 +71,6 @@ module ArchUnit
70
71
  end
71
72
  end
72
73
  end
73
- # rubocop:enable Metrics/MethodLength
74
74
  private_class_method :lines_of_code
75
75
 
76
76
  def source_path(value)
@@ -70,7 +70,7 @@ module ArchUnit
70
70
  end
71
71
 
72
72
  # Static information about one Ruby class declaration.
73
- # rubocop:disable Lint/DataDefineOverride -- `methods` is the established metric vocabulary.
73
+ # rubocop:disable-next Lint/DataDefineOverride -- `methods` is the established metric vocabulary.
74
74
  ClassInfo = Data.define(:name, :file_path, :methods, :fields) do
75
75
  include ValueValidation
76
76
 
@@ -86,7 +86,6 @@ module ArchUnit
86
86
  "#{file_path}:#{name}"
87
87
  end
88
88
  end
89
- # rubocop:enable Lint/DataDefineOverride
90
89
 
91
90
  # Static counts and class declarations extracted from one Ruby source file.
92
91
  FileInfo = Data.define(
@@ -102,7 +101,7 @@ module ArchUnit
102
101
  ) do
103
102
  include ValueValidation
104
103
 
105
- # rubocop:disable Metrics/MethodLength, Metrics/ParameterLists -- Independent count facts.
104
+ # rubocop:disable-next Metrics/MethodLength, Metrics/ParameterLists -- Independent count facts.
106
105
  def initialize(
107
106
  path:,
108
107
  lines_of_code:,
@@ -131,7 +130,6 @@ module ArchUnit
131
130
 
132
131
  super
133
132
  end
134
- # rubocop:enable Metrics/MethodLength, Metrics/ParameterLists
135
133
 
136
134
  def identifier
137
135
  path
@@ -7,7 +7,7 @@ module ArchUnit
7
7
  module Metrics
8
8
  module Extraction
9
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.
10
+ # rubocop:disable-next Metrics/ClassLength -- One visitor owns the state of a single AST walk.
11
11
  class SourceMetricsVisitor < Prism::Visitor
12
12
  IMPORT_METHODS = %i[autoload load require require_relative].freeze
13
13
  ATTRIBUTE_METHODS = %i[attr_accessor attr_reader attr_writer].freeze
@@ -82,7 +82,7 @@ module ArchUnit
82
82
  @singleton_class_depth -= 1
83
83
  end
84
84
 
85
- # rubocop:disable Metrics/MethodLength -- The ensure keeps nested visitor state balanced.
85
+ # rubocop:disable-next Metrics/MethodLength -- The ensure keeps nested visitor state balanced.
86
86
  def visit_def_node(node)
87
87
  active_class = active_class_accumulator
88
88
  unless active_class
@@ -97,7 +97,6 @@ module ArchUnit
97
97
  ensure
98
98
  @method_stack.pop if active_class
99
99
  end
100
- # rubocop:enable Metrics/MethodLength
101
100
 
102
101
  INSTANCE_VARIABLE_NODES.each do |visitor_method|
103
102
  define_method(visitor_method) do |node|
@@ -271,7 +270,6 @@ module ArchUnit
271
270
  end
272
271
  end
273
272
  end
274
- # rubocop:enable Metrics/ClassLength
275
273
  private_constant :SourceMetricsVisitor
276
274
  end
277
275
  end
@@ -21,6 +21,8 @@ module ArchUnit
21
21
  super(scope:, metric:)
22
22
  end
23
23
 
24
+ # Creates a custom-metric rule retaining the configured description.
25
+ # @return [CustomMetricCondition]
24
26
  def should_satisfy(predicate)
25
27
  CustomMetricCondition.new(selection: self, predicate:)
26
28
  end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative '../../common/fluentapi/checkable'
4
+ require_relative 'logged_metric'
4
5
  require_relative '../assertion/custom_metric'
5
6
 
6
7
  module ArchUnit
@@ -32,7 +33,9 @@ module ArchUnit
32
33
  )
33
34
  return empty_test if empty_test
34
35
 
35
- Assertion.gather_custom_metric_violations(classes, selection.metric, predicate)
36
+ Assertion.gather_custom_metric_violations(
37
+ classes, LoggedMetric.wrap(selection.metric), predicate
38
+ )
36
39
  end
37
40
  end
38
41
  end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../../common/logging/inspection'
4
+
5
+ module ArchUnit
6
+ module Metrics
7
+ module FluentApi
8
+ # Observes the existing calculation once, including values that pass the rule.
9
+ module LoggedMetric
10
+ def self.wrap(metric)
11
+ logger = Common::Logging::Inspection.logger
12
+ return metric unless logger&.debug?
13
+
14
+ metric.with(calculation: lambda do |subject|
15
+ value = metric.calculate(subject)
16
+ logger.log_metric(name: metric.name, value:, subject: subject.identifier)
17
+ value
18
+ end)
19
+ end
20
+ end
21
+ end
22
+ end
23
+ end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative '../../common/fluentapi/checkable'
4
+ require_relative 'logged_metric'
4
5
  require_relative '../assertion/metric_predicate'
5
6
 
6
7
  module ArchUnit
@@ -34,7 +35,9 @@ module ArchUnit
34
35
  )
35
36
  return empty_test if empty_test
36
37
 
37
- Assertion.gather_metric_predicate_violations(subjects, selection.metric, predicate)
38
+ Assertion.gather_metric_predicate_violations(
39
+ subjects, LoggedMetric.wrap(selection.metric), predicate
40
+ )
38
41
  end
39
42
  end
40
43
  end
@@ -8,6 +8,8 @@ module ArchUnit
8
8
  module FluentApi
9
9
  # Shared HTML export terminal for one family of scoped metrics.
10
10
  module MetricReportBuilder
11
+ # Writes an offline HTML report for all metrics in this family.
12
+ # @return [nil]
11
13
  def export_as_html(output_path, options = nil)
12
14
  options = Reporting::MetricsExportOptions.resolve(options).with(output_path:)
13
15
  Reporting::MetricsExporter.export_as_html(metric_report_data, options)
@@ -9,6 +9,16 @@ module ArchUnit
9
9
  module Metrics
10
10
  module FluentApi
11
11
  # Lazy selection of one metric over a metrics scope.
12
+ # @!method should_be_below(threshold)
13
+ # @return [MetricThresholdCondition]
14
+ # @!method should_be_above(threshold)
15
+ # @return [MetricThresholdCondition]
16
+ # @!method should_be(threshold)
17
+ # @return [MetricThresholdCondition]
18
+ # @!method should_be_below_or_equal(threshold)
19
+ # @return [MetricThresholdCondition]
20
+ # @!method should_be_above_or_equal(threshold)
21
+ # @return [MetricThresholdCondition]
12
22
  class MetricSelection
13
23
  THRESHOLD_METHODS = {
14
24
  should_be_below: :below,
@@ -29,8 +39,12 @@ module ArchUnit
29
39
  freeze
30
40
  end
31
41
 
32
- def measure
33
- scope.__send__(:subjects_for, metric.subject_type).map do |subject|
42
+ # Evaluates this metric for every selected subject.
43
+ # @param options [CheckOptions, nil] dependency-analysis options for coupling metrics
44
+ # @return [Array<MetricMeasurement>]
45
+ def measure(options = nil)
46
+ options = Common::FluentApi::CheckOptions.resolve(options)
47
+ scope.__send__(:subjects_for, metric.subject_type, options:).map do |subject|
34
48
  MetricMeasurement.new(
35
49
  subject:,
36
50
  metric_name: metric.name,
@@ -45,6 +59,8 @@ module ArchUnit
45
59
  end
46
60
  end
47
61
 
62
+ # Creates a rule from a custom predicate over value and subject.
63
+ # @return [MetricPredicateCondition]
48
64
  def should_satisfy(predicate)
49
65
  MetricPredicateCondition.new(selection: self, predicate:)
50
66
  end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative '../../common/fluentapi/checkable'
4
+ require_relative 'logged_metric'
4
5
  require_relative '../assertion/metric_threshold'
5
6
 
6
7
  module ArchUnit
@@ -38,7 +39,7 @@ module ArchUnit
38
39
  return empty_test if empty_test
39
40
 
40
41
  Assertion.gather_metric_threshold_violations(
41
- subjects, selection.metric, comparison, threshold
42
+ subjects, LoggedMetric.wrap(selection.metric), comparison, threshold
42
43
  )
43
44
  end
44
45
  end
@@ -9,12 +9,17 @@ module ArchUnit
9
9
  module FluentApi
10
10
  module_function
11
11
 
12
+ # Starts an immutable source-metrics scope.
13
+ # @return [MetricsBuilder]
12
14
  def metrics(project_locator = nil)
13
15
  MetricsBuilder.new(project_locator:)
14
16
  end
15
17
  end
16
18
  end
17
19
 
20
+ # Starts an immutable source-metrics scope.
21
+ # @param project_locator [String, Pathname, nil] project directory, Gemfile, or gemspec
22
+ # @return [Metrics::FluentApi::MetricsBuilder]
18
23
  def self.metrics(project_locator = nil)
19
24
  Metrics::FluentApi.metrics(project_locator)
20
25
  end
@@ -23,41 +23,56 @@ module ArchUnit
23
23
  freeze
24
24
  end
25
25
 
26
+ # Selects metric source files by basename.
27
+ # @return [MetricsBuilder]
26
28
  def with_name(pattern, except: nil)
27
29
  with_filter(Common::RegexFactory.filename_matcher(pattern, except:))
28
30
  end
29
31
 
32
+ # Selects metric source files by containing folder.
33
+ # @return [MetricsBuilder]
30
34
  def in_folder(pattern, except: nil)
31
35
  with_filter(Common::RegexFactory.folder_matcher(pattern, except:))
32
36
  end
33
37
 
38
+ # Selects metric source files by complete path.
39
+ # @return [MetricsBuilder]
34
40
  def in_path(pattern, except: nil)
35
41
  with_filter(Common::RegexFactory.path_matcher(pattern, except:))
36
42
  end
37
43
 
44
+ # Selects classes within the selected source files.
45
+ # @return [MetricsBuilder]
38
46
  def for_classes_matching(pattern, except: nil)
39
47
  with_filter(Common::RegexFactory.classname_matcher(pattern, except:))
40
48
  end
41
49
 
50
+ # @return [CountMetricsBuilder] count metric vocabulary
42
51
  def count
43
52
  CountMetricsBuilder.new(self)
44
53
  end
45
54
 
55
+ # @return [LCOMMetricsBuilder] cohesion metric vocabulary
46
56
  def lcom
47
57
  LCOMMetricsBuilder.new(self)
48
58
  end
49
59
 
60
+ # @return [DistanceMetricsBuilder] dependency-distance metric vocabulary
50
61
  def distance
51
62
  DistanceMetricsBuilder.new(self)
52
63
  end
53
64
 
65
+ # Defines a custom numeric calculation over ClassInfo.
66
+ # @return [CustomMetricBuilder]
54
67
  def custom_metric(name, description, calculation)
55
68
  CustomMetricBuilder.new(scope: self, name:, description:, calculation:)
56
69
  end
57
70
 
71
+ # @return [MetricProjectInfo] extracted files and classes in this scope
58
72
  def analyze
59
73
  project = Extraction.extract_project_info(project_locator)
60
74
  selected_files = project.files.filter_map { |file| selected_file_info(file) }
75
+ Common::Logging::Inspection.selection('metric file', selected_files, &:path)
61
76
  Extraction::ProjectInfo.new(project_root: project.project_root, files: selected_files)
62
77
  end
63
78
 
@@ -39,6 +39,7 @@ module ArchUnit
39
39
 
40
40
  diagram = Uml::PlantUmlParser.parse(diagram_source.read)
41
41
  edges = Common::Projection.project_edges(graph, projection)
42
+ Common::Logging::Inspection.projection(edges)
42
43
  Assertion.gather_diagram_adherence_violations(edges, diagram, options)
43
44
  end
44
45
 
@@ -41,6 +41,7 @@ module ArchUnit
41
41
  return empty_test if empty_test
42
42
 
43
43
  edges = Common::Projection.project_edges(graph, projection)
44
+ Common::Logging::Inspection.projection(edges)
44
45
  Assertion.gather_forbidden_slice_dependency_violations(
45
46
  edges, source_slice, target_slice
46
47
  )
@@ -18,6 +18,8 @@ module ArchUnit
18
18
  freeze
19
19
  end
20
20
 
21
+ # Forbids a directed dependency between two named slices.
22
+ # @return [ForbiddenSliceDependencyCondition]
21
23
  def contain_dependency(source_slice, target_slice)
22
24
  ForbiddenSliceDependencyCondition.new(scope, source_slice:, target_slice:)
23
25
  end
@@ -24,18 +24,26 @@ module ArchUnit
24
24
  freeze
25
25
  end
26
26
 
27
+ # Ignores slices that are absent from the diagram and have no dependency edge.
28
+ # @return [PositiveSliceConditionBuilder]
27
29
  def ignoring_orphan_slices
28
30
  copy(options.with(ignore_orphan_slices: true))
29
31
  end
30
32
 
33
+ # Ignores dependencies to external modules during diagram comparison.
34
+ # @return [PositiveSliceConditionBuilder]
31
35
  def ignoring_external_slices
32
36
  copy(options.with(ignore_external_slices: true))
33
37
  end
34
38
 
39
+ # Requires the slice graph to match inline PlantUML.
40
+ # @return [DiagramSliceCondition]
35
41
  def adhere_to_diagram(text)
36
42
  DiagramSliceCondition.new(scope, DiagramSource.inline(text), options:)
37
43
  end
38
44
 
45
+ # Requires the slice graph to match a PlantUML file.
46
+ # @return [DiagramSliceCondition]
39
47
  def adhere_to_diagram_in_file(path)
40
48
  DiagramSliceCondition.new(scope, DiagramSource.file(path), options:)
41
49
  end
@@ -22,28 +22,40 @@ module ArchUnit
22
22
  freeze
23
23
  end
24
24
 
25
+ # Captures slice names with the `(**)` segment in a glob pattern.
26
+ # @return [SliceScopeBuilder]
25
27
  def defined_by(pattern, except: nil)
26
28
  copy(projection: Projection.slice_by_pattern(pattern, except:))
27
29
  end
28
30
 
31
+ # Captures slice names with the first group in a regular expression.
32
+ # @return [SliceScopeBuilder]
29
33
  def defined_by_regex(regexp, except: nil)
30
34
  copy(projection: Projection.slice_by_regex(regexp, except:))
31
35
  end
32
36
 
37
+ # Enters the negated slice-rule mood.
38
+ # @return [NegativeSliceConditionBuilder]
33
39
  def should_not
34
40
  NegativeSliceConditionBuilder.new(self)
35
41
  end
36
42
 
43
+ # Enters the positive slice-rule mood.
44
+ # @return [PositiveSliceConditionBuilder]
37
45
  def should
38
46
  PositiveSliceConditionBuilder.new(self)
39
47
  end
40
48
 
49
+ # Renders the real slice graph as PlantUML.
50
+ # @return [String]
41
51
  def to_plantuml(options = nil)
42
52
  graph = extract_graph(options)
43
53
  edges = Common::Projection.project_edges(graph, projection)
44
54
  Uml::PlantUmlRenderer.render(edges, components: projection.slice_labels(graph))
45
55
  end
46
56
 
57
+ # Writes the real slice graph as PlantUML.
58
+ # @return [nil]
47
59
  def export_as_plantuml(output_path, options = nil)
48
60
  graph = extract_graph(options)
49
61
  edges = Common::Projection.project_edges(graph, projection)
@@ -9,6 +9,8 @@ module ArchUnit
9
9
  module FluentApi
10
10
  module_function
11
11
 
12
+ # Starts an immutable slice-rule sentence.
13
+ # @return [SliceScopeBuilder]
12
14
  def project_slices(project_locator = nil)
13
15
  SliceScopeBuilder.new(project_locator:)
14
16
  end
@@ -19,6 +21,9 @@ module ArchUnit
19
21
  end
20
22
  end
21
23
 
24
+ # Starts an immutable slice-rule sentence.
25
+ # @param project_locator [String, Pathname, nil] project directory, Gemfile, or gemspec
26
+ # @return [Slices::FluentApi::SliceScopeBuilder]
22
27
  def self.project_slices(project_locator = nil)
23
28
  Slices::FluentApi.project_slices(project_locator)
24
29
  end
@@ -10,6 +10,8 @@ module ArchUnit
10
10
  module Testing
11
11
  module_function
12
12
 
13
+ # Raises AssertionFailure when a rule returns violations.
14
+ # @return [nil]
13
15
  def assert_passes(rule, options = nil)
14
16
  result = result_for(rule, options)
15
17
  return if result.passed?
@@ -18,6 +20,8 @@ module ArchUnit
18
20
  end
19
21
  end
20
22
 
23
+ # Raises AssertionFailure when a rule returns violations.
24
+ # @return [nil]
21
25
  def self.assert_passes(rule, options = nil)
22
26
  Testing.assert_passes(rule, options)
23
27
  end
@@ -11,17 +11,29 @@ module ArchUnit
11
11
  # Shapes a violation collection into one framework-neutral test result.
12
12
  class ResultFactory
13
13
  class << self
14
- def from_violations(violations, color: nil, expected_to_pass: true)
14
+ def from_violations(violations, color: nil, expected_to_pass: true, rationale: nil)
15
15
  validate_violations(violations)
16
16
  validate_expected_to_pass(expected_to_pass)
17
17
  color = resolve_color(color)
18
- return empty_result(color, expected_to_pass) if violations.empty?
19
-
20
- failing_result(violations, color, expected_to_pass)
18
+ result = if violations.empty?
19
+ empty_result(color, expected_to_pass)
20
+ else
21
+ failing_result(violations, color, expected_to_pass)
22
+ end
23
+ explain_result(result, rationale)
21
24
  end
22
25
 
23
26
  private
24
27
 
28
+ def explain_result(result, rationale)
29
+ unless rationale.nil? || (rationale.is_a?(String) && !rationale.strip.empty?)
30
+ raise ArgumentError, 'rationale must be a non-empty String or nil'
31
+ end
32
+ return result unless rationale && result.failed?
33
+
34
+ TestResult.new(passed: false, message: "#{result.message}\n\nBecause: #{rationale.strip}")
35
+ end
36
+
25
37
  def passing_result(color)
26
38
  message = ColorUtils.green('No architecture violations found.', enabled: color)
27
39
  TestResult.new(passed: true, message:)
@@ -13,11 +13,10 @@ module ArchUnit
13
13
  end
14
14
 
15
15
  # RSpec's matcher protocol fixes this method name.
16
- # rubocop:disable Naming/PredicatePrefix
16
+ # rubocop:disable-next Naming/PredicatePrefix
17
17
  def does_not_match?(rule)
18
18
  matches_expectation?(rule, expected_to_pass: false)
19
19
  end
20
- # rubocop:enable Naming/PredicatePrefix
21
20
 
22
21
  def failure_message
23
22
  result.message
@@ -14,22 +14,26 @@ module ArchUnit
14
14
  module Testing
15
15
  module_function
16
16
 
17
+ # Formats structured violations for a terminal or test failure.
18
+ # @return [String]
17
19
  def format_violations(violations, color: nil)
18
20
  ResultFactory.from_violations(violations, color:).message
19
21
  end
20
22
 
23
+ # Converts a rule check into a framework-neutral TestResult.
24
+ # @return [TestResult]
21
25
  def result_for(rule, options = nil, expected_to_pass: true)
22
26
  unless rule.is_a?(Common::FluentApi::Checkable)
23
27
  raise ArgumentError, 'rule must implement Checkable'
24
28
  end
25
29
 
26
30
  violations = rule.check(options)
27
- return ResultFactory.from_violations(violations) if expected_to_pass
28
-
29
- ResultFactory.from_violations(violations, expected_to_pass: false)
31
+ ResultFactory.from_violations(violations, expected_to_pass:, rationale: rule.rationale)
30
32
  end
31
33
  end
32
34
 
35
+ # Formats structured violations for a terminal or test failure.
36
+ # @return [String]
33
37
  def self.format_violations(violations, color: nil)
34
38
  Testing.format_violations(violations, color:)
35
39
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ArchUnit
4
- VERSION = '0.0.1'
4
+ VERSION = '1.0.0'
5
5
  end
data/lib/archunit.rb CHANGED
@@ -2,6 +2,7 @@
2
2
 
3
3
  require_relative 'archunit/version'
4
4
  require_relative 'archunit/error/technical_error'
5
+ require_relative 'archunit/error/source_parse_error'
5
6
  require_relative 'archunit/error/user_error'
6
7
  require_relative 'archunit/common/pattern'
7
8
  require_relative 'archunit/common/filter'
@@ -25,8 +26,11 @@ require_relative 'archunit/common/projection/project_to_nodes'
25
26
  require_relative 'archunit/common/projection/project_cycles'
26
27
  require_relative 'archunit/extraction/locate_project'
27
28
  require_relative 'archunit/extraction/enumerate_source_files'
29
+ require_relative 'archunit/extraction/resolve_load_paths'
30
+ require_relative 'archunit/extraction/extraction_profile'
28
31
  require_relative 'archunit/extraction/resolved_import'
29
32
  require_relative 'archunit/extraction/resolve_import'
33
+ require_relative 'archunit/extraction/import_resolver'
30
34
  require_relative 'archunit/extraction/extract_imports'
31
35
  require_relative 'archunit/extraction/extract_dependencies'
32
36
  require_relative 'archunit/extraction/extract_graph'
@@ -50,6 +54,8 @@ module ArchUnit
50
54
  Checkable = Common::FluentApi::Checkable
51
55
  ImportKind = Common::Extraction::ImportKind
52
56
  Edge = Common::Extraction::Edge
57
+ DependencyEvidence = Common::Extraction::DependencyEvidence
58
+ ParseDiagnostic = Common::Extraction::ParseDiagnostic
53
59
  Graph = Common::Extraction::Graph
54
60
  MappedEdge = Common::Projection::MappedEdge
55
61
  ProjectedEdge = Common::Projection::ProjectedEdge
@@ -104,6 +110,9 @@ module ArchUnit
104
110
  ResultFactory = Testing::ResultFactory
105
111
  AssertionFailure = Testing::AssertionFailure
106
112
 
113
+ # Clears every cached dependency graph in the current process.
114
+ # Use this after changing analyzed source files between checks.
115
+ # @return [nil]
107
116
  def self.clear_graph_cache
108
117
  Extraction.clear_graph_cache
109
118
  end